| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- <template>
- <div class="cad-preview">
- <!-- DXF:本地解析 + canvas 渲染,不依赖任何后端服务 -->
- <div class="cad-dxf">
- <div v-if="dxfError" class="cad-hint">{{ dxfError }}</div>
- <div v-else class="cad-canvas-area">
- <canvas ref="canvasRef" class="cad-canvas" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="fitView"></canvas>
- <div class="cad-toolbar">
- <span class="cad-stat">{{ dxfStat }}</span>
- <a-button size="small" type="link" @click="fitView">适应窗口</a-button>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
- import { parseDxf } from '/@/views/ventAI/manageAssistent/lib/dxf';
- import type { DxfEntity, DxfParseResult } from '/@/views/ventAI/manageAssistent/lib/dxf';
- import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
- /**
- * 右侧面板的 CAD 预览:本地解析 DXF 并在 canvas 上绘制(可滚轮缩放、拖拽平移),不依赖任何后端服务。
- *
- * 当前只处理 .dxf——面板仅在 isDxfFile 命中时才渲染本组件。
- * .dwg(专有二进制,需上传后端转 SVG)见 components/resource/DwgPreview.vue;
- * .mxweb(依赖 mxcad 及其配套服务)暂不预览,由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
- */
- const props = defineProps<{ file: AttachedFile }>();
- // —— DXF ——
- const canvasRef = ref<HTMLCanvasElement>();
- const dxfError = ref('');
- const parsed = ref<DxfParseResult | null>(null);
- const view = ref({ scale: 1, tx: 0, ty: 0 });
- const dxfStat = computed(() => {
- const r = parsed.value;
- if (!r) return '';
- return r.skipped > 0 ? `${r.entities.length} 个对象(跳过 ${r.skipped} 个不支持的对象)` : `${r.entities.length} 个对象`;
- });
- const loadDxf = () => {
- parsed.value = null;
- dxfError.value = '';
- const buf = props.file.arrayBuffer;
- const text = props.file.content ?? (buf ? new TextDecoder().decode(buf) : '');
- if (!text.trim()) {
- dxfError.value = '未取到 DXF 内容,无法预览';
- return;
- }
- const result = parseDxf(text);
- if (!result.entities.length) {
- dxfError.value = result.skipped ? `未解析到可显示的图形(跳过 ${result.skipped} 个不支持的实体)` : 'DXF 中没有可显示的实体';
- return;
- }
- parsed.value = result;
- fitView();
- };
- /** 按图形范围缩放到画布(留 10% 边距) */
- const fitView = () => {
- const canvas = canvasRef.value;
- if (!canvas || !parsed.value) return;
- const w = canvas.clientWidth || 1;
- const h = canvas.clientHeight || 1;
- const b = parsed.value.bounds;
- if (!b) {
- view.value = { scale: 1, tx: w / 2, ty: h / 2 };
- draw();
- return;
- }
- const spanX = Math.max(b.maxX - b.minX, 1e-6);
- const spanY = Math.max(b.maxY - b.minY, 1e-6);
- const scale = Math.min(w / spanX, h / spanY) * 0.9;
- const cx = (b.minX + b.maxX) / 2;
- const cy = (b.minY + b.maxY) / 2;
- // 纵向取加号:绘制时 y 轴翻转(screenY = ty - y * scale)
- view.value = { scale, tx: w / 2 - cx * scale, ty: h / 2 + cy * scale };
- draw();
- };
- const pathEntity = (ctx: CanvasRenderingContext2D, e: DxfEntity) => {
- ctx.beginPath();
- switch (e.kind) {
- case 'line':
- ctx.moveTo(e.a.x, e.a.y);
- ctx.lineTo(e.b.x, e.b.y);
- break;
- case 'polyline': {
- if (!e.points.length) return;
- ctx.moveTo(e.points[0].x, e.points[0].y);
- for (let i = 1; i < e.points.length; i++) ctx.lineTo(e.points[i].x, e.points[i].y);
- if (e.closed) ctx.closePath();
- break;
- }
- case 'circle':
- ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), 0, Math.PI * 2);
- break;
- case 'arc':
- // 绘制变换含 y 翻转,而 arc 的参数 t 恰好等于 DXF 角度(cos/sin 直接对应图纸坐标),
- // 所以取 false(t 递增)才是 DXF 意义上的逆时针短弧;取 true 会画成 270° 的补弧(实测如此)。
- ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), (e.startDeg * Math.PI) / 180, (e.endDeg * Math.PI) / 180, false);
- break;
- case 'ellipse':
- ctx.ellipse(
- e.center.x,
- e.center.y,
- Math.max(e.rx, 0),
- Math.max(e.ry, 0),
- (e.rotationDeg * Math.PI) / 180,
- (e.startDeg * Math.PI) / 180,
- (e.endDeg * Math.PI) / 180,
- false
- );
- break;
- case 'point': {
- // 单点必须画成小十字,否则 stroke 什么都看不到
- const r = 1.5 / (view.value.scale || 1);
- ctx.moveTo(e.at.x - r, e.at.y);
- ctx.lineTo(e.at.x + r, e.at.y);
- ctx.moveTo(e.at.x, e.at.y - r);
- ctx.lineTo(e.at.x, e.at.y + r);
- break;
- }
- default:
- break;
- }
- };
- const drawTextEntity = (ctx: CanvasRenderingContext2D, e: Extract<DxfEntity, { kind: 'text' }>) => {
- const { scale, tx, ty } = view.value;
- const px = e.height * scale;
- // 太小看不清、太大(图纸被缩得极小或字号本就夸张)就不画,避免糊满整屏
- if (!Number.isFinite(px) || px < 5 || px > 120) return;
- ctx.save();
- ctx.translate(tx + e.at.x * scale, ty - e.at.y * scale);
- if (e.rotationDeg) ctx.rotate((-e.rotationDeg * Math.PI) / 180);
- ctx.font = `${px}px sans-serif`;
- ctx.fillStyle = '#9fb3c8';
- ctx.fillText(e.text, 0, 0);
- ctx.restore();
- };
- const draw = () => {
- const canvas = canvasRef.value;
- const data = parsed.value;
- if (!canvas || !data) return;
- const dpr = window.devicePixelRatio || 1;
- const w = canvas.clientWidth || 1;
- const h = canvas.clientHeight || 1;
- canvas.width = Math.round(w * dpr);
- canvas.height = Math.round(h * dpr);
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- ctx.clearRect(0, 0, w, h);
- const { scale, tx, ty } = view.value;
- ctx.save();
- ctx.translate(tx, ty);
- // DXF 的 y 轴向上,canvas 向下 → 纵向取负;线宽除以 scale 保持屏幕上恒为 1px
- ctx.scale(scale, -scale);
- ctx.strokeStyle = '#c9d6e4';
- ctx.lineWidth = 1 / scale;
- for (const e of data.entities) {
- if (e.kind === 'text') continue;
- pathEntity(ctx, e);
- ctx.stroke();
- }
- ctx.restore();
- // 文字在翻转后的坐标系里会被镜像,单独按屏幕坐标绘制
- for (const e of data.entities) {
- if (e.kind === 'text') drawTextEntity(ctx, e);
- }
- };
- // 平移/缩放
- let dragging = false;
- let lastX = 0;
- let lastY = 0;
- const onMouseMove = (ev: MouseEvent) => {
- if (!dragging) return;
- const v = view.value;
- view.value = { ...v, tx: v.tx + (ev.clientX - lastX), ty: v.ty + (ev.clientY - lastY) };
- lastX = ev.clientX;
- lastY = ev.clientY;
- draw();
- };
- const onMouseUp = () => {
- dragging = false;
- window.removeEventListener('mousemove', onMouseMove);
- window.removeEventListener('mouseup', onMouseUp);
- };
- const onMouseDown = (ev: MouseEvent) => {
- dragging = true;
- lastX = ev.clientX;
- lastY = ev.clientY;
- window.addEventListener('mousemove', onMouseMove);
- window.addEventListener('mouseup', onMouseUp);
- };
- const onWheel = (ev: WheelEvent) => {
- const canvas = canvasRef.value;
- if (!canvas) return;
- const rect = canvas.getBoundingClientRect();
- const px = ev.clientX - rect.left;
- const py = ev.clientY - rect.top;
- const v = view.value;
- const scale = Math.min(Math.max(v.scale * (ev.deltaY < 0 ? 1.15 : 1 / 1.15), 1e-6), 1e6);
- // 以光标为不动点:tx' = px - (px - tx) * k
- const k = scale / v.scale;
- view.value = { scale, tx: px - (px - v.tx) * k, ty: py - (py - v.ty) * k };
- draw();
- };
- let resizeObserver: ResizeObserver | null = null;
- // 每次换文件后都要把观察者绑到「当前这块」画布:DXF 分支的画布是随文件类型重新创建的,
- // 继续观察被替换掉的旧画布,尺寸变化就不会再触发重绘。
- const setupResizeObserver = () => {
- resizeObserver?.disconnect();
- resizeObserver = null;
- const el = canvasRef.value;
- if (!el) return;
- resizeObserver = new ResizeObserver(() => {
- if (parsed.value) fitView();
- });
- resizeObserver.observe(el);
- };
- // 面板实例会在多个文件标签之间复用(RightPanelTabs 没加 :key),换文件必须重新初始化。
- // 必须先等一次 nextTick:面板实例在多个文件标签间复用,此刻模板还停在上一个文件的分支上
- //(例如文本预览块),画布尚未挂载,canvasRef 为空会让 fitView 直接 return,绘制被静默跳过——
- // 表现就是面板一片空白,得手动点一次「适应窗口」才出图。
- const init = async () => {
- onMouseUp();
- parsed.value = null;
- dxfError.value = '';
- await nextTick();
- loadDxf();
- setupResizeObserver();
- };
- onMounted(() => {
- void init();
- });
- watch(
- () => props.file,
- () => {
- void init();
- }
- );
- onBeforeUnmount(() => {
- onMouseUp();
- resizeObserver?.disconnect();
- resizeObserver = null;
- });
- </script>
- <style scoped lang="less">
- .cad-preview {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- }
- .cad-dxf {
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
- }
- .cad-canvas-area {
- position: relative;
- flex: 1;
- min-height: 320px;
- }
- .cad-canvas {
- display: block;
- width: 100%;
- height: 100%;
- cursor: grab;
- background: rgb(13 21 32 / 55%);
- &:active {
- cursor: grabbing;
- }
- }
- .cad-toolbar {
- position: absolute;
- right: 8px;
- bottom: 8px;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .cad-stat {
- font-size: 12px;
- color: #8b949e;
- }
- .cad-hint {
- padding: 12px 16px;
- font-size: 13px;
- color: #8b949e;
- }
- </style>
|