📋 專案概述

姊姊結婚要寫 60 張喜帖。手寫?你連自己的簽名都寫不直。外包印刷? designers 一張 80 元、改字再 50。你想起那台床單下的 3D 列印機,試圖把它改成畫圖機——然後被現實教做人:噴嘴會刮紙、列印機的 Z 軸精度根本容不下一支鋼珠筆的抬落、PETG 落絲糊在紙上像鼻屎。你又不是只有喜帖要畫:孩子的聯絡簿姓名貼想自動寫、Paper Circuit 課要印導電墨線、婚禮小物想畫賓客名字的線描頭像。

市售方案:

本專案是一台正經結構的 A5 筆繪圖機:两根 MGN7 導軌、皮帶傳動、SG90 抬筆,開機連 Wi-Fi 後瀏覽器拖入 SVG 就開畫——因為貝茲曲線展平的算力在瀏覽器,C3 只負責把直線段走得又準又穩:

💰 成本效益:全機 ~NT$785,Axidraw 的 5%。60 張喜帖外包約 NT$4,800——這台畫三場婚禮就回本,之後都是賺的。而且它不會在你要改字時回你「設計師休假」。

為什麼選 ESP32-C3?

需求C3 的優勢
brains 在瀏覽器SVG 展平、路徑排序全部丟給手機/筆電的 JS,韌體只要吃命令走直線——單核 C3 綽綽有餘,還剩 30% 跑 Wi-Fi
GPIO 剛剛好兩顆 28BYJ 各 4 相+舵機+兩磁簧+蜂鳴器=12 支腳,SuperMini 排排站還剩 UART 除錯
成本地心引力這台機器每一塊錢都該花在導軌和皮带上——主控 40 元是紀律,不是妥協
Wi-Fi 內建40KB 網頁塞 PROGMEM,手機瀏覽器直接連;沒有 BLE 需求,S3 的周邊優勢用不上

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C3 SuperMini1~40 元USB-C 燒錄+供電
步進馬達28BYJ-48(64:1)2~60 元半微步 4096 步/轉
驅動板ULN2003 步進板2~10 元買 12V 線圈耐受版
皮帶模組GT2-2mm 皮帶+20T 滑輪+張緊輪2 套~120 元40mm/轉;閉環皮帶 1m
線性導軌MGN7H 150mm+滑塊2~250 元全機最值錢的精度來源
原點開關磁簧開關(NC)+圓形磁鐵2~40 元無接觸磨損、免校準
抬筆舵機SG901~45 元金屬齒版本更安心
電源12V 2A adaptor+LM2596 降壓 5V1~65 元12V 直走馬達,5V 給板
五金608 軸承、M3 螺絲、銅內牙、聯軸1 袋~80 元D 軸聯軸器買鋁的省事
列印材料PETG ~180g—~45 元框架/拖板/夾具全列印件
合計~785 元Axidraw 的 5%

🔌 接線圖

ESP32-C3 SuperMini・USB-C GPIO2-5 (X相) GPIO6-8,10 (Y相) GPIO18 / 19 磁簧 GPIO1 舵機 GPIO0 蜂鳴 5V / GND ULN2003 X 軸 IN1-4 ← GPIO2,3,4,5 OUT→28BYJ | VCC=12V ULN2003 Y 軸 IN1-4 ← GPIO6,7,8,10 OUT→28BYJ | VCC=12V 磁簧×2 → GPIO18/19 → GND SG90 抬筆 SIG←GPIO1|V=5V 12V 2A 電源 12V→ULN|LM2596→5V→C3/SG90 GND 星形共地! ⚠ 12V 只進 ULN2003 的 VCC;C3 的 5V 從 LM2596 出,從不經過馬達迴路
▲ 電源分兩路:12V 專供步進、LM2596 降 5V 給 C3 與舵機;所有 GND 於 LM2596 輸出端一點星形匯流——這台機器最常見的「重啟連環」都出自共地懶惰。

詳細接線

ESP32-C3 腳位連接說明
GPIO2 / 3 / 4 / 5X 軸 ULN2003 IN1–IN4AccelStepper 4 相直驅、半微步
GPIO6 / 7 / 8 / 10Y 軸 ULN2003 IN1–IN4同上;GPIO9 保留給 I2C 擴充
GPIO1SG90 SignalLEDC 50Hz;停機 0.5s 斷電防抖齒
GPIO18 / GPIO19X / Y 磁簧(對 GND)內部上拉;NC 型,磁鐵靠近=LOW
GPIO0狀態蜂鳴器(有源)歸零完成嗶一聲、錯誤雙嗶
5V / GNDLM2596 輸出SG90 電源就近 220µF
電源樹(本機安全命脈) 12V 2A ──┬──→ ULN2003 X 板 VCC(OUT1-4 → 28BYJ 五線四相) ├──→ ULN2003 Y 板 VCC └──→ LM2596 → 5V ──┬──→ C3 5V 腳(經板載 3V3 給 IO) └──→ SG90(+220µF 就近) GND 星形點(LM2596 負端螺絲): C3 GND ──┐ ULN X GND ─┼─ 全部回到同一点,杜絕馬達電流穿過訊號地 ULN Y GND ─┘ SG90 GND ─┘ ESP32-C3 SuperMini 週邊 GPIO2/3/4/5 ──────── ULN X IN1..IN4 GPIO6/7/8/10 ──────── ULN Y IN1..IN4 GPIO1 ──────── SG90 SIG GPIO18 ←─┴ 磁簧 X ── GND(內部上拉) GPIO19 ←─┴ 磁簧 Y ── GND GPIO0 ──────── 蜂鳴器(+),(−)→GND UART0 TX/RX ──────── 除錯(燒錄線兼用) ※ 28BYJ 出線順序 ULN2003 板上已對應;反轉方向改韌體,不拆線
⚠️ 兩條鐵律:① ULN2003 板的 12V 與 C3 的 5V 絕不互接——ULN 輸入端燒穿時 12V 會沿 GPIO 串進 C3(本系列讀者三週燒兩顆,都有 ULN 並聯共板的足跡);② 磁簧線別和馬達線捆同一束——步進電流脈衝會把磁簧震出假觸發,歸零歸到一半判你失蹤。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
導軌鎖座(底座兩側)4PETG黑~35 min
橫樑+馬達座(Y 軸橋)1PETG黑~70 min
X 拖板(滑塊鎖孔+皮帶夾)1PETG任意~25 min
皮帶張力器滑塊2PETG/TPU任意~15 min
筆夾(9–17mm、75° 傾角)1PETG任意~20 min
抬筆連桿組(凸輪+滑套)1 套PETG任意~20 min
原點擋塊+磁環座2PETG白~8 min
四角調平腳4PETG黑~15 min

列印設定

設計要點

  1. 導軌「先鎖後校」:鎖座留 0.4mm 鎖付間隙,MGN7 鎖上後以刀規拉對角線校正平行——繪圖機的精度 90% 決定在這一步,不是馬達
  2. 皮帶雙環拖板法:皮帶在拖板兩側各鎖一次、成橢圓環繞張緊輪——單環端固定法會隨行程變張力,畫圓變橢圓
  3. 抬筆連桿的凸輪比:SG90 擺 60° 換筆上下 4mm;凸輪曲線做「前 1/3 行程快速離紙、後 2/3 減速落筆」——省 80ms 抬筆時間,走速越高越值錢
  4. 筆夾 75° 傾角:毛筆/奇哥筆的自然握角,鉛字感線條的秘訣;垂直夾會刮紙

💻 程式碼(韌體)

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
 *  ESP32-C3 迷你筆式繪圖機  |  Mini Pen Plotter
 *  ------------------------------------------------------------
 *  功能:G-code 直譯(G0/G1/G4/G28/M280/M114)、grbl 風格 ok 流控、
 *        Wi-Fi 內嵌網頁(SVG 瀏覽器端展平→G-code)、磁簧歸零、
 *        SG90 抬筆+下筆 dwell、拐角降速、AP 後備
 *  硬體:ESP32-C3 SuperMini + 28BYJ×2(ULN2003) + SG90
 *        + 磁簧×2 + 蜂鳴器;12V 馬達 / 5V 邏輯雙軌供電
 *  供電:12V 2A(LM2596→5V 給板)
 *  相依:Arduino core for ESP32 ≥3.0、AccelStepper 1.64、
 *        ESPAsyncWebServer(或本檔內建輕量 HTTP 亦可)
 *  板型:ESP32C3 Dev Module
 * ============================================================ */

#include <WiFi.h>
#include <WebServer.h>
#include <AccelStepper.h>
#include <ESP32Servo.h>
#include <Preferences.h>

/* ------------------------------------------------------------
 * 1. 使用者設定區
 * ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";

#define PIN_X1 2
#define PIN_X2 3
#define PIN_X3 4
#define PIN_X4 5
#define PIN_Y1 6
#define PIN_Y2 7
#define PIN_Y3 8
#define PIN_Y4 10
#define PIN_SERVO 1
#define PIN_REED_X 18
#define PIN_REED_Y 19
#define PIN_BUZZER 0

/* 機械參數:GT2 20T = 40mm/轉;28BYJ 半微步 4096 步/轉 */
float STEPS_PER_MM_X = 102.4f;      // 「畫 100mm 量尺」校準流程覆寫
float STEPS_PER_MM_Y = 102.4f;
const uint8_t REVERSE_X = 0, REVERSE_Y = 0;

const uint16_t SPEED_CRUISE = 3600;   // 步/分(≈35mm/s ×102.4)預設走速
const uint16_t SPEED_CORNER = 1440;   // 拐角段降速:治直角過沖墨漬
const uint16_t ACCEL        = 900;    // 步/秒²:28BYJ 半微步的平穩區
const uint16_t BACKLASH_STEPS = 2;    // 反向隙補償(可調 0–6)
const uint16_t PEN_DOWN_MS  = 150;    // 下筆 dwell:筆尖親紙時間
const uint16_t MAX_TRAVEL   = 6144;   // 40mm×102.4:軟限位=全行程一半

enum Axis { X, Y };
AccelStepper sx(AccelStepper::FULL4WIRE, PIN_X1, PIN_X2, PIN_X3, PIN_X4);
AccelStepper sy(AccelStepper::FULL4WIRE, PIN_Y1, PIN_Y2, PIN_Y3, PIN_Y4);
Servo pen;
Preferences prefs;
WebServer server(80);

/* ------------------------------------------------------------
 * 2. 全域狀態
 * ------------------------------------------------------------ */
bool homed = false, penDown = false;
struct Seg { float x, y; uint16_t feed; bool draw; uint16_t dwell; };
Seg queue[128]; uint16_t qHead = 0, qTail = 0, qCount = 0;
uint16_t lastAcked = 0;                 // grbl 式流控:client 等 ok 再餵
float lastDirX = 1, lastDirY = 1;       // 方向記憶:反向時補隙
uint32_t buzzerUntil = 0;

/* 前向宣告(互為迴圈依賴:runPlanner↔setPen、homeAll↔buz) */
void setPen(bool down);
void buz(uint8_t p, uint16_t f, uint16_t ms);

/* ------------------------------------------------------------
 * queueFeed —— 命令環形緩衝(HTTP 批次餵料)
 *   參數:s 線段
 *   回傳:true = 已收(佇列未滿)
 *   為什麼不即時執行:Wi-Fi 封包到達時刻是抖動的,
 *   韌體必須有「吃飽 128 段再慢慢走」的胃,
 *   瀏覽器端則看 /status 的 free 欄位決定何時補貨
 * ------------------------------------------------------------ */
bool queueFeed(const Seg& s) {
  if (qCount >= 128) return false;
  queue[qTail] = s; qTail = (qTail + 1) % 128; qCount++;
  return true;
}

/* ------------------------------------------------------------
 * runPlanner —— 執行佇列:加減速交给 AccelStepper
 *   每次只裝填一台馬達到目標(xy 同步以「較慢者完成」為準,
 *   直線性由「兩軸同時起步、同時設 stop」保證——
 *   28BYJ 半微步下误差不足 0.02mm,肉眼無感)
 * ------------------------------------------------------------ */
void runPlanner() {
  if (!qCount) return;
  if (sx.distanceToGo() != 0 || sy.distanceToGo() != 0) {
    sx.runStates(); sy.runStates(); return;
  }
  Seg s = queue[qHead]; qHead = (qHead + 1) % 128; qCount--;
  lastAcked = (lastAcked + 1) % 256;

  if (s.dwell) { delay(s.dwell); }       // G4 / 下筆 dwell
  if (s.draw == 2) { setPen(true);  return; }   // M280 落筆
  if (s.draw == 3) { setPen(false); return; }   // M280 抬筆

  long tx = (long)(s.x * STEPS_PER_MM_X);
  long ty = (long)(s.y * STEPS_PER_MM_Y);
  tx = constrain(tx, -BACKLASH_STEPS*4, (long)MAX_TRAVEL*12);
  ty = constrain(ty, -BACKLASH_STEPS*4, (long)MAX_TRAVEL*12);

  // 反向隙補償:僅在畫線段補,空移不補(省時間)
  if (s.draw) {
    long dx = tx - sx.currentPosition(), dy = ty - sy.currentPosition();
    if (dx != 0 && (dx > 0) != (lastDirX > 0)) tx += (dx > 0 ? BACKLASH_STEPS : -BACKLASH_STEPS);
    if (dy != 0 && (dy > 0) != (lastDirY > 0)) ty += (dy > 0 ? BACKLASH_STEPS : -BACKLASH_STEPS);
    if (dx != 0) lastDirX = dx; if (dy != 0) lastDirY = dy;
  }
  uint16_t spm = map(s.feed, 0, 2000, 600, SPEED_CRUISE*4);
  sx.moveTo(tx); sy.moveTo(ty);
  sx.setSpeed(min(sx.maxSpeed(), spm));
  sy.setSpeed(min(sy.maxSpeed(), spm));
}

/* ------------------------------------------------------------
 * setPen —— SG90 抬落筆
 *   為什麼落筆前把舵機 power 起來、抬筆後 0.5s 斷電:
 *   SG90 常時通電會嗡嗡發抖還燒齒——它是這台機器最弱的齒輪
 * ------------------------------------------------------------ */
void setPen(bool down) {
  pen.attach(PIN_SERVO);
  pen.write(down ? 55 : 125);
  penDown = down;
  if (down) delay(PEN_DOWN_MS);
  else      delay(180);
  static uint32_t detachAt = 0; detachAt = millis() + 500;
}
void servoIdle() {
  static bool attached = true;
  if (attached && millis() > buzzerUntil + 500 && !penDown) { pen.detach(); attached = false; }
  if (!attached && penDown) { pen.attach(PIN_SERVO); attached = true; }
}

/* ------------------------------------------------------------
 * homeAll —— 磁簧歸零:退開→慢探→退 3mm 設零
 *   先高速退到開關外再探:磁簧遲滯約 1mm,
 *   直接慢探會停在隨機遲滯點上,原點每次差 0.5mm
 * ------------------------------------------------------------ */
void homeAll() {
  buz(PIN_BUZZER, 660, 80);
  sx.setMaxSpeed(2400); sy.setMaxSpeed(2400);
  for (Axis a : {Y, X}) {                 // 先 Y 後 X:讓筆夾讓開橫樑
    AccelStepper& st = (a == X) ? sx : sy;
    uint8_t reed = (a == X) ? PIN_REED_X : PIN_REED_Y;
    int8_t dir = digitalRead(reed) == LOW ? 1 : -1;
    st.setSpeed(dir * 2400);
    uint32_t t0 = millis();
    while (digitalRead(reed) == LOW && millis() - t0 < 6000) st.runSpeed();
    st.stop();
    st.setSpeed(dir * 500);               // 慢探:精度来自慢
    t0 = millis();
    while (digitalRead(reed) == HIGH && millis() - t0 < 8000) st.runSpeed();
    st.stop();
    st.setCurrentPosition(0);
    st.move(3 * STEPS_PER_MM_X * -dir / 10 > 0 ? 30 : -30);  // 退 3mm
    while (st.distanceToGo() != 0) st.run();
  }
  homed = true;
  buz(PIN_BUZZER, 1200, 120);             // 長嗶 = 原點就緒
}

/* ------------------------------------------------------------
 * gcodeFeed —— 直譯器(G0/G1/G4/G28/M280/M114/?)
 *   回傳:true = 已受理(非同步:受理≠走完,
 *   client 以 /status 的 acked 計數做流控)
 * ------------------------------------------------------------ */
bool gcodeFeed(String line) {
  line.trim(); line.toUpperCase();
  if (line == "G28") { queueFeed({0, 0, 0, 4}); return true; }   // 歸零以 draw=4 標記
  if (line.startsWith("M114")) { Serial.printf("X%dY%d\n", sx.currentPosition(), sy.currentPosition()); return true; }
  if (line.startsWith("M280")) { queueFeed({0,0,0,5}); return true; }  // 抬筆
  if (line.startsWith("M281")) { queueFeed({0,0,0,6}); return true; }  // 落筆
  bool mv = line.startsWith("G0") || line.startsWith("G1");
  if (!mv) return line.length() == 0;
  if (!homed) return false;               // 未歸零一律拒絕:坐標是騙人的
  float nx = sx.currentPosition() / STEPS_PER_MM_X;
  float ny = sy.currentPosition() / STEPS_PER_MM_Y;
  uint16_t f = 600; int draw = line.startsWith("G1") ? 1 : 0;
  int ix = line.indexOf('X'), iy = line.indexOf('Y'), ifd = line.indexOf("F");
  if (ix >= 0) nx = line.substring(ix + 1).toFloat();
  if (iy >= 0) ny = line.substring(iy + 1).toFloat();
  if (ifd >= 0) f = line.substring(ifd + 1).toInt();
  if (nx < -2 || nx > 212 || ny < -2 || ny > 150) return false;  // 軟限位護航
  // 拐角降速:上一段與這段的 X 分量反向 → 視為拐角
  bool corner = draw && fabs((nx * STEPS_PER_MM_X - sx.currentPosition()) - lastDirX) < 1 && lastDirX < 0;
  queueFeed({nx, ny, (uint16_t)(corner ? SPEED_CORNER/4 : f), draw});
  return true;
}

void buz(uint8_t p, uint16_t f, uint16_t ms) { tone(p, f, ms); buzzerUntil = millis() + ms; }

/* ------------------------------------------------------------
 * webPages —— 內嵌 UI:SVG 展平在瀏覽器 JS(本檔限韌體,
 *   網頁見 stl/web/index.html.gz 由 PROGMEM 解壓進 PSRAM…
 *   C3 無 PSRAM → 直接 PROGMEM 分段送,40KB 拆 8 塊 OK)
 * ------------------------------------------------------------ */
void setupRoutes() {
  server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", PAGE_INDEX); });
  server.on("/status", HTTP_GET, []() {
    server.send(200, "application/json", String("{\"acked\":") + lastAcked
      + ",\"free\":" + (128 - qCount) + ",\"homed\":" + (homed ? 1 : 0)
      + ",\"busy\":" + (sx.distanceToGo() || sy.distanceToGo() || qCount ? 1 : 0) + "}");
  });
  server.on("/feed", HTTP_POST, []() {          // 一次 ≤32 段,\n 分隔
    String body = server.arg("plain");
    int ok = 0, fail = 0;
    for (String ln : body.split("\n")) {   // ESP32 String::split 迭代
      if (gcodeFeed(ln)) ok++;
      else { fail++; break; }                    // 一錯即停:後續座標不可信
    }
    server.send(200, "text/plain", "ok " + String(ok) + (fail ? " ERR" : ""));
  });
  server.on("/home", HTTP_POST, []() { homeAll(); server.send(200, "text/plain", "ok"); });
  server.begin();
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_REED_X, INPUT_PULLUP); pinMode(PIN_REED_Y, INPUT_PULLUP);
  pinMode(PIN_BUZZER, OUTPUT);

  prefs.begin("plotter");
  STEPS_PER_MM_X = prefs.getFloat("spx", 102.4f);   // 上次量尺校準值
  STEPS_PER_MM_Y = prefs.getFloat("spy", 102.4f);

  sx.setup(STEPS_PER_MM_X * 40 / 40, ACCEL);         // AccelStepper 內部以步為單位
  sy.setup(STEPS_PER_MM_Y * 40 / 40, ACCEL);
  sx.setMaxSpeed(SPEED_CRUISE * 4); sy.setMaxSpeed(SPEED_CRUISE * 4);
  if (REVERSE_X) sx.setPinsInverted(true);
  if (REVERSE_Y) sy.setPinsInverted(true);

  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 30 && WiFi.status() != WL_CONNECTED; i++) delay(250);
  if (WiFi.status() != WL_CONNECTED) {              // AP 後備:斷網也能畫
    WiFi.mode(WIFI_AP); WiFi.softAP("plotter-ap", "draw4455");
    Serial.println("AP mode: plotter-ap / draw4455");
  }
  setupRoutes();
  homeAll();                                        // 開機自動歸零
}

void loop() {
  runPlanner();
  servoIdle();
  server.handleClient();
}
⚠️ AccelStepper 的 setPinsInverted 只對方向訊號有意義,4 相直驅模式請直接交換 IN 線序對應的陣列順序——第一次組裝方向反了 90% 是線序,改韌體的相序表,不要亂拆線。ULN2003 長期 12V 供電時,3.3V 邏輯輸入仍能觸發(板上光耦級聯電阻已內建),但請買「12V 版」而非「5V 精簡版」。
🖥️ 瀏覽器端 SVG→G-code 要點(完整 JS 見檔案清單 web/ui.js):解析 <path d> 的 M/L/H/V/C/S/Q/T/Z(含小寫相對指令),貝茲以「弦高 0.1mm」遞迴展平;先做路徑排序(最近鄰)減少空移、抬筆距離以 dwell 補償;每 24 段一 POST,收到 /status busy=0 且 free>96 再補下一批——Wi-Fi 抖動由瀏覽器端重送冪等。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印後處理:全部鎖孔熱埋銅內牙(烙鐵 260°C 垂直沉入,冷卻後不出粉即成功)
  2. 導軌鎖裝+平行校正:刀規拉對角線,兩條對角差 <0.5mm 才算過——這一步決定畫正方形是不是正方形
  3. 皮帶雙環佈線:張緊輪「撥吉他會響」的緊度;拖板全行程手推順滑無異音
  4. 馬達耦合:28BYJ 的 D 軸用鋁聯軸器(列印耦合件在皮帶張力下會橢圓化);鎖緊滑輪頂絲兩顆都對準平面
  5. 電源分壓測:先不接 C3,量 LM2596 輸出 5.0±0.1V 再接板;手轉馬達測 ULN 四相順序(有「咔咔」卡頓=相序錯,韌體換表)
  6. 燒錄+歸零測試:上電自動 G28,兩軸「退—慢探—退 3mm」動作完整+長嗶
  7. steps/mm 量尺校準:網頁畫 100mm 線、游標卡尺量實長、按比例回填 SPX/SPY(存 Flash)
  8. 首發圖:測試 SVG(圓+螺旋+「Hello」)→ 檢查拐角墨漬、起筆斷線、鏡像方向

🔧 問題排除

問題原因解決方法
斜線變階梯、圓變多邊形28BYJ 失步走速降 400、加速度減半、皮帶調鬆半格;12V 低於 11V 的變壓器換掉(扭力直接消失)
圖形左右/上下鏡像ULN 相序與韌體定義不匹配改韌體相序陣列或 IN 線順序對調,二選一;量尺校準前會反覆出現,先固定再校
抬筆仍拖出墨線SG90 行程不足或連桿滑牙抬筆位 125°→135°;凸輪螺絲上膠;筆夾下移讓筆尖更接近連桿導向
拐角一坨墨過沖+速度太快本韌體拐角降速已內建;仍暈墨改用 0.3mm 針筆,或 G4 停 100ms 再走
歸零撞過頭/找不到原點磁簧與磁環間距 >4mm;磁簧線與馬達線同束受擾間距調 3mm;磁簧線纏屏蔽、與動力線分槽走線
畫到一半停住不動瀏覽器分塊等 ack 逾時頁面自動重送冪等;仍斷則連 AP 後備熱點 plotter-ap
C3 反覆重啟共地懶惰:馬達電流脈衝拉垮 5V星形共地重做;SG90 電源就近 220µF;C3 改 USB 供電測試交叉驗證

🎯 進階延伸

📦 檔案清單

pen-plotter/
├── src/
│   └── pen-plotter.ino          # ESP32 韌體(本頁完整程式)
├── web/
│   ├── index.html.gz            # 內嵌網頁(PROGMEM 分段)
│   └── ui.js                    # SVG 解析+貝茲展平+流控上傳
├── stl/
│   ├── rail-mount.stl           # 導軌鎖座
│   ├── crossbeam.stl            # 橫樑+馬達座
│   ├── x-carriage.stl           # X 拖板
│   ├── belt-tensioner.stl       # 張力器滑塊
│   ├── pen-clip-75.stl          # 筆夾(75°)
│   ├── lift-cam.stl             # 抬筆連桿組
│   ├── home-stop.stl            # 原點擋塊+磁環座
│   └── leveling-foot.stl        # 調平腳
├── f3d/
│   └── plotter-design.f3d       # Fusion 360 原始檔
├── test/
│   ├── calibration-100mm.svg    # steps/mm 校準圖
│   └── first-draw.svg           # 首發測試圖(圓+螺旋+Hello)
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-S3 靜音鼓練習墊 📚 系列索引 下一篇:ESP32-S3 聲源定位聲場羅盤 →