LaserWeb4:矢量图与位图如何变成 G-code

LaserWeb4 是浏览器里的激光 / CNC CAM。用户导入 SVG、DXF 或 PNG,配置切割 / 填充 / 光栅操作,点 Generate,再点 Play,机床就开始走。

这篇文章沿着数据走完全程:文件进来之后存在哪、几何怎么变、刀路怎么算、G-code 怎么写出来、最后怎么送到串口。重点是两件事:

  • 矢量(路径跟随 + 填充)
  • 位图(扫描线 + 每像素功率)

二者在导入之后就分道扬镳,只在「拼成一份 G-code 文本」和「发给机床」时汇合。


1. 总架构:浏览器不算串口,串口不解析图形

浏览器从不打开 USB。CAM 全在前端;真正的 port.write() 在独立的 Node 进程 lw.comm-server 里。
#mermaid-svg-9qu48T5MoFv5vkqi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9qu48T5MoFv5vkqi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9qu48T5MoFv5vkqi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9qu48T5MoFv5vkqi .error-icon{fill:#552222;}#mermaid-svg-9qu48T5MoFv5vkqi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9qu48T5MoFv5vkqi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9qu48T5MoFv5vkqi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9qu48T5MoFv5vkqi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9qu48T5MoFv5vkqi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9qu48T5MoFv5vkqi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9qu48T5MoFv5vkqi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9qu48T5MoFv5vkqi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9qu48T5MoFv5vkqi .marker.cross{stroke:#333333;}#mermaid-svg-9qu48T5MoFv5vkqi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9qu48T5MoFv5vkqi p{margin:0;}#mermaid-svg-9qu48T5MoFv5vkqi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9qu48T5MoFv5vkqi .cluster-label text{fill:#333;}#mermaid-svg-9qu48T5MoFv5vkqi .cluster-label span{color:#333;}#mermaid-svg-9qu48T5MoFv5vkqi .cluster-label span p{background-color:transparent;}#mermaid-svg-9qu48T5MoFv5vkqi .label text,#mermaid-svg-9qu48T5MoFv5vkqi span{fill:#333;color:#333;}#mermaid-svg-9qu48T5MoFv5vkqi .node rect,#mermaid-svg-9qu48T5MoFv5vkqi .node circle,#mermaid-svg-9qu48T5MoFv5vkqi .node ellipse,#mermaid-svg-9qu48T5MoFv5vkqi .node polygon,#mermaid-svg-9qu48T5MoFv5vkqi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9qu48T5MoFv5vkqi .rough-node .label text,#mermaid-svg-9qu48T5MoFv5vkqi .node .label text,#mermaid-svg-9qu48T5MoFv5vkqi .image-shape .label,#mermaid-svg-9qu48T5MoFv5vkqi .icon-shape .label{text-anchor:middle;}#mermaid-svg-9qu48T5MoFv5vkqi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9qu48T5MoFv5vkqi .rough-node .label,#mermaid-svg-9qu48T5MoFv5vkqi .node .label,#mermaid-svg-9qu48T5MoFv5vkqi .image-shape .label,#mermaid-svg-9qu48T5MoFv5vkqi .icon-shape .label{text-align:center;}#mermaid-svg-9qu48T5MoFv5vkqi .node.clickable{cursor:pointer;}#mermaid-svg-9qu48T5MoFv5vkqi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9qu48T5MoFv5vkqi .arrowheadPath{fill:#333333;}#mermaid-svg-9qu48T5MoFv5vkqi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9qu48T5MoFv5vkqi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9qu48T5MoFv5vkqi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9qu48T5MoFv5vkqi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9qu48T5MoFv5vkqi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9qu48T5MoFv5vkqi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9qu48T5MoFv5vkqi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9qu48T5MoFv5vkqi .cluster text{fill:#333;}#mermaid-svg-9qu48T5MoFv5vkqi .cluster span{color:#333;}#mermaid-svg-9qu48T5MoFv5vkqi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9qu48T5MoFv5vkqi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9qu48T5MoFv5vkqi rect.text{fill:none;stroke-width:0;}#mermaid-svg-9qu48T5MoFv5vkqi .icon-shape,#mermaid-svg-9qu48T5MoFv5vkqi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9qu48T5MoFv5vkqi .icon-shape p,#mermaid-svg-9qu48T5MoFv5vkqi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9qu48T5MoFv5vkqi .icon-shape .label rect,#mermaid-svg-9qu48T5MoFv5vkqi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9qu48T5MoFv5vkqi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9qu48T5MoFv5vkqi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9qu48T5MoFv5vkqi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} lw.comm-server
浏览器 LaserWeb4
socket.emit('runJob')
SVG / DXF
PNG / JPG
Redux documents\[\]
Redux operations\[\]
getGcode()
Redux gcode.content
gcodeQueue
send1Q / machineSend
SerialPort.write
Grbl / Smoothie / Marlin ...

两层职责:

做什么 不做什么
浏览器 解析图形、算刀路、生成 G-code 字符串 不打开串口
lw.comm-server 拆行、排队、按固件缓冲推送 不理解 SVG / 像素

前端入口:src/components/com.jsrunJob()socket.emit('runJob', job)

服务器把整份文本按 \n 拆开,进 gcodeQueue,再按 Grbl / Smoothie / TinyG / Marlin 各自的 RX 缓冲往外推。收到 ok 才发下一行(或下一批)。


2. 端到端数据流

点 Generate 时,总调度是 src/lib/cam-gcode.jsgetGcode()。每个启用的 operation 先做 preflight (从文档树抽出几何 / 图片),再按 op.type 分流。
#mermaid-svg-3Ayref5uJ1s4pYss{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3Ayref5uJ1s4pYss .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Ayref5uJ1s4pYss .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Ayref5uJ1s4pYss .error-icon{fill:#552222;}#mermaid-svg-3Ayref5uJ1s4pYss .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Ayref5uJ1s4pYss .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Ayref5uJ1s4pYss .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Ayref5uJ1s4pYss .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Ayref5uJ1s4pYss .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Ayref5uJ1s4pYss .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Ayref5uJ1s4pYss .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Ayref5uJ1s4pYss .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Ayref5uJ1s4pYss .marker.cross{stroke:#333333;}#mermaid-svg-3Ayref5uJ1s4pYss svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Ayref5uJ1s4pYss p{margin:0;}#mermaid-svg-3Ayref5uJ1s4pYss .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3Ayref5uJ1s4pYss .cluster-label text{fill:#333;}#mermaid-svg-3Ayref5uJ1s4pYss .cluster-label span{color:#333;}#mermaid-svg-3Ayref5uJ1s4pYss .cluster-label span p{background-color:transparent;}#mermaid-svg-3Ayref5uJ1s4pYss .label text,#mermaid-svg-3Ayref5uJ1s4pYss span{fill:#333;color:#333;}#mermaid-svg-3Ayref5uJ1s4pYss .node rect,#mermaid-svg-3Ayref5uJ1s4pYss .node circle,#mermaid-svg-3Ayref5uJ1s4pYss .node ellipse,#mermaid-svg-3Ayref5uJ1s4pYss .node polygon,#mermaid-svg-3Ayref5uJ1s4pYss .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3Ayref5uJ1s4pYss .rough-node .label text,#mermaid-svg-3Ayref5uJ1s4pYss .node .label text,#mermaid-svg-3Ayref5uJ1s4pYss .image-shape .label,#mermaid-svg-3Ayref5uJ1s4pYss .icon-shape .label{text-anchor:middle;}#mermaid-svg-3Ayref5uJ1s4pYss .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3Ayref5uJ1s4pYss .rough-node .label,#mermaid-svg-3Ayref5uJ1s4pYss .node .label,#mermaid-svg-3Ayref5uJ1s4pYss .image-shape .label,#mermaid-svg-3Ayref5uJ1s4pYss .icon-shape .label{text-align:center;}#mermaid-svg-3Ayref5uJ1s4pYss .node.clickable{cursor:pointer;}#mermaid-svg-3Ayref5uJ1s4pYss .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3Ayref5uJ1s4pYss .arrowheadPath{fill:#333333;}#mermaid-svg-3Ayref5uJ1s4pYss .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3Ayref5uJ1s4pYss .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3Ayref5uJ1s4pYss .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Ayref5uJ1s4pYss .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3Ayref5uJ1s4pYss .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Ayref5uJ1s4pYss .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3Ayref5uJ1s4pYss .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3Ayref5uJ1s4pYss .cluster text{fill:#333;}#mermaid-svg-3Ayref5uJ1s4pYss .cluster span{color:#333;}#mermaid-svg-3Ayref5uJ1s4pYss div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3Ayref5uJ1s4pYss .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3Ayref5uJ1s4pYss rect.text{fill:none;stroke-width:0;}#mermaid-svg-3Ayref5uJ1s4pYss .icon-shape,#mermaid-svg-3Ayref5uJ1s4pYss .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Ayref5uJ1s4pYss .icon-shape p,#mermaid-svg-3Ayref5uJ1s4pYss .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3Ayref5uJ1s4pYss .icon-shape .label rect,#mermaid-svg-3Ayref5uJ1s4pYss .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Ayref5uJ1s4pYss .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3Ayref5uJ1s4pYss .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3Ayref5uJ1s4pYss :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} SVG / DXF
PNG / JPG / BMP
Laser Cut / Inside / Outside / Fill Path
Laser Raster / Raster Merge
导入文件
类型
解析 → rawPaths 毫米折线
dataURL + transform2d
Redux documents\[\]
用户勾选图形,建 Operation
getGcode()
cam-preflight worker
op.type
cam-lasercut worker
cam-gcode-raster + RasterToGcode
G0 / G1 + 开关激光,功率固定
扫描线 G0 / G1,每点一个 S
gcodeStart + 各 op + gcodeEnd
state.gcode.content
Play → runJob → 串口流式发送

分流代码就在这里:

javascript 复制代码
// src/lib/cam-gcode.js
if (op.type === 'Laser Cut' || op.type === 'Laser Cut Inside'
    || op.type === 'Laser Cut Outside' || op.type === 'Laser Fill Path') {
    invokeWebWorker(cam-lasercut.js, { geometry, openGeometry, tabGeometry }, ...)
} else if (op.type === 'Laser Raster') {
    getLaserRasterGcodeFromOp(..., docsWithImages, ...)
}

最后一行:

javascript 复制代码
done(settings.gcodeStart + gcode.join('\r\n') + settings.gcodeEnd);

下面分开讲矢量、填充、位图。它们共享 preflight,刀路完全不同。


3. 矢量:导入时采样,之后只认折线

3.1 一切变成 rawPaths

内部几何不是 SVG DOM,也不是贝塞尔。每个图形是:

text 复制代码
rawPaths: [ [x0,y0, x1,y1, x2,y2, ...],  ... ]

单位毫米。闭合:首尾坐标相同。颜色记在 strokeColor / fillColor,后面可以按颜色过滤。

入口:src/components/cam.jsloadDocumentsrc/reducers/document.jsloadSvg()

<path><rect> / <circle> 走两条线性化路径,结果格式相同:
#mermaid-svg-ESynHdSjWdg5rnSk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ESynHdSjWdg5rnSk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ESynHdSjWdg5rnSk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ESynHdSjWdg5rnSk .error-icon{fill:#552222;}#mermaid-svg-ESynHdSjWdg5rnSk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ESynHdSjWdg5rnSk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ESynHdSjWdg5rnSk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ESynHdSjWdg5rnSk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ESynHdSjWdg5rnSk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ESynHdSjWdg5rnSk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ESynHdSjWdg5rnSk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ESynHdSjWdg5rnSk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ESynHdSjWdg5rnSk .marker.cross{stroke:#333333;}#mermaid-svg-ESynHdSjWdg5rnSk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ESynHdSjWdg5rnSk p{margin:0;}#mermaid-svg-ESynHdSjWdg5rnSk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ESynHdSjWdg5rnSk .cluster-label text{fill:#333;}#mermaid-svg-ESynHdSjWdg5rnSk .cluster-label span{color:#333;}#mermaid-svg-ESynHdSjWdg5rnSk .cluster-label span p{background-color:transparent;}#mermaid-svg-ESynHdSjWdg5rnSk .label text,#mermaid-svg-ESynHdSjWdg5rnSk span{fill:#333;color:#333;}#mermaid-svg-ESynHdSjWdg5rnSk .node rect,#mermaid-svg-ESynHdSjWdg5rnSk .node circle,#mermaid-svg-ESynHdSjWdg5rnSk .node ellipse,#mermaid-svg-ESynHdSjWdg5rnSk .node polygon,#mermaid-svg-ESynHdSjWdg5rnSk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ESynHdSjWdg5rnSk .rough-node .label text,#mermaid-svg-ESynHdSjWdg5rnSk .node .label text,#mermaid-svg-ESynHdSjWdg5rnSk .image-shape .label,#mermaid-svg-ESynHdSjWdg5rnSk .icon-shape .label{text-anchor:middle;}#mermaid-svg-ESynHdSjWdg5rnSk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ESynHdSjWdg5rnSk .rough-node .label,#mermaid-svg-ESynHdSjWdg5rnSk .node .label,#mermaid-svg-ESynHdSjWdg5rnSk .image-shape .label,#mermaid-svg-ESynHdSjWdg5rnSk .icon-shape .label{text-align:center;}#mermaid-svg-ESynHdSjWdg5rnSk .node.clickable{cursor:pointer;}#mermaid-svg-ESynHdSjWdg5rnSk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ESynHdSjWdg5rnSk .arrowheadPath{fill:#333333;}#mermaid-svg-ESynHdSjWdg5rnSk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ESynHdSjWdg5rnSk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ESynHdSjWdg5rnSk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ESynHdSjWdg5rnSk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ESynHdSjWdg5rnSk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ESynHdSjWdg5rnSk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ESynHdSjWdg5rnSk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ESynHdSjWdg5rnSk .cluster text{fill:#333;}#mermaid-svg-ESynHdSjWdg5rnSk .cluster span{color:#333;}#mermaid-svg-ESynHdSjWdg5rnSk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ESynHdSjWdg5rnSk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ESynHdSjWdg5rnSk rect.text{fill:none;stroke-width:0;}#mermaid-svg-ESynHdSjWdg5rnSk .icon-shape,#mermaid-svg-ESynHdSjWdg5rnSk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ESynHdSjWdg5rnSk .icon-shape p,#mermaid-svg-ESynHdSjWdg5rnSk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ESynHdSjWdg5rnSk .icon-shape .label rect,#mermaid-svg-ESynHdSjWdg5rnSk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ESynHdSjWdg5rnSk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ESynHdSjWdg5rnSk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ESynHdSjWdg5rnSk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} path d='...'
rect / line / polyline / circle / ellipse
SVG 文件
标签
pathStrToRawPaths
Snap.parsePathString
Snap.path.toCubic 全部变成三次贝塞尔
linearizeCubicBezier 按弦长采样
tagparser.js → getPaths()
直线:直接加点
曲线:lw.svg-curves 的 Arc / CubicBezier / QuadricBezier
rawPaths 毫米折线

矩形(无圆角) 先改写成四条直线再闭合:

javascript 复制代码
// src/lib/lw.svg-parser/tagparser.js  _rect()
this._path(['M', x, y, 'h', w, 'v', h, 'h', -w, 'z'])

四个角四个顶点,不会被切碎。

曲线 必须变成折线。<path>Snap.path.toCubic 把 L/H/V/A/Q 都变成 C,再:

javascript 复制代码
// src/lib/mesh.js  linearizeCubicBezier()
if (控制点等于端点)
    return ['L', p2x, p2y];          // 直线退化,不再切分
else
    弦长超过阈值就把段数 × 2;         // 自适应加密

默认弦长大约 0.1 × pxPerInch / 25.4(96 dpi 约 0.38 个 SVG 单位)。圆、椭圆、A 弧走 lw.svg-curves,默认 segmentLength: 1

本项目不输出 G2/G3。 圆弧在导入瞬间就已经是一串点。

3.2 画布画的也是这些点

文档状态里没有原始 d="..."。渲染走 WebGL,不是 <svg>

src/components/document-cache.jsrawPaths 编成 GPU 缓冲:

  • 描边:LINE_STRIP 点连点
  • 填充预览:triangulateRawPaths(poly2tri)三角化
  • 选中高亮:同样的点加厚画

所以预览和刀路是同一份几何。直线仍然是几个顶点;曲线是密点。放大曲线可能看出折线感,因为不会按缩放再采样

3.3 Preflight:闭合面 XOR,开口线另放

src/lib/workers/cam-preflight.js 遍历 operation 勾选的文档:

条件 去向
闭合路径 geometry = xor(geometry, clipperPaths),偶奇规则
开口路径 openGeometry(填充不用它)
type === 'image' docsWithImages(光栅用)

单位:mm → Clipper 整数(× 50_000_000),算完再除回去。布尔运算用 clipper-lib (Angus Johnson),填充规则写死 even-odd ,不读 SVG 的 fill-rule="nonzero"

重叠的闭合环会互相挖空:外圆 XOR 内圆 = 圆环。自交五角星按偶奇填尖、空心。

3.4 四种矢量操作

src/lib/cam-gcode-laser-cut.jsgetLaserCutGcodeFromOp() 按类型选刀路,算法都在 src/lib/cam.js

Operation 函数 含义
Laser Cut cut(geometry, openGeometry) 沿路径中心线(开口线雕、闭合切割)
Laser Cut Inside insideOutside(..., isInside=true) 按光束直径向内偏置(补 kerf)
Laser Cut Outside insideOutside(..., isInside=false) 向外偏置
Laser Fill Path fillPath(geometry, lineDistance, lineAngle) 面内平行填充线

没有单独的 Engrave:线雕就是 Cut 开口路径;区域填充就是 Fill Path。

cut() 把闭合环首尾相接,开口路径原样留下,再用最近邻 mergePaths() 减少空走。insideOutside() 用 ClipperOffset,可按 cutWidth / stepOver 多圈。

3.5 折线写成 G-code

getLaserCutGcode() 对每条刀路、每一遍 pass:

  1. G0 到起点
  2. 可选 G0 Z(多层)
  3. 开激光(设置里的 gcodeToolOn,可含 $INTENSITY
  4. 沿线 G1 X Y F
  5. 关激光(gcodeToolOff

功率整条路径一个 S:

javascript 复制代码
laserOnS = gcodeLaserIntensity
         + (gcodeSMinValue + (gcodeSMaxValue - gcodeSMinValue) * laserPower / 100)

拼字符串的是 src/lib/action2gcode/generators/default-generator.jsmoveRapidG0moveToolG1。Marlin 生成器会把 S 放到 Tool On 那一行。

矩形 Laser Cut 大致是:

复制代码
G0 X0.00 Y0.00
M3 S800
G1 X50.00 Y0.00 F3000
G1 X50.00 Y30.00
G1 X0.00 Y30.00
G1 X0.00 Y0.00
M5

曲线则是一长串很密的 G1。M3/M5 不是写死的,来自设置。


4. 填充模式:先算面,再铺平行线

填充不是把区域涂实,也不是位图那种逐像素变功率。它是:偶奇规则算出该烧的面,再在面里铺固定功率的平行线。

4.1 面怎么来

只要有一条闭合路径,该文档的 rawPathsSimplifyPolygons(even-odd),再和 operation 里其它图形 XOR。开口线不参与填充。

可选 margin:填充前 offset(geometry, -margin),往里收一圈,避免填到轮廓上。

三种常见形状:

text 复制代码
圆角矩形     一条闭合折线(直边 + 角上采样点)
             → 一个实心面,线的端点贴着圆角

自交五角星   偶奇:尖是实心,中心五边形是孔
不自交星轮廓  整个星含中心都实心

中空环       同一 path 两个子路径,或外圆+内圆两个文档
             → XOR 得到环带;扫描线穿过孔时断开、G0 跳过

画布上的实心色块只是三角化预览,不是刀路。真正要走的线要等 Generate 之后在 G-code 预览里看。

4.2 fillPath 算法

javascript 复制代码
// src/lib/cam.js
export function fillPath(geometry, lineDistance, angle) {
    // 1. 包围盒中心,按 lineAngle 旋转
    // 2. 间距 lineDistance 铺之字形条带(覆盖包围盒)
    // 3. separateTabs(scan, geometry):条带 ∩ 填充面
    // 4. 取奇数段 = 落在面内的线段
    // 5. mergePaths:就近串起来,少空走
}

#mermaid-svg-oyBEOYf0hrXHLnkK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oyBEOYf0hrXHLnkK .error-icon{fill:#552222;}#mermaid-svg-oyBEOYf0hrXHLnkK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oyBEOYf0hrXHLnkK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oyBEOYf0hrXHLnkK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oyBEOYf0hrXHLnkK .marker.cross{stroke:#333333;}#mermaid-svg-oyBEOYf0hrXHLnkK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oyBEOYf0hrXHLnkK p{margin:0;}#mermaid-svg-oyBEOYf0hrXHLnkK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oyBEOYf0hrXHLnkK .cluster-label text{fill:#333;}#mermaid-svg-oyBEOYf0hrXHLnkK .cluster-label span{color:#333;}#mermaid-svg-oyBEOYf0hrXHLnkK .cluster-label span p{background-color:transparent;}#mermaid-svg-oyBEOYf0hrXHLnkK .label text,#mermaid-svg-oyBEOYf0hrXHLnkK span{fill:#333;color:#333;}#mermaid-svg-oyBEOYf0hrXHLnkK .node rect,#mermaid-svg-oyBEOYf0hrXHLnkK .node circle,#mermaid-svg-oyBEOYf0hrXHLnkK .node ellipse,#mermaid-svg-oyBEOYf0hrXHLnkK .node polygon,#mermaid-svg-oyBEOYf0hrXHLnkK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oyBEOYf0hrXHLnkK .rough-node .label text,#mermaid-svg-oyBEOYf0hrXHLnkK .node .label text,#mermaid-svg-oyBEOYf0hrXHLnkK .image-shape .label,#mermaid-svg-oyBEOYf0hrXHLnkK .icon-shape .label{text-anchor:middle;}#mermaid-svg-oyBEOYf0hrXHLnkK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oyBEOYf0hrXHLnkK .rough-node .label,#mermaid-svg-oyBEOYf0hrXHLnkK .node .label,#mermaid-svg-oyBEOYf0hrXHLnkK .image-shape .label,#mermaid-svg-oyBEOYf0hrXHLnkK .icon-shape .label{text-align:center;}#mermaid-svg-oyBEOYf0hrXHLnkK .node.clickable{cursor:pointer;}#mermaid-svg-oyBEOYf0hrXHLnkK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oyBEOYf0hrXHLnkK .arrowheadPath{fill:#333333;}#mermaid-svg-oyBEOYf0hrXHLnkK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oyBEOYf0hrXHLnkK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oyBEOYf0hrXHLnkK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oyBEOYf0hrXHLnkK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oyBEOYf0hrXHLnkK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oyBEOYf0hrXHLnkK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oyBEOYf0hrXHLnkK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oyBEOYf0hrXHLnkK .cluster text{fill:#333;}#mermaid-svg-oyBEOYf0hrXHLnkK .cluster span{color:#333;}#mermaid-svg-oyBEOYf0hrXHLnkK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oyBEOYf0hrXHLnkK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oyBEOYf0hrXHLnkK rect.text{fill:none;stroke-width:0;}#mermaid-svg-oyBEOYf0hrXHLnkK .icon-shape,#mermaid-svg-oyBEOYf0hrXHLnkK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oyBEOYf0hrXHLnkK .icon-shape p,#mermaid-svg-oyBEOYf0hrXHLnkK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oyBEOYf0hrXHLnkK .icon-shape .label rect,#mermaid-svg-oyBEOYf0hrXHLnkK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oyBEOYf0hrXHLnkK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oyBEOYf0hrXHLnkK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oyBEOYf0hrXHLnkK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Clipper 填充面

含孔、星尖、圆角
旋转后的平行条带
separateTabs

线 ∩ 多边形
面内线段
mergePaths
G0 跳到线头 → 开激光 → G1 → 关激光

碰到边界或孔就断开:

复制代码
外圆 ████████████████████
     ████          ████     ← 孔:M5,G0 跳过,再 M3
     ████████████████████

lineDistance 必须大于 0,否则直接报错。功率仍是固定的 laserPower。要灰度深浅,用光栅,不要用 Fill Path。

4.3 这段代码是谁写的

铺线策略是 Todd Fleming 在 LW4 里加的(jscut 没有 fillPath,铣床 Pocket 是同心偏置)。求交没有引入现成的 infill 库,而是:

步骤 实现
怎么铺平行线 作者 JS gl-matrix 只做旋转
线 ∩ 填充面 作者 C++,Emscripten Boost.Polygonweb-cam-cppseparateTabs
闭合面 / 挖孔 / 内缩 第三方 clipper-lib

separateTabs 本是 jscut 给铣床「过桥」用的:Clipper 处理开口路径会丢点、改方向,所以用 Boost.Polygon 另写。填充把它借用过来------填充面当 tab 区域,扫描线当刀路,奇数段就是面内。


5. 位图:扫描网格 + 每像素一个 S

矢量是「路径 → 折线 → G1」。位图是「像素 → 一行行扫 → 每个灰度一个功率」。

5.1 导入与缩放

PNG/JPG/BMP:FileReader.readAsDataURL,存 dataURL。物理尺寸:

text 复制代码
宽(mm) = naturalWidth / dpiBitmap × 25.4

默认 dpiBitmap = 300。网格间距等于 operation 的 laserDiameter(光斑直径)。

位图还有旁路:Potrace 描成 SVG(src/components/image-filters.js),之后当矢量 Cut / Fill,不再走光栅。

5.2 像素处理

src/lib/lw.raster2gcode/canvas-grid.js 画到 canvas,canvas-filters.js 按像素:

反相 → 亮度 → 对比度 → 伽马 → 灰度算法 → 可选 Floyd-Steinberg 抖动

读像素时白=不烧、黑=满功率:

javascript 复制代码
// src/lib/lw.raster2gcode/raster-to-gcode.js
_getPixelPower(x, y) {
    y = this.size.height - y - 1;   // canvas 原点在左上
    return 255 - this.getPixel(x, y).gray;
}
_mapPixelPower(value) {
    return value * (range.max - range.min) / 255 + range.min;
}

range 来自 gcodeSMaxValue × laserPowerRange。Grbl 的 S 上限经常是 1,不是 255。

5.3 扫描成 G-code

RasterToGcode.run()

  1. 默认水平双向扫描(一行左→右,下一行右→左);可选 45° 对角
  2. 去掉两端全白、合并相邻同功率、可选 overscan
  3. 行首 G0 X Y S0,沿线 G1 X Y S<功率>
javascript 复制代码
addCommand(this.G0, ['X', point.X], ['Y', point.Y], ['S', 0])
while (point) {
    addCommand(point.G, ['X', point.X], ['Y', point.Y], ['S', point.S])
    point = this._getPoint(++index)
}

坐标:X = pixelX × laserDiameter。后处理(cam-gcode-raster.js)再包多层 pass、Z、以及设置里的 Tool On/Off。M3/M4 同样不是写死的。

像素网格在主线程算完,出码在 worker cam-raster.js,避免卡住 UI。

Laser Raster Merge 先把多份矢量 / 图合成一张白底图,再走同一套扫描。

5.4 和矢量对比

矢量 Cut / Fill 位图 Raster
输入 rawPaths canvas 像素
几何库 Clipper + web-cam-cpp lw.raster2gcode
运动 沿轮廓,或面内平行线 整幅图一行行扫
功率 整条路径一个 S 每个灰度一个 S
G-code 体积 路径短 非常密

6. 从文本到机床:流式发送

Play 读取 state.gcode.contentcom.runJob() 一次把整串发出去。服务器:

  1. 按行拆开,去掉 ; 后注释
  2. 全部入队
  3. send1Q() 按固件缓冲推
  4. machineSend()port.write()
固件 流控
Grbl 跟踪 RX 字符(默认 128),有空位再发,等 ok
Smoothie 默认一行一确认
TinyG 约 24 行未确认
Marlin / Repetier 约 2 行缓冲

暂停 / 恢复 / 中止走实时命令(Grbl 的 ! / ~ / Ctrl-X 等)。CAM 层不参与流控。


7. 核心文件与方法

总调度

文件 方法 作用
src/lib/cam-gcode.js getGcode() 按 operation 分流,拼接起止 G-code
src/lib/workers/cam-preflight.js examineDocTree() 文档树 → geometry / openGeometry / docsWithImages
src/components/cam.js loadDocument / generateGcode 导入 UI、触发生成

矢量导入与几何

文件 方法 作用
src/reducers/document.js loadSvg() SVG → rawPaths
src/lib/mesh.js pathStrToRawPaths / linearizeCubicBezier 贝塞尔采样成折线
src/lib/mesh.js rawPathsToClipperPaths / xor / offset 毫米 ↔ Clipper,布尔、偏置
src/lib/lw.svg-parser/tagparser.js _rect / _circle / _pathC / _pathA 基本图形与曲线采样
src/lib/dxf.js processDXF DXF → rawPaths
src/components/document-cache.js update() 折线 → WebGL 缓冲

矢量刀路与 G-code

文件 方法 作用
src/lib/cam.js cut / insideOutside / fillPath / mergePaths 中心线、偏置、填充、路径合并
src/lib/cam.js separateTabs 开口路径 ∩ 多边形(填充复用;C++ 在 web-cam-cpp)
src/lib/cam-gcode-laser-cut.js getLaserCutGcodeFromOp / getLaserCutGcode 选算法,写出 G0/G1
src/lib/action2gcode/generators/default-generator.js moveRapid / moveTool / toolOn / toolOff 拼 G-code 字符串

位图

文件 方法 作用
src/lib/cam-gcode-raster.js getLaserRasterGcodeFromOp 变换画布、调光栅引擎、后处理
src/lib/lw.raster2gcode/canvas-filters.js canvasFilters 灰度、抖动
src/lib/lw.raster2gcode/canvas-grid.js _processImage / getPixel 像素网格
src/lib/lw.raster2gcode/raster-to-gcode.js run / _scanHorizontally / _mapPixelPower 扫描线 → G1+S
src/lib/workers/cam-raster.js onmessage Worker 里跑 RasterToGcode.run()

发送

文件 方法 作用
src/components/com.js runJob / runCommand / handleConnectMachine Socket.IO 发任务、连机器
src/components/jog.js runJob() Play 按钮读 gcode.content
lw.comm-server(独立仓库) runJob / send1Q / machineSend 队列、流控、SerialPort.write

8. 一张图收束

text 复制代码
SVG/DXF                         PNG/JPG
  │ 导入时采样                      │ canvas 像素
  ▼                                ▼
rawPaths 折线                    dataURL + 滤镜网格
  │                                │
  ├─ Laser Cut:沿折线             │
  ├─ Inside/Outside:Clipper 偏置  │
  └─ Fill Path:偶奇面 + 平行线     └─ 双向扫描,每点一个 S
              │                                │
              └────────────┬───────────────────┘
                           ▼
              gcodeStart + G0/G1 + ToolOn/Off + gcodeEnd
                           ▼
              Socket.IO → lw.comm-server → 串口(按 ok 流控)

记住三条边界,方案就清楚了:

  1. 曲线只在导入时变成折线,G-code 里没有圆弧指令。
  2. 矢量填充是面内平行线,不是涂实,也不是灰度。孔靠 even-odd / XOR。
  3. 位图才按像素变功率;和矢量只在最终那份文本、以及串口队列里汇合。
相关推荐
Dfreedom.3 小时前
目标检测后处理核心:NMS非极大值抑制详解
图像处理·人工智能·深度学习·目标检测·目标跟踪
牧羊人.3338 小时前
动手学深度学习 02 | 手写数字识别
图像处理·人工智能·深度学习·算法
AndrewHZ8 小时前
图像处理入门023 | 自适应直方图均衡化:CLAHE——把 HE 的“全局“变“局部“,治好光照不均与噪声放大
图像处理·双线性插值·自适应均衡化·clahe·对比度限制·分块处理·美颜pipeline
hahaha60162 天前
HLS高层次综合设计技巧--C++类和模板
图像处理·人工智能·算法·计算机视觉
AndrewHZ2 天前
图像处理入门021 | 直方图:图像像素分布的可视化 —— cv2.calcHist 与 4 类典型分布
图像处理·opencv·直方图·直方图均衡化·cdf·灰度直方图·多通道直方图
AndrewHZ3 天前
图像处理入门020 | 图像二值化:阈值分割基础 —— cv2.threshold 五种类型与自适应阈值铺垫
图像处理·opencv·文档扫描·阈值分割·图像二值化·trackbar·自适应阈值
笑霸final3 天前
不用上传服务器!用 Vue3 + ONNX Runtime Web 在浏览器本地实现智能抠图
运维·前端·图像处理·ai·onnx·canva可画·web性能优化
A hao4 天前
户外LED广告牌需要什么防护等级IP级别?
大数据·网络·图像处理·人工智能·网络协议·tcp/ip·广告
一只嵌入式爱好者5 天前
深入理解ISP
图像处理·isp