1
0

CadPreview.vue 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. <template>
  2. <div class="cad-preview">
  3. <!-- DXF:本地解析 + canvas 渲染,不依赖任何后端服务 -->
  4. <div class="cad-dxf">
  5. <div v-if="dxfError" class="cad-hint">{{ dxfError }}</div>
  6. <div v-else class="cad-canvas-area">
  7. <canvas ref="canvasRef" class="cad-canvas" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="fitView"></canvas>
  8. <div class="cad-toolbar">
  9. <span class="cad-stat">{{ dxfStat }}</span>
  10. <a-button size="small" type="link" @click="fitView">适应窗口</a-button>
  11. </div>
  12. </div>
  13. </div>
  14. </div>
  15. </template>
  16. <script setup lang="ts">
  17. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
  18. import { parseDxf } from '/@/views/ventAI/manageAssistent/lib/dxf';
  19. import type { DxfEntity, DxfParseResult } from '/@/views/ventAI/manageAssistent/lib/dxf';
  20. import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
  21. /**
  22. * 右侧面板的 CAD 预览:本地解析 DXF 并在 canvas 上绘制(可滚轮缩放、拖拽平移),不依赖任何后端服务。
  23. *
  24. * 当前只处理 .dxf——面板仅在 isDxfFile 命中时才渲染本组件。
  25. * .dwg(专有二进制,需上传后端转 SVG)见 components/resource/DwgPreview.vue;
  26. * .mxweb(依赖 mxcad 及其配套服务)暂不预览,由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
  27. */
  28. const props = defineProps<{ file: AttachedFile }>();
  29. // —— DXF ——
  30. const canvasRef = ref<HTMLCanvasElement>();
  31. const dxfError = ref('');
  32. const parsed = ref<DxfParseResult | null>(null);
  33. const view = ref({ scale: 1, tx: 0, ty: 0 });
  34. const dxfStat = computed(() => {
  35. const r = parsed.value;
  36. if (!r) return '';
  37. return r.skipped > 0 ? `${r.entities.length} 个对象(跳过 ${r.skipped} 个不支持的对象)` : `${r.entities.length} 个对象`;
  38. });
  39. const loadDxf = () => {
  40. parsed.value = null;
  41. dxfError.value = '';
  42. const buf = props.file.arrayBuffer;
  43. const text = props.file.content ?? (buf ? new TextDecoder().decode(buf) : '');
  44. if (!text.trim()) {
  45. dxfError.value = '未取到 DXF 内容,无法预览';
  46. return;
  47. }
  48. const result = parseDxf(text);
  49. if (!result.entities.length) {
  50. dxfError.value = result.skipped ? `未解析到可显示的图形(跳过 ${result.skipped} 个不支持的实体)` : 'DXF 中没有可显示的实体';
  51. return;
  52. }
  53. parsed.value = result;
  54. fitView();
  55. };
  56. /** 按图形范围缩放到画布(留 10% 边距) */
  57. const fitView = () => {
  58. const canvas = canvasRef.value;
  59. if (!canvas || !parsed.value) return;
  60. const w = canvas.clientWidth || 1;
  61. const h = canvas.clientHeight || 1;
  62. const b = parsed.value.bounds;
  63. if (!b) {
  64. view.value = { scale: 1, tx: w / 2, ty: h / 2 };
  65. draw();
  66. return;
  67. }
  68. const spanX = Math.max(b.maxX - b.minX, 1e-6);
  69. const spanY = Math.max(b.maxY - b.minY, 1e-6);
  70. const scale = Math.min(w / spanX, h / spanY) * 0.9;
  71. const cx = (b.minX + b.maxX) / 2;
  72. const cy = (b.minY + b.maxY) / 2;
  73. // 纵向取加号:绘制时 y 轴翻转(screenY = ty - y * scale)
  74. view.value = { scale, tx: w / 2 - cx * scale, ty: h / 2 + cy * scale };
  75. draw();
  76. };
  77. const pathEntity = (ctx: CanvasRenderingContext2D, e: DxfEntity) => {
  78. ctx.beginPath();
  79. switch (e.kind) {
  80. case 'line':
  81. ctx.moveTo(e.a.x, e.a.y);
  82. ctx.lineTo(e.b.x, e.b.y);
  83. break;
  84. case 'polyline': {
  85. if (!e.points.length) return;
  86. ctx.moveTo(e.points[0].x, e.points[0].y);
  87. for (let i = 1; i < e.points.length; i++) ctx.lineTo(e.points[i].x, e.points[i].y);
  88. if (e.closed) ctx.closePath();
  89. break;
  90. }
  91. case 'circle':
  92. ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), 0, Math.PI * 2);
  93. break;
  94. case 'arc':
  95. // 绘制变换含 y 翻转,而 arc 的参数 t 恰好等于 DXF 角度(cos/sin 直接对应图纸坐标),
  96. // 所以取 false(t 递增)才是 DXF 意义上的逆时针短弧;取 true 会画成 270° 的补弧(实测如此)。
  97. ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), (e.startDeg * Math.PI) / 180, (e.endDeg * Math.PI) / 180, false);
  98. break;
  99. case 'ellipse':
  100. ctx.ellipse(
  101. e.center.x,
  102. e.center.y,
  103. Math.max(e.rx, 0),
  104. Math.max(e.ry, 0),
  105. (e.rotationDeg * Math.PI) / 180,
  106. (e.startDeg * Math.PI) / 180,
  107. (e.endDeg * Math.PI) / 180,
  108. false
  109. );
  110. break;
  111. case 'point': {
  112. // 单点必须画成小十字,否则 stroke 什么都看不到
  113. const r = 1.5 / (view.value.scale || 1);
  114. ctx.moveTo(e.at.x - r, e.at.y);
  115. ctx.lineTo(e.at.x + r, e.at.y);
  116. ctx.moveTo(e.at.x, e.at.y - r);
  117. ctx.lineTo(e.at.x, e.at.y + r);
  118. break;
  119. }
  120. default:
  121. break;
  122. }
  123. };
  124. const drawTextEntity = (ctx: CanvasRenderingContext2D, e: Extract<DxfEntity, { kind: 'text' }>) => {
  125. const { scale, tx, ty } = view.value;
  126. const px = e.height * scale;
  127. // 太小看不清、太大(图纸被缩得极小或字号本就夸张)就不画,避免糊满整屏
  128. if (!Number.isFinite(px) || px < 5 || px > 120) return;
  129. ctx.save();
  130. ctx.translate(tx + e.at.x * scale, ty - e.at.y * scale);
  131. if (e.rotationDeg) ctx.rotate((-e.rotationDeg * Math.PI) / 180);
  132. ctx.font = `${px}px sans-serif`;
  133. ctx.fillStyle = '#9fb3c8';
  134. ctx.fillText(e.text, 0, 0);
  135. ctx.restore();
  136. };
  137. const draw = () => {
  138. const canvas = canvasRef.value;
  139. const data = parsed.value;
  140. if (!canvas || !data) return;
  141. const dpr = window.devicePixelRatio || 1;
  142. const w = canvas.clientWidth || 1;
  143. const h = canvas.clientHeight || 1;
  144. canvas.width = Math.round(w * dpr);
  145. canvas.height = Math.round(h * dpr);
  146. const ctx = canvas.getContext('2d');
  147. if (!ctx) return;
  148. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  149. ctx.clearRect(0, 0, w, h);
  150. const { scale, tx, ty } = view.value;
  151. ctx.save();
  152. ctx.translate(tx, ty);
  153. // DXF 的 y 轴向上,canvas 向下 → 纵向取负;线宽除以 scale 保持屏幕上恒为 1px
  154. ctx.scale(scale, -scale);
  155. ctx.strokeStyle = '#c9d6e4';
  156. ctx.lineWidth = 1 / scale;
  157. for (const e of data.entities) {
  158. if (e.kind === 'text') continue;
  159. pathEntity(ctx, e);
  160. ctx.stroke();
  161. }
  162. ctx.restore();
  163. // 文字在翻转后的坐标系里会被镜像,单独按屏幕坐标绘制
  164. for (const e of data.entities) {
  165. if (e.kind === 'text') drawTextEntity(ctx, e);
  166. }
  167. };
  168. // 平移/缩放
  169. let dragging = false;
  170. let lastX = 0;
  171. let lastY = 0;
  172. const onMouseMove = (ev: MouseEvent) => {
  173. if (!dragging) return;
  174. const v = view.value;
  175. view.value = { ...v, tx: v.tx + (ev.clientX - lastX), ty: v.ty + (ev.clientY - lastY) };
  176. lastX = ev.clientX;
  177. lastY = ev.clientY;
  178. draw();
  179. };
  180. const onMouseUp = () => {
  181. dragging = false;
  182. window.removeEventListener('mousemove', onMouseMove);
  183. window.removeEventListener('mouseup', onMouseUp);
  184. };
  185. const onMouseDown = (ev: MouseEvent) => {
  186. dragging = true;
  187. lastX = ev.clientX;
  188. lastY = ev.clientY;
  189. window.addEventListener('mousemove', onMouseMove);
  190. window.addEventListener('mouseup', onMouseUp);
  191. };
  192. const onWheel = (ev: WheelEvent) => {
  193. const canvas = canvasRef.value;
  194. if (!canvas) return;
  195. const rect = canvas.getBoundingClientRect();
  196. const px = ev.clientX - rect.left;
  197. const py = ev.clientY - rect.top;
  198. const v = view.value;
  199. const scale = Math.min(Math.max(v.scale * (ev.deltaY < 0 ? 1.15 : 1 / 1.15), 1e-6), 1e6);
  200. // 以光标为不动点:tx' = px - (px - tx) * k
  201. const k = scale / v.scale;
  202. view.value = { scale, tx: px - (px - v.tx) * k, ty: py - (py - v.ty) * k };
  203. draw();
  204. };
  205. let resizeObserver: ResizeObserver | null = null;
  206. // 每次换文件后都要把观察者绑到「当前这块」画布:DXF 分支的画布是随文件类型重新创建的,
  207. // 继续观察被替换掉的旧画布,尺寸变化就不会再触发重绘。
  208. const setupResizeObserver = () => {
  209. resizeObserver?.disconnect();
  210. resizeObserver = null;
  211. const el = canvasRef.value;
  212. if (!el) return;
  213. resizeObserver = new ResizeObserver(() => {
  214. if (parsed.value) fitView();
  215. });
  216. resizeObserver.observe(el);
  217. };
  218. // 面板实例会在多个文件标签之间复用(RightPanelTabs 没加 :key),换文件必须重新初始化。
  219. // 必须先等一次 nextTick:面板实例在多个文件标签间复用,此刻模板还停在上一个文件的分支上
  220. //(例如文本预览块),画布尚未挂载,canvasRef 为空会让 fitView 直接 return,绘制被静默跳过——
  221. // 表现就是面板一片空白,得手动点一次「适应窗口」才出图。
  222. const init = async () => {
  223. onMouseUp();
  224. parsed.value = null;
  225. dxfError.value = '';
  226. await nextTick();
  227. loadDxf();
  228. setupResizeObserver();
  229. };
  230. onMounted(() => {
  231. void init();
  232. });
  233. watch(
  234. () => props.file,
  235. () => {
  236. void init();
  237. }
  238. );
  239. onBeforeUnmount(() => {
  240. onMouseUp();
  241. resizeObserver?.disconnect();
  242. resizeObserver = null;
  243. });
  244. </script>
  245. <style scoped lang="less">
  246. .cad-preview {
  247. display: flex;
  248. flex-direction: column;
  249. height: 100%;
  250. min-height: 0;
  251. }
  252. .cad-dxf {
  253. display: flex;
  254. flex-direction: column;
  255. flex: 1;
  256. min-height: 0;
  257. }
  258. .cad-canvas-area {
  259. position: relative;
  260. flex: 1;
  261. min-height: 320px;
  262. }
  263. .cad-canvas {
  264. display: block;
  265. width: 100%;
  266. height: 100%;
  267. cursor: grab;
  268. background: rgb(13 21 32 / 55%);
  269. &:active {
  270. cursor: grabbing;
  271. }
  272. }
  273. .cad-toolbar {
  274. position: absolute;
  275. right: 8px;
  276. bottom: 8px;
  277. display: flex;
  278. align-items: center;
  279. gap: 8px;
  280. }
  281. .cad-stat {
  282. font-size: 12px;
  283. color: #8b949e;
  284. }
  285. .cad-hint {
  286. padding: 12px 16px;
  287. font-size: 13px;
  288. color: #8b949e;
  289. }
  290. </style>