diff --git a/CHANGELOG b/CHANGELOG index e471899..1cebce2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-23 06:05] FEAT: v2.14 2D 图纸样式对齐源站设计图(官方 PNG 逐元素比对)。WindoorCanvas 绘制层重写: ①玻璃 teal 渐变(官方配色)+深色细描边; ②型材立体三段线(深轮廓+紫灰主体+白高光, 宽度=60mm 料宽映射限幅), 外框/中梃/扇料框统一; ③四角白色角码; ④开向符号对齐官方: 平开=虚线大三角(apex 按 hm.openDirection 指铰链侧, wtf 同样画三角), 推拉=水平滑行箭头(成对反向); ⑤把手(铰链对面侧白条)+锁点小方块; ⑥玻璃编号标签(frame/sash 的 mm.fls[].ser.text 经 polyId 树寻址定位到格中心); ⑦尺寸标注改官方蓝色带端部刻度; CombineCanvas 玻璃/型材配色同步; 验证: 设计图纸 704 画布与官方平面图逐元素一致, ui-shots 6 页无报错 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/components/CombineCanvas.vue, shots/*) + - [2026-09-23 05:40] FEAT: v2.13 细节尺寸编辑——组合与样板的每一格可精确编辑。①WindoorCanvas: 点击玻璃格选中(蓝虚线高亮+格尺寸标注), HUD 输入格W/格H 自动平移该格边界中梃(优先右/下边界其次左/上, 共线同位多段成组移动+组内各段裁剪 bbox 内缩30mm 交集 clamp); 选中中梃可输入距左/上边缘位置精确移动; 中梃拖动改组移动(修复共线多段拖动错位); ②CombineView: 工具栏新增"编辑细节"对话框(复用 WindoorCanvas 编辑器 editable 模式), 保存后原位写回对应单元形状(__uid/__name 保留, rev 驱动 3D 预览重建); ③CombineCanvas: 中梃改按各自裁剪 bbox 绘制(原横梃画满宽的示意画法与真实分格不符); ④TemplatesView 编辑提示文案更新; 验证: 真实浏览器截图(格选中 663×1207→格H=800 横梃精确下移/梃位 900 移动/组合对话框编辑保存写回后主画布与 3D 同步), vue-tsc 0 错误 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue, frontend/src/views/TemplatesView.vue, frontend/scripts/verify-detail.mjs, shots/detail-*.png) - [2026-09-23 05:10] FIX: v2.12 全部 2D 画布上下颠倒修复(组合/编辑/效果图/AI变体)。根因: 四画布(WindoorCanvas/CombineCanvas/RenderCanvas/variantRenderer)的 ty 映射按 y 向上渲染, 而 v2.11 已实证模型坐标 y 向下(屏幕系) → 样板内部布局整体颠倒(31337 亮子被画到底部而 3D 在顶部, 用户报"方向反了"); 统一去翻转: ty 改直接映射+toModel 反演同步+ty(y1)/ty(y0) 顶底序全部归位, WindoorCanvas 同步修正尺寸手柄位置(右下角)/中梃竖向拖动 dy 符号/扇符号与尺寸标注位置; 验证: 组合页 2D 与 3D 方向一致(704 亮子均在上), 设计图纸画布 704 布局=官方平面图(左上亮子+左下扇+右整列), ui-shots 6 页无报错 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/components/RenderCanvas.vue, frontend/src/components/variantRenderer.ts, frontend/scripts/verify-2d.mjs, shots/*) diff --git a/frontend/src/components/CombineCanvas.vue b/frontend/src/components/CombineCanvas.vue index dae9bb3..7038e10 100644 --- a/frontend/src/components/CombineCanvas.vue +++ b/frontend/src/components/CombineCanvas.vue @@ -206,6 +206,9 @@ function render() { const ty = (y: number) => H / 2 + (y - midY) * scale + view.cy ctx.lineJoin = 'round' + // 图纸样式(对齐源站设计图): teal 玻璃 + 紫灰立体型材 + const P = { profile: '#8f8fa6', profileDark: '#585866', highlight: '#ffffff', + glass: '#2fb3ab', glassHi: '#49c6be', glassDeep: '#1f8b85' } for (const shape of props.shapes) { const sel = selectedUid.value !== null && shape.__uid === selectedUid.value const rp = replayShape(shape) @@ -215,26 +218,34 @@ function render() { ctx.beginPath() rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y)))) ctx.closePath() - ctx.fillStyle = 'rgba(211,221,218,0.6)' + const rb2 = bboxOf(rpts) + const grad = ctx.createLinearGradient(tx(rb2.x0), ty(rb2.y0), tx(rb2.x1), ty(rb2.y1)) + grad.addColorStop(0, P.glassHi) + grad.addColorStop(0.5, P.glass) + grad.addColorStop(1, P.glassDeep) + ctx.fillStyle = grad ctx.fill() - ctx.strokeStyle = sel ? '#409eff' : '#5c6166' - ctx.lineWidth = 1.6 + ctx.strokeStyle = P.profileDark + ctx.lineWidth = 1.2 ctx.stroke() } // 外框 const rs = rp.regions.map(regionPoints).filter(p => p.length >= 3) if (rs.length) { const bb = bboxOf(rs.flat()) - ctx.strokeStyle = sel ? '#409eff' : '#5c6166' - ctx.lineWidth = 3 + ctx.strokeStyle = sel ? '#409eff' : P.profile + ctx.lineWidth = 3.5 ctx.strokeRect(tx(bb.x0), ty(bb.y0), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale) + ctx.strokeStyle = sel ? '#66aaff' : P.highlight + ctx.lineWidth = 1 + ctx.strokeRect(tx(bb.x0) + 1.5, ty(bb.y0) + 1.5, (bb.x1 - bb.x0) * scale - 3, (bb.y1 - bb.y0) * scale - 3) } // 中梃(按各自裁剪 bbox 绘制, 与真实分格一致) for (const { sp, bb } of rp.clips) { if (!sp.line?.pt) continue const n = sp.line.norm ?? { x: 1, y: 0 } - ctx.strokeStyle = sel ? '#66aaff' : '#7c828a' - ctx.lineWidth = 2 + ctx.strokeStyle = sel ? '#66aaff' : P.profile + ctx.lineWidth = 2.5 ctx.beginPath() if (Math.abs(n.x) >= Math.abs(n.y)) { ctx.moveTo(tx(sp.line.pt.x), ty(bb.y0)); ctx.lineTo(tx(sp.line.pt.x), ty(bb.y1)) } else { ctx.moveTo(tx(bb.x0), ty(sp.line.pt.y)); ctx.lineTo(tx(bb.x1), ty(sp.line.pt.y)) } diff --git a/frontend/src/components/WindoorCanvas.vue b/frontend/src/components/WindoorCanvas.vue index a35326f..72281e1 100644 --- a/frontend/src/components/WindoorCanvas.vue +++ b/frontend/src/components/WindoorCanvas.vue @@ -177,6 +177,19 @@ function onMullionPos() { type Replay = ReturnType 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() } diff --git a/shots/02-combine-two.png b/shots/02-combine-two.png index 6eb3055..c7e982b 100644 Binary files a/shots/02-combine-two.png and b/shots/02-combine-two.png differ diff --git a/shots/03-combine-selected.png b/shots/03-combine-selected.png index d1b2c9a..d2cc4e3 100644 Binary files a/shots/03-combine-selected.png and b/shots/03-combine-selected.png differ diff --git a/shots/04-combine-3d.png b/shots/04-combine-3d.png index d1b2c9a..71b5d85 100644 Binary files a/shots/04-combine-3d.png and b/shots/04-combine-3d.png differ diff --git a/shots/06-dashboard.png b/shots/06-dashboard.png index 605d0be..e7f3372 100644 Binary files a/shots/06-dashboard.png and b/shots/06-dashboard.png differ diff --git a/shots/verify-2d-canvas.png b/shots/verify-2d-canvas.png index 8412f89..586c4d5 100644 Binary files a/shots/verify-2d-canvas.png and b/shots/verify-2d-canvas.png differ