feat: v2.14 2D 图纸样式对齐源站设计图(官方 PNG 逐元素比对)
- 玻璃 teal 渐变 + 深色细描边(官方配色) - 型材立体三段线: 深轮廓+紫灰主体+白高光, 外框/中梃/扇料框统一 - 四角白色角码; 把手+锁点; 玻璃编号标签(polyId 树寻址定位) - 开向符号对齐官方: 平开虚线大三角(按 openDirection), 推拉滑行箭头 - 尺寸标注改官方蓝色带端部刻度; CombineCanvas 配色同步 - 验证: 704 画布与官方平面图逐元素一致, ui-shots 6 页无报错
This commit is contained in:
@@ -177,6 +177,19 @@ function onMullionPos() {
|
||||
type Replay = ReturnType<typeof replayShape>
|
||||
let replays: Replay[] = []
|
||||
|
||||
// ============ 图纸样式(对齐源站设计图渲染) ============
|
||||
const STYLE = {
|
||||
profile: '#8f8fa6', // 型材紫灰主色
|
||||
profileDark: '#585866', // 型材外轮廓
|
||||
highlight: '#ffffff', // 型材高光
|
||||
glass: '#2fb3ab', // 玻璃 teal
|
||||
glassHi: '#49c6be',
|
||||
glassDeep: '#1f8b85',
|
||||
label: '#39414c', // 玻璃编号文字
|
||||
dim: '#2b6cb0', // 尺寸标注蓝
|
||||
symbol: '#3a4048', // 开向符号
|
||||
}
|
||||
|
||||
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
||||
|
||||
function toModel(sx: number, sy: number): Pt {
|
||||
@@ -302,11 +315,9 @@ function render() {
|
||||
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
|
||||
const ty = (y: number) => H / 2 + (y - midY) * scale + view.cy
|
||||
|
||||
const glassAlpha = 'rgba(211,221,218,0.6)'
|
||||
const profile = '#5c6166'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
dd.sm.forEach((shape, si) => drawFrame(ctx, shape, replays[si], si, tx, ty, { glassAlpha, profile }))
|
||||
dd.sm.forEach((shape, si) => drawFrame(ctx, shape, replays[si], si, tx, ty, scale))
|
||||
|
||||
if (props.editable) {
|
||||
const x1 = tx(modelBox.x1), y1 = ty(modelBox.y1)
|
||||
@@ -322,94 +333,144 @@ function render() {
|
||||
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D, shape: FrameShape, rp: Replay, shapeIdx: number,
|
||||
tx: (x: number) => number, ty: (y: number) => number,
|
||||
color: { glassAlpha: string; profile: string },
|
||||
tx: (x: number) => number, ty: (y: number) => number, scale: number,
|
||||
) {
|
||||
const regions = rp.regions.map(regionPoints).filter(pts => pts.length >= 3)
|
||||
const cm = shape.cm ?? {}
|
||||
// 型材视觉宽度: 60mm 料宽映射像素, 限幅
|
||||
const lw = Math.max(3, Math.min(10, 60 * scale))
|
||||
|
||||
// 玻璃区域(重放结果)
|
||||
// 1) 玻璃格: teal 渐变(官方图纸配色)
|
||||
for (const pts of regions) {
|
||||
ctx.beginPath()
|
||||
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = cm.g ?? color.glassAlpha
|
||||
const bb = bboxOf(pts)
|
||||
const grad = ctx.createLinearGradient(tx(bb.x0), ty(bb.y0), tx(bb.x1), ty(bb.y1))
|
||||
grad.addColorStop(0, STYLE.glassHi)
|
||||
grad.addColorStop(0.5, STYLE.glass)
|
||||
grad.addColorStop(1, STYLE.glassDeep)
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = cm.bn ?? color.profile
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.strokeStyle = STYLE.profileDark
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 中梃(每条按目标区域 bbox 裁剪后绘制, 双线示意型材; 选中高亮)
|
||||
// 2) 中梃(按目标区域裁剪, 型材三段线; 选中高亮)
|
||||
rp.clips.forEach(({ sp, bb }, spIdx) => {
|
||||
const isSel = selected.value?.shapeIdx === shapeIdx && selected.value.spIdx === spIdx
|
||||
drawBar(ctx, spAxis(sp), spPos(sp), bb, tx, ty, isSel, color.profile)
|
||||
drawBar(ctx, spAxis(sp), spPos(sp), bb, tx, ty, scale, isSel)
|
||||
})
|
||||
|
||||
// 外框
|
||||
// 3) 外框型材 + 四角角码
|
||||
if (regions.length) {
|
||||
const bb = bboxOf(regions.flat())
|
||||
const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1)
|
||||
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
|
||||
const avg = ew.reduce((a, b) => a + b, 0) / ew.length
|
||||
ctx.strokeStyle = cm.bn ?? color.profile
|
||||
ctx.lineWidth = 3.2
|
||||
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
|
||||
drawProfileRect(ctx, tx(bb.x0), ty(bb.y0), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale, lw)
|
||||
const cs = Math.max(4, lw * 0.8)
|
||||
const cx0 = tx(bb.x0), cy0 = ty(bb.y0), cx1 = tx(bb.x1), cy1 = ty(bb.y1)
|
||||
ctx.fillStyle = '#f2f2f4'
|
||||
ctx.strokeStyle = STYLE.profileDark
|
||||
ctx.lineWidth = 1
|
||||
const inset = Math.max(2.5, avg * 0.018)
|
||||
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
||||
for (const [ax, ay] of [[cx0, cy0], [cx1, cy0], [cx0, cy1], [cx1, cy1]]) {
|
||||
ctx.beginPath()
|
||||
ctx.rect(ax - cs / 2, ay - cs / 2, cs, cs)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// 扇(推断: 有效bd → polyId挂接区域 → 帧bbox按扇数均分) — 共享推断模块
|
||||
// 4) 扇: 扇料框 + 官方开向符号 + 把手
|
||||
const glyphs = inferSashGlyphs(shape, rp).sashes
|
||||
for (const g of glyphs) {
|
||||
const ewAvg = ((shape.fm?.ew ?? [60, 60, 60, 60]).reduce((a, b) => a + b, 0)) / 4
|
||||
const fw = Math.max(2.5, Math.min(lw, ewAvg * scale))
|
||||
glyphs.forEach((g, gi) => {
|
||||
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y0), by1 = ty(g.y1)
|
||||
const w = bx1 - bx0, h = by1 - by0
|
||||
if (w < 4 || h < 4) continue
|
||||
ctx.save()
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.strokeStyle = 'rgba(70,76,82,0.9)'
|
||||
ctx.lineWidth = 1.3
|
||||
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
|
||||
ctx.setLineDash([])
|
||||
if (w < 6 || h < 6) return
|
||||
// 扇料框(细一档的型材)
|
||||
drawProfileRect(ctx, bx0, by0, w, h, fw)
|
||||
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
|
||||
if (g.wtf) {
|
||||
// 双向开启门:对角虚线交叉符号(对齐原站图纸样式)
|
||||
ctx.strokeStyle = 'rgba(70,76,82,0.7)'
|
||||
ctx.lineWidth = 1
|
||||
// 开向符号: 平开=虚线三角(apex 指铰链侧), 内开内倒=对角交叉, 推拉=水平滑行箭头
|
||||
ctx.strokeStyle = STYLE.symbol
|
||||
ctx.lineWidth = 1.2
|
||||
if (g.kind === 'sliding') {
|
||||
const arrowDir = gi % 2 === 0 ? 1 : -1
|
||||
const L = Math.min(w, h) * 0.3
|
||||
ctx.setLineDash([])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(bx0 + 4, by0 + 4); ctx.lineTo(bx1 - 4, by1 - 4)
|
||||
ctx.moveTo(bx1 - 4, by0 + 4); ctx.lineTo(bx0 + 4, by1 - 4)
|
||||
ctx.moveTo(cx - L * arrowDir, cy - h * 0.06)
|
||||
ctx.lineTo(cx + L * arrowDir, cy - h * 0.06)
|
||||
ctx.lineTo(cx + (L - 8) * arrowDir, cy - h * 0.06 - 6)
|
||||
ctx.moveTo(cx + L * arrowDir, cy - h * 0.06)
|
||||
ctx.lineTo(cx + (L - 8) * arrowDir, cy - h * 0.06 + 6)
|
||||
ctx.stroke()
|
||||
} else {
|
||||
const ang = ((g.od || 90) * Math.PI) / 180
|
||||
const L = Math.min(w, h) * 0.32
|
||||
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
|
||||
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
|
||||
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
|
||||
ctx.setLineDash([6, 5])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(ax, ay)
|
||||
ctx.lineTo(cx - wx, cy - wy)
|
||||
ctx.lineTo(cx + wx, cy + wy)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = g.openToward === 'inward' ? 'rgba(255,255,255,0.55)' : 'rgba(70,76,82,0.25)'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
|
||||
ctx.lineWidth = 1.4
|
||||
const dir = g.openDirection ?? 'left'
|
||||
if (dir.startsWith('up')) {
|
||||
ctx.moveTo(bx0, by1); ctx.lineTo(cx, by0); ctx.lineTo(bx1, by1)
|
||||
} else if (dir.startsWith('right')) {
|
||||
ctx.moveTo(bx0, by0); ctx.lineTo(bx1, cy); ctx.lineTo(bx0, by1)
|
||||
} else {
|
||||
ctx.moveTo(bx1, by0); ctx.lineTo(bx0, cy); ctx.lineTo(bx1, by1)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
if (g.isDoor) {
|
||||
ctx.fillStyle = 'rgba(70,76,82,0.85)'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('门', cx, by1 - 6)
|
||||
ctx.textAlign = 'left'
|
||||
// 把手: 铰链对面侧(白条+描边+上下锁点)
|
||||
const dir = g.openDirection ?? 'left'
|
||||
const hl = Math.max(8, h * (g.isDoor ? 0.36 : 0.2))
|
||||
const hw2 = Math.max(3.5, lw * 0.55)
|
||||
ctx.fillStyle = '#f4f4f6'
|
||||
ctx.strokeStyle = STYLE.profileDark
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
if (g.kind === 'casement') {
|
||||
if (dir.startsWith('left')) ctx.roundRect(bx1 - hw2 - 2, cy - hl / 2, hw2, hl, 2)
|
||||
else if (dir.startsWith('right')) ctx.roundRect(bx0 + 2, cy - hl / 2, hw2, hl, 2)
|
||||
else ctx.roundRect(cx - hl / 2, by1 - hw2 - 2, hl, hw2, 2)
|
||||
} else {
|
||||
ctx.roundRect(cx - hw2 / 2, cy - hl / 2, hw2, hl, 2)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
// 锁点(把手两端小方块)
|
||||
const dot = Math.max(2.5, hw2 * 0.55)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
if (g.kind === 'casement' && !dir.startsWith('up')) {
|
||||
const dx = dir.startsWith('left') ? bx1 - hw2 / 2 - 2 : bx0 + hw2 / 2 + 2
|
||||
for (const oy of [cy - hl / 3, cy + hl / 3]) {
|
||||
ctx.beginPath(); ctx.rect(dx - dot / 2, oy - dot / 2, dot, dot); ctx.fill(); ctx.stroke()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 选中玻璃格高亮: 蓝虚线框 + 格尺寸
|
||||
// 5) 玻璃编号: 固定格(frame.mm.fls) + 扇格(sash.mm.fls), 经 polyId 树寻址定位
|
||||
ctx.fillStyle = STYLE.label
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
const drawCellLabel = (text: string, bb: { x0: number; y0: number; x1: number; y1: number }) => {
|
||||
const fs = Math.max(10, Math.min(16, (bb.x1 - bb.x0) * scale * 0.09))
|
||||
ctx.font = `bold ${fs}px sans-serif`
|
||||
ctx.fillText(text, tx((bb.x0 + bb.x1) / 2), ty((bb.y0 + bb.y1) / 2))
|
||||
}
|
||||
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
||||
if (sub.enabled === false) continue
|
||||
const text = ((sub.mm as any)?.fls as any)?.fls?.[0]?.ser?.text
|
||||
if (!text || sub.polyId?.idx == null) continue
|
||||
const segs = rp.resolveRef?.(sub.polyId.idx, sub.polyId.pos)
|
||||
if (segs) drawCellLabel(text, bboxOf(regionPoints(segs)))
|
||||
}
|
||||
for (const fl of ((shape.mm as any)?.fls as any)?.fls ?? []) {
|
||||
const text = fl?.ser?.text
|
||||
if (!text || fl.pid?.idx == null) continue
|
||||
const segs = rp.resolveRef?.(fl.pid.idx, fl.pid.pos)
|
||||
if (segs) drawCellLabel(text, bboxOf(regionPoints(segs)))
|
||||
}
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
|
||||
// 6) 选中玻璃格高亮: 蓝虚线框 + 格尺寸
|
||||
if (selRegion.value && selRegion.value.shapeIdx === shapeIdx) {
|
||||
const r = rp.regions[selRegion.value.ri]
|
||||
if (r) {
|
||||
@@ -423,61 +484,80 @@ function drawFrame(
|
||||
ctx.fillStyle = '#409eff'
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.fillText(`${Math.round(bb.x1 - bb.x0)} × ${Math.round(bb.y1 - bb.y0)} mm`, (x0 + x1) / 2, y0 - 8)
|
||||
ctx.textAlign = 'left'
|
||||
}
|
||||
}
|
||||
|
||||
// 尺寸标注
|
||||
// 7) 尺寸标注(官方蓝色刻度线)
|
||||
if (regions.length) {
|
||||
const bb = bboxOf(regions.flat())
|
||||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y0), y1 = ty(bb.y1)
|
||||
const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0)
|
||||
ctx.fillStyle = '#7a8087'
|
||||
ctx.fillStyle = STYLE.dim
|
||||
ctx.strokeStyle = STYLE.dim
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.fillText(`${wmm} mm`, (x0 + x1) / 2, y1 + 16)
|
||||
ctx.save()
|
||||
ctx.translate(x0 - 14, (y0 + y1) / 2)
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.fillText(`${hmm} mm`, 0, 0)
|
||||
ctx.restore()
|
||||
ctx.strokeStyle = 'rgba(122,128,135,0.6)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x0, y1 + 6); ctx.lineTo(x1, y1 + 6)
|
||||
ctx.moveTo(x0, y1 + 3); ctx.lineTo(x0, y1 + 9)
|
||||
ctx.moveTo(x1, y1 + 3); ctx.lineTo(x1, y1 + 9)
|
||||
ctx.moveTo(x0 - 6, y0); ctx.lineTo(x0 - 6, y1)
|
||||
ctx.moveTo(x0 - 9, y0); ctx.lineTo(x0 - 3, y0)
|
||||
ctx.moveTo(x0 - 9, y1); ctx.lineTo(x0 - 3, y1)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
/** 画一根中梃(按bbox裁剪的双线) */
|
||||
/** 型材矩形: 深色轮廓 + 紫灰主体 + 白色高光(立体型材感) */
|
||||
function drawProfileRect(
|
||||
ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, lw: number,
|
||||
) {
|
||||
ctx.save()
|
||||
ctx.lineCap = 'square'
|
||||
ctx.strokeStyle = STYLE.profileDark
|
||||
ctx.lineWidth = lw + 1.6
|
||||
ctx.strokeRect(x, y, w, h)
|
||||
ctx.strokeStyle = STYLE.profile
|
||||
ctx.lineWidth = lw
|
||||
ctx.strokeRect(x, y, w, h)
|
||||
ctx.strokeStyle = STYLE.highlight
|
||||
ctx.lineWidth = Math.max(1, lw * 0.16)
|
||||
const o = lw * 0.3
|
||||
ctx.strokeRect(x + o, y + o, w - o * 2, h - o * 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** 画一根中梃(按bbox裁剪, 型材三段线; 选中蓝色高亮) */
|
||||
function drawBar(
|
||||
ctx: CanvasRenderingContext2D, axis: 'v' | 'h', pos: number,
|
||||
bb: { x0: number; y0: number; x1: number; y1: number },
|
||||
tx: (x: number) => number, ty: (y: number) => number, isSelected: boolean, profile: string,
|
||||
tx: (x: number) => number, ty: (y: number) => number, scale: number, isSelected: boolean,
|
||||
) {
|
||||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y0), y1 = ty(bb.y1)
|
||||
const lw = Math.max(3, Math.min(10, 60 * scale))
|
||||
const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1)
|
||||
ctx.save()
|
||||
if (axis === 'v') {
|
||||
const px = tx(pos)
|
||||
if (px < x0 - 1 || px > x1 + 1) { ctx.restore(); return }
|
||||
ctx.strokeStyle = isSelected ? '#409eff' : profile
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.moveTo(px, y0); ctx.lineTo(px, y1); ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.moveTo(px, y0); ctx.lineTo(px, y1); ctx.stroke()
|
||||
} else {
|
||||
const py = ty(pos)
|
||||
if (py < y0 - 1 || py > y1 + 1) { ctx.restore(); return }
|
||||
ctx.strokeStyle = isSelected ? '#409eff' : profile
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.moveTo(x0, py); ctx.lineTo(x1, py); ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.moveTo(x0, py); ctx.lineTo(x1, py); ctx.stroke()
|
||||
ctx.lineCap = 'butt'
|
||||
const seg = (style: string, width: number, inset: number) => {
|
||||
ctx.strokeStyle = style
|
||||
ctx.lineWidth = width
|
||||
ctx.beginPath()
|
||||
if (axis === 'v') { const px = tx(pos); ctx.moveTo(px, y0 + inset); ctx.lineTo(px, y1 - inset) }
|
||||
else { const py = ty(pos); ctx.moveTo(x0 + inset, py); ctx.lineTo(x1 - inset, py) }
|
||||
ctx.stroke()
|
||||
}
|
||||
seg(STYLE.profileDark, lw + 1.6, -0.8)
|
||||
seg(STYLE.profile, lw, 0)
|
||||
seg(STYLE.highlight, Math.max(1, lw * 0.16), lw * 0.3)
|
||||
if (isSelected) seg('#409eff', lw + 2, -1)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user