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.js 的 runJob() → socket.emit('runJob', job)。
服务器把整份文本按 \n 拆开,进 gcodeQueue,再按 Grbl / Smoothie / TinyG / Marlin 各自的 RX 缓冲往外推。收到 ok 才发下一行(或下一批)。
2. 端到端数据流
点 Generate 时,总调度是 src/lib/cam-gcode.js 的 getGcode()。每个启用的 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.js 的 loadDocument → src/reducers/document.js 的 loadSvg()。
<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.js 把 rawPaths 编成 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.js 的 getLaserCutGcodeFromOp() 按类型选刀路,算法都在 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:
G0到起点- 可选
G0 Z(多层) - 开激光(设置里的
gcodeToolOn,可含$INTENSITY) - 沿线
G1 X Y F - 关激光(
gcodeToolOff)
功率整条路径一个 S:
javascript
laserOnS = gcodeLaserIntensity
+ (gcodeSMinValue + (gcodeSMaxValue - gcodeSMinValue) * laserPower / 100)
拼字符串的是 src/lib/action2gcode/generators/default-generator.js:moveRapid → G0,moveTool → G1。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 面怎么来
只要有一条闭合路径,该文档的 rawPaths 先 SimplifyPolygons(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.Polygon (web-cam-cpp 的 separateTabs) |
| 闭合面 / 挖孔 / 内缩 | 第三方 | 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():
- 默认水平双向扫描(一行左→右,下一行右→左);可选 45° 对角
- 去掉两端全白、合并相邻同功率、可选 overscan
- 行首
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.content,com.runJob() 一次把整串发出去。服务器:
- 按行拆开,去掉
;后注释 - 全部入队
send1Q()按固件缓冲推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 流控)
记住三条边界,方案就清楚了:
- 曲线只在导入时变成折线,G-code 里没有圆弧指令。
- 矢量填充是面内平行线,不是涂实,也不是灰度。孔靠 even-odd / XOR。
- 位图才按像素变功率;和矢量只在最终那份文本、以及串口队列里汇合。