Симулятор роботов на Ардуино

Всем привет! Пишу этот текст сам, без привлечения ИИ))), а вот, то что я предлагаю без ИИ я бы не осилил.

Есть тут похожие темы, но я хочу выделиться одной особенностью. Я разработал симулятор Ардуино, который может управлять роботами в 3D симуляции в физическом движке. На мой взгляд, выглядит забавно и интересно.

Закидайте помидорами, пожалуйста!!!

P.S.

Ну и обычные лампочки там тоже есть.

А вооще, используейте мой PinPort как аналог Wokwi. Этот Wokwi на бесплатном аккаунте очень тупит.

стока кодов, наверняка что то интересное найти можно, и думаю даже наверное можно свой код вставить и анимация отработает ?)))
срочно напишите про ctrl и колесико мыши, а то я сам также сайты делаю, а предупредить забываю)))
а то ведь реально огорчатся, и закидают)))

Там много всего интересного, пользуйтесь. Разегистрируйтесь, форкните любой проект и меняйте код на свой вкус. Если будут вопросы и проблемы я всегда на связи. Про Ctrl + колесико мыши не понял )))), расскажи.

изображение автоматически не подгоняется, у кого маленький монитор наверное вовсе могут не заметить симулятор например…
но может это из за виртуал бокса у меня только…

Да, проекту нужен монитор побольше.

Сможете вот таких “роботов” сделать и поуправлять? Было бы интересно посмотреть.

Интересно, не слышал про них, сделаю

Упревление сделаю как в других проектах, через библиотеку Гайвера Settings. Там отличная веб морда.

Евгений, готово, можно затестить Машинку Брайтенберга Роботы: Машинка Брайтенберга — примеры проектов онлайн — PinPort.ru

У меня не замирает, а вальс танцует вокруг лампочки :slight_smile:

Спасибо!

Мотылёк.

Вальс тоже неплохо ))))

Ну ещё можно добавить кучу простых роботов. Начать с рисовальщика.

…квадропода как ходуна
4ac3e50ce3911f1beaf1a615fe93a8c
…одномоторного пограничника

добрался до анализа проекта наконец)))
еще раз спс за кучу примеров кодов, хотелось бы конечно что бы любой код был к ближе к реальной работе проектов…
как например тут…

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PinPort-style — Рисование шариком на TFT 128×160</title>
<style>
  * { box-sizing: border-box; }
  body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    max-width: 1600px; margin: 0 auto; padding: 20px;
    background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
    color: white;
  }
  h1 { text-align: center; margin-bottom: 20px; text-shadow: 2px 2px 4px rgba(0,0,0,.3); }
  .main-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
    gap: 20px;
    background: rgba(255,255,255,.1);
    backdrop-filter: blur(10px);
    border-radius: 20px; padding: 20px;
    box-shadow: 0 8px 32px 0 rgba(31,38,135,.37);
  }
  .section {
    background: white; border-radius: 15px; padding: 20px;
    color: #333; box-shadow: 0 4px 6px rgba(0,0,0,.1);
  }
  .section h2 { margin-top: 0; color: #2a5298; border-bottom: 2px solid #f0f0f0; padding-bottom: 10px; }
  .file-input-label {
    background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
    color: white; padding: 12px 24px; border-radius: 25px;
    cursor: pointer; display: inline-block; transition: transform .3s, box-shadow .3s;
    font-size: 16px; font-weight: 600; margin: 10px 0;
  }
  .file-input-label:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,.3); }
  input[type="file"] { display: none; }
  .filename { font-size: 14px; color: #666; margin: 10px 0; word-break: break-all; }
  .image-preview { background: #f5f5f5; border-radius: 10px; padding: 15px; margin-top: 15px; }
  .image-container { display: flex; flex-direction: column; align-items: center; gap: 10px; min-height: 150px; justify-content: center; }
  .image-container img { max-width: 100%; max-height: 180px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,.2); }
  #placeholder { padding: 40px; color: #999; font-style: italic; }
  .controls { margin-top: 20px; display: flex; flex-direction: column; gap: 15px; }
  .control-group { display: flex; flex-direction: column; gap: 5px; }
  .control-row { display: flex; gap: 10px; align-items: center; }
  label { font-weight: 600; color: #555; }
  input[type="range"] { flex: 1; height: 5px; background: linear-gradient(90deg, #1e3c72 0%, #2a5298 100%); border-radius: 5px; -webkit-appearance: none; }
  input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: white; border: 2px solid #2a5298; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 5px rgba(0,0,0,.2); }
  input[type="number"] { width: 70px; padding: 5px; border: 1px solid #dee2e6; border-radius: 5px; text-align: center; }
  select { padding: 8px; border: 1px solid #dee2e6; border-radius: 8px; font-size: 15px; background: white; cursor: pointer; }
  .button {
    background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
    color: white; padding: 12px 24px; border: none; border-radius: 25px;
    cursor: pointer; font-size: 16px; font-weight: 600;
    transition: transform .3s, box-shadow .3s; width: 100%;
  }
  .button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,.3); }
  .button:disabled { opacity: .5; cursor: not-allowed; }
  .button.secondary { background: #6c757d; }
  .preview-container {
    background: #f8f9fa; border-radius: 8px; padding: 15px;
    max-height: 240px; overflow-y: auto;
    font-family: 'Courier New', monospace; font-size: 12px;
    border: 1px solid #dee2e6; white-space: pre;
  }
  .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 10px; padding: 10px; background: #e9ecef; border-radius: 5px; font-weight: 600; text-align: center; }
  canvas { max-width: 100%; border: 2px solid #dee2e6; border-radius: 8px; background: white; display: block; margin: 0 auto; }
  .value-display { display: inline-block; background: #2a5298; color: white; padding: 2px 10px; border-radius: 15px; font-size: 14px; margin-left: 10px; }
  .warning { color: #dc3545; font-size: 14px; margin-top: 5px; padding: 10px; background: #ffe6e6; border-radius: 5px; text-align: center; }
  .info { color: #28a745; font-size: 14px; margin-top: 5px; padding: 10px; background: #e6ffe6; border-radius: 5px; text-align: center; }
  .dimensions { background: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 10px; text-align: center; }
  .code-block {
    background: #1e1e1e; color: #d4d4d4; border-radius: 8px;
    padding: 15px; font-family: 'Courier New', monospace; font-size: 12px;
    overflow-x: auto; max-height: 420px; white-space: pre;
  }
  .screen-wrap { display: flex; flex-direction: column; align-items: center; gap: 12px; }
  .badge { background: #2a5298; color: white; padding: 4px 12px; border-radius: 12px; font-size: 13px; }
  .badge.green { background: #28a745; }
  .badge.orange { background: #fd7e14; }
  .progress { width: 100%; height: 8px; background: #e9ecef; border-radius: 4px; overflow: hidden; margin-top: 10px; }
  .progress > div { height: 100%; width: 0%; background: linear-gradient(90deg,#1e3c72,#2a5298); transition: width .1s; }
  .small { font-size: 12px; color: #666; }
  .flex-row { display: flex; gap: 10px; flex-wrap: wrap; }
  .checkbox-row { display: flex; align-items: center; gap: 8px; padding: 10px; background: #f0f4ff; border-radius: 8px; }
  .checkbox-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; }
  .checkbox-row label { margin: 0; cursor: pointer; font-weight: 500; color: #333; }
  .hint { background: #fff8e1; border-left: 4px solid #ffc107; padding: 10px 12px; border-radius: 5px; font-size: 13px; color: #6d4c00; margin-top: 10px; }
</style>
</head>
<body>
<h1>🎯 PinPort-style — Рисование шариком на TFT 128×160</h1>

<div class="main-container">

  <!-- 1. Загрузка -->
  <div class="section">
    <h2>1. Загрузите рисунок или trace.txt</h2>
    <div class="upload-container" style="text-align:center;">
      <label for="fileInput" class="file-input-label">📁 Выбрать изображение</label>
      <input type="file" id="fileInput" accept="image/*">
      <div class="filename" id="fileName">Файл не выбран</div>
      <div class="image-preview">
        <h3 style="margin:5px 0;">Исходный рисунок</h3>
        <div class="image-container">
          <img id="originalImage" src="#" alt="Исходный рисунок" style="display:none;">
          <div id="placeholder">👆 Загрузите рисунок</div>
        </div>
      </div>
    </div>
    <div class="controls">
      <div class="control-group">
        <label>Порог яркости: <span class="value-display" id="thresholdValue">128</span></label>
        <input type="range" id="threshold" min="0" max="255" value="128" step="1">
      </div>
      <div class="control-group">
        <label>Толщина линии: <span class="value-display" id="thicknessValue">1</span></label>
        <input type="range" id="thickness" min="1" max="3" value="1" step="1">
      </div>
      <div class="control-group">
        <label>Размер выходного изображения:</label>
        <div class="control-row"><span>Ширина:</span><input type="number" id="outputWidth" min="10" max="500" value="128"><span>px</span></div>
        <div class="control-row" style="margin-top:5px;"><span>Высота:</span><input type="number" id="outputHeight" min="10" max="500" value="160"><span>px</span></div>
        <div class="control-row" style="margin-top:10px;"><span>Отступ от края:</span><input type="number" id="margin" min="0" max="50" value="5"><span>px</span></div>
        <div class="dimensions" id="dimensionsDisplay">Исходный размер: 128x160 → Целевой: 128x160</div>
      </div>
      <button id="processBtn" class="button" disabled>🔄 Создать путь из картинки</button>
    </div>

    <!-- Загрузка trace.txt -->
    <div style="margin-top:20px; padding-top:15px; border-top:2px dashed #dee2e6;">
      <h3 style="margin:0 0 10px; color:#2a5298;">Или загрузите готовый trace.txt</h3>
      <label for="traceFileInput" class="file-input-label">📄 Выбрать trace.txt</label>
      <input type="file" id="traceFileInput" accept=".txt,text/plain">
      <div class="filename" id="traceFileName">Файл не выбран</div>
      <div class="small" style="margin-top:5px;">Формат: строки вида <code>x,y</code>. Комментарии с <code>;</code> игнорируются.</div>

      <div class="checkbox-row" style="margin-top:10px;">
        <input type="checkbox" id="mergeLine">
        <label for="mergeLine">Склеить в одну линию (сортировка по ближайшему соседу + удаление дублей)</label>
      </div>
      <div class="small" style="margin-top:5px; color:#666;">
        Если выключено — шарик едет по точкам в том порядке, как в файле (как в Arduino-скетче).
        Если включено — точки пересортируются в непрерывную линию, как при генерации из картинки.
      </div>
    </div>

    <div id="warning" class="warning" style="display:none;"></div>
    <div id="info" class="info" style="display:none;"></div>
  </div>

  <!-- 2. Симулятор -->
  <div class="section">
    <h2>2. Симулятор TFT (шарик рисует след)</h2>
    <div class="screen-wrap">
      <div class="flex-row" style="justify-content:center;">
        <span class="badge" id="stateBadge">Ожидание</span>
        <span class="badge green" id="countBadge">Точек: 0</span>
        <span class="badge orange" id="distBadge">Пройдено: 0 px</span>
      </div>
      <canvas id="tftCanvas" width="128" height="160" style="width:256px;height:320px;image-rendering:pixelated;"></canvas>
      <div class="progress"><div id="progressBar"></div></div>
      <div class="small" id="progressText">0 / 0</div>
      <div class="flex-row" style="width:100%;">
        <button id="startBtn" class="button" disabled style="flex:1;">▶ Старт</button>
        <button id="stopBtn" class="button secondary" disabled style="flex:1;">⏸ Пауза</button>
        <button id="resetBtn" class="button secondary" disabled style="flex:1;">⟲ Сброс</button>
      </div>
      <button id="showAllBtn" class="button secondary" disabled style="width:100%;">👁 Показать все точки (без анимации)</button>

      <!-- Режим шарика (только для HTML) -->
      <div class="control-group" style="width:100%;">
        <label>Режим шарика (только в HTML):</label>
        <select id="ballMode">
          <option value="dot" selected>Точка (1 px) — ничего не стирает</option>
          <option value="cross">Крестик (4 px) — ничего не стирает</option>
          <option value="circle">Круг (как в Arduino) — затирает</option>
        </select>
      </div>

      <div class="checkbox-row" style="width:100%;" id="noEraseRow">
        <input type="checkbox" id="noErase">
        <label for="noErase">Не стирать след под шариком (только для режима «Круг»)</label>
      </div>

      <div class="control-group" style="width:100%;" id="radiusRow">
        <label>Радиус круга (только для режима «Круг»): <span class="value-display" id="radiusValue">1</span> px</label>
        <input type="range" id="radius" min="1" max="6" value="1" step="1">
      </div>

      <div class="control-group" style="width:100%;">
        <label>Скорость шарика (speed): <span class="value-display" id="speedValue">1.5</span> px/кадр</label>
        <input type="range" id="speed" min="0.2" max="6" value="1.5" step="0.1">
      </div>
      <div class="control-group" style="width:100%;">
        <label>Задержка кадра (delay): <span class="value-display" id="delayValue">10</span> мс</label>
        <input type="range" id="delay" min="1" max="100" value="10" step="1">
      </div>

      <div class="hint" style="width:100%;">
        Режим шарика влияет <b>только на отображение в HTML</b>. Код Arduino не меняется — на реальном TFT шарик остаётся таким, как в скетче (круг фоном + контуром).
        Если рисунок затирается — выберите режим «Точка» или «Крестик», они ничего не стирают.
      </div>
    </div>
  </div>

  <!-- 3. Координаты + Arduino -->
  <div class="section">
    <h2>3. Координаты для ESP32</h2>
    <div class="stats">
      <span>📊 Точек: <strong id="pointCount">0</strong></span>
      <span>📏 Длина: <strong id="pathLength">0</strong> пикс.</span>
      <span>📐 Масштаб: <strong id="scaleFactor">1.0x</strong></span>
    </div>
    <div class="preview-container" id="coordinatesDisplay">; Нажмите "Создать путь" или загрузите trace.txt</div>
    <button id="saveBtn" class="button" style="margin-top:15px;" disabled>💾 Скачать trace.txt</button>
    <div style="margin-top:15px; background:#d4edda; padding:10px; border-radius:5px; color:#155724;">
      <strong>✅ ОДНА НЕПРЕРЫВНАЯ ЛИНИЯ!</strong><br>
      Координаты масштабированы под размер <span id="finalSize">128x160</span>
    </div>
    <h3 style="margin-top:20px; color:#2a5298;">Код Arduino (TFT_eSPI) — без изменений</h3>
    <div class="code-block" id="arduinoCode"></div>
    <button id="copyCodeBtn" class="button secondary" style="margin-top:10px;">📋 Копировать код</button>
  </div>

</div>

<script>
/* ============================================================
   ЧАСТЬ 1. ГЕНЕРАЦИЯ КООРДИНАТ ИЗ КАРТИНКИ
   ============================================================ */
const fileInput = document.getElementById('fileInput');
const originalImage = document.getElementById('originalImage');
const placeholder = document.getElementById('placeholder');
const fileNameDisplay = document.getElementById('fileName');
const processBtn = document.getElementById('processBtn');
const saveBtn = document.getElementById('saveBtn');
const bwCanvas = document.createElement('canvas');
const bwCtx = bwCanvas.getContext('2d');
const coordinatesDisplay = document.getElementById('coordinatesDisplay');
const pointCountSpan = document.getElementById('pointCount');
const pathLengthSpan = document.getElementById('pathLength');
const scaleFactorSpan = document.getElementById('scaleFactor');
const finalSizeSpan = document.getElementById('finalSize');
const dimensionsDisplay = document.getElementById('dimensionsDisplay');

const thresholdInput = document.getElementById('threshold');
const thresholdValue = document.getElementById('thresholdValue');
const thicknessInput = document.getElementById('thickness');
const thicknessValue = document.getElementById('thicknessValue');
const outputWidthInput = document.getElementById('outputWidth');
const outputHeightInput = document.getElementById('outputHeight');
const marginInput = document.getElementById('margin');

const warningDiv = document.getElementById('warning');
const infoDiv = document.getElementById('info');

let originalImageData = null;
let allPoints = [];
let sourceWidth = 128, sourceHeight = 160;

thresholdInput.addEventListener('input', () => {
  thresholdValue.textContent = thresholdInput.value;
  if (originalImageData) updateBWPreview();
});
thicknessInput.addEventListener('input', () => { thicknessValue.textContent = thicknessInput.value; });

function updateDimensions() {
  const tw = parseInt(outputWidthInput.value) || 128;
  const th = parseInt(outputHeightInput.value) || 160;
  const m = parseInt(marginInput.value) || 0;
  dimensionsDisplay.textContent = `Исходный размер: ${sourceWidth}x${sourceHeight} → Целевой: ${tw}x${th} (отступ: ${m}px)`;
  finalSizeSpan.textContent = `${tw}x${th}`;
}
outputWidthInput.addEventListener('input', updateDimensions);
outputHeightInput.addEventListener('input', updateDimensions);
marginInput.addEventListener('input', updateDimensions);

fileInput.addEventListener('change', function(e) {
  const file = e.target.files[0];
  if (!file) { resetToInitial(); return; }
  fileNameDisplay.textContent = `Выбран файл: ${file.name}`;
  const reader = new FileReader();
  reader.onload = function(ev) {
    const img = new Image();
    img.onload = function() {
      sourceWidth = img.width; sourceHeight = img.height;
      bwCanvas.width = sourceWidth; bwCanvas.height = sourceHeight;
      bwCtx.drawImage(img, 0, 0, sourceWidth, sourceHeight);
      originalImageData = bwCtx.getImageData(0, 0, sourceWidth, sourceHeight);
      originalImage.src = ev.target.result;
      originalImage.style.display = 'block';
      placeholder.style.display = 'none';
      updateBWPreview();
      processBtn.disabled = false;
      updateDimensions();
    };
    img.src = ev.target.result;
  };
  reader.readAsDataURL(file);
});

function updateBWPreview() {
  if (!originalImageData) return;
  const threshold = parseInt(thresholdInput.value);
  const bwData = convertToBW(originalImageData, threshold);
  bwCtx.putImageData(bwData, 0, 0);
}

function convertToBW(imageData, threshold) {
  const data = imageData.data;
  const bwData = new ImageData(imageData.width, imageData.height);
  for (let i = 0; i < data.length; i += 4) {
    const r = data[i], g = data[i+1], b = data[i+2];
    const brightness = 0.299*r + 0.587*g + 0.114*b;
    const v = brightness > threshold ? 255 : 0;
    bwData.data[i] = v; bwData.data[i+1] = v; bwData.data[i+2] = v; bwData.data[i+3] = 255;
  }
  return bwData;
}

function skeletonize(imageData, thickness) {
  const width = imageData.width, height = imageData.height;
  const data = imageData.data;
  const result = new Array(width * height).fill(255);
  if (thickness === 1) {
    for (let i = 0; i < data.length; i += 4) if (data[i] === 0) result[i/4] = 0;
  } else {
    for (let y = 1; y < height - 1; y++) {
      for (let x = 1; x < width - 1; x++) {
        const idx = y * width + x;
        if (data[idx * 4] === 0) {
          let isEdge = false;
          if (data[((y-1)*width + x)*4] === 255 || data[((y+1)*width + x)*4] === 255 ||
              data[(y*width + (x-1))*4] === 255 || data[(y*width + (x+1))*4] === 255) isEdge = true;
          if (isEdge) result[idx] = 0;
        }
      }
    }
  }
  return result;
}

function collectPoints(skeleton, width, height) {
  const points = [];
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      if (skeleton[y * width + x] === 0) points.push({x, y});
    }
  }
  if (points.length === 0) return [];
  const result = [];
  const remaining = [...points];
  remaining.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
  let current = remaining.shift();
  result.push(current);
  while (remaining.length > 0) {
    let nearestIndex = 0, nearestDist = Infinity;
    for (let i = 0; i < remaining.length; i++) {
      const dx = remaining[i].x - current.x;
      const dy = remaining[i].y - current.y;
      const dist = Math.sqrt(dx*dx + dy*dy);
      if (dist < nearestDist) { nearestDist = dist; nearestIndex = i; }
    }
    current = remaining[nearestIndex];
    result.push(current);
    remaining.splice(nearestIndex, 1);
  }
  return result;
}

function scalePoints(points, targetWidth, targetHeight, margin) {
  if (points.length === 0) return [];
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
  points.forEach(p => {
    minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
    minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
  });
  if (maxX - minX < 1) maxX = minX + 1;
  if (maxY - minY < 1) maxY = minY + 1;
  const availW = targetWidth - 2*margin;
  const availH = targetHeight - 2*margin;
  const scaleX = availW / (maxX - minX);
  const scaleY = availH / (maxY - minY);
  const scale = Math.min(scaleX, scaleY);
  const scaledW = (maxX - minX) * scale;
  const scaledH = (maxY - minY) * scale;
  const offsetX = margin + (availW - scaledW) / 2;
  const offsetY = margin + (availH - scaledH) / 2;
  return points.map(p => ({
    x: Math.round(offsetX + (p.x - minX) * scale),
    y: Math.round(offsetY + (p.y - minY) * scale)
  }));
}

function formatPoints(points) {
  if (!points || points.length === 0) return '; Нет данных';
  let result = '; Путь для рисования одной линией\n';
  points.forEach(p => { result += `${p.x},${p.y}\n`; });
  return result;
}

function processImage() {
  if (!originalImageData) return;
  const threshold = parseInt(thresholdInput.value);
  const thickness = parseInt(thicknessInput.value);
  const targetWidth = parseInt(outputWidthInput.value) || 128;
  const targetHeight = parseInt(outputHeightInput.value) || 160;
  const margin = parseInt(marginInput.value) || 0;

  const bwData = convertToBW(originalImageData, threshold);
  const skeleton = skeletonize(bwData, thickness);
  const rawPoints = collectPoints(skeleton, sourceWidth, sourceHeight);
  allPoints = scalePoints(rawPoints, targetWidth, targetHeight, margin);

  applyPoints(allPoints, 'Найдено точек из картинки: ');
}

function applyPoints(points, msgPrefix) {
  coordinatesDisplay.textContent = formatPoints(points);
  pointCountSpan.textContent = points.length;

  let totalLength = 0;
  for (let i = 1; i < points.length; i++) {
    const dx = points[i].x - points[i-1].x;
    const dy = points[i].y - points[i-1].y;
    totalLength += Math.sqrt(dx*dx + dy*dy);
  }
  pathLengthSpan.textContent = Math.round(totalLength);
  scaleFactorSpan.textContent = '1.0x';
  finalSizeSpan.textContent = `${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}`;

  if (points.length === 0) {
    showWarning('Линии не найдены!');
    saveBtn.disabled = true;
    startBtn.disabled = true;
    stopBtn.disabled = true;
    resetBtn.disabled = true;
    showAllBtn.disabled = true;
  } else {
    showInfo(msgPrefix + points.length);
    saveBtn.disabled = false;
    resetSimulation();
    startBtn.disabled = false;
    stopBtn.disabled = true;
    resetBtn.disabled = false;
    showAllBtn.disabled = false;
  }
}

function showWarning(m) { warningDiv.textContent = m; warningDiv.style.display = 'block'; infoDiv.style.display = 'none'; }
function showInfo(m) { infoDiv.textContent = m; infoDiv.style.display = 'block'; warningDiv.style.display = 'none'; }

function resetToInitial() {
  fileNameDisplay.textContent = 'Файл не выбран';
  originalImage.style.display = 'none';
  placeholder.style.display = 'block';
  processBtn.disabled = true;
  saveBtn.disabled = true;
  startBtn.disabled = true;
  stopBtn.disabled = true;
  resetBtn.disabled = true;
  showAllBtn.disabled = true;
  coordinatesDisplay.textContent = '; Нажмите "Создать путь" или загрузите trace.txt';
  pointCountSpan.textContent = '0';
  pathLengthSpan.textContent = '0';
  scaleFactorSpan.textContent = '1.0x';
  originalImageData = null;
  allPoints = [];
  resetSimulation();
}

saveBtn.addEventListener('click', function() {
  if (!allPoints || allPoints.length === 0) return;
  const blob = new Blob([formatPoints(allPoints)], {type: 'text/plain'});
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.download = 'trace.txt'; a.href = url;
  document.body.appendChild(a); a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
});

processBtn.addEventListener('click', processImage);

/* ============================================================
   ЧАСТЬ 2. ЗАГРУЗКА ГОТОВОГО trace.txt + склейка
   ============================================================ */
const traceFileInput = document.getElementById('traceFileInput');
const traceFileName = document.getElementById('traceFileName');
const mergeLineCheckbox = document.getElementById('mergeLine');

function dedupPoints(points, minDist = 1.01) {
  if (points.length === 0) return [];
  const out = [points[0]];
  for (let i = 1; i < points.length; i++) {
    const p = points[i];
    const prev = out[out.length - 1];
    const dx = p.x - prev.x, dy = p.y - prev.y;
    if (Math.sqrt(dx*dx + dy*dy) > minDist) out.push(p);
  }
  return out;
}

function sortByNearestNeighbor(points) {
  if (points.length === 0) return [];
  const remaining = [...points];
  remaining.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
  const result = [];
  let current = remaining.shift();
  result.push(current);
  while (remaining.length > 0) {
    let nearestIndex = 0, nearestDist = Infinity;
    for (let i = 0; i < remaining.length; i++) {
      const dx = remaining[i].x - current.x;
      const dy = remaining[i].y - current.y;
      const d = dx*dx + dy*dy;
      if (d < nearestDist) { nearestDist = d; nearestIndex = i; }
    }
    current = remaining[nearestIndex];
    result.push(current);
    remaining.splice(nearestIndex, 1);
  }
  return result;
}

function parseTraceText(text) {
  const pts = [];
  text.split(/\r?\n/).forEach(line => {
    line = line.trim();
    if (!line || line.startsWith(';')) return;
    const m = line.match(/^(-?\d+)\s*,\s*(-?\d+)$/);
    if (m) pts.push({ x: parseInt(m[1], 10), y: parseInt(m[2], 10) });
  });
  return pts;
}

function loadTraceFromText(text) {
  let pts = parseTraceText(text);
  if (pts.length === 0) {
    showWarning('В файле не найдено координат вида x,y');
    return;
  }
  pts = pts.map(p => ({
    x: Math.max(0, Math.min(DISPLAY_WIDTH  - 1, p.x)),
    y: Math.max(0, Math.min(DISPLAY_HEIGHT - 1, p.y))
  }));

  let msg = 'Загружено точек из trace.txt: ';
  if (mergeLineCheckbox.checked) {
    const before = pts.length;
    pts = dedupPoints(pts, 1.01);
    pts = sortByNearestNeighbor(pts);
    msg = `Склеено в одну линию: было ${before}, стало `;
  }
  allPoints = pts;
  applyPoints(allPoints, msg);
}

traceFileInput.addEventListener('change', function(e) {
  const file = e.target.files[0];
  if (!file) return;
  traceFileName.textContent = `Выбран файл: ${file.name}`;
  const reader = new FileReader();
  reader.onload = function(ev) { loadTraceFromText(ev.target.result); };
  reader.readAsText(file);
});

mergeLineCheckbox.addEventListener('change', function() {
  if (traceFileInput.files && traceFileInput.files[0]) {
    const reader = new FileReader();
    reader.onload = function(ev) { loadTraceFromText(ev.target.result); };
    reader.readAsText(traceFileInput.files[0]);
  }
});

/* ============================================================
   ЧАСТЬ 3. СИМУЛЯТОР TFT (шарик + след)
   ============================================================ */
const DISPLAY_WIDTH = 128;
const DISPLAY_HEIGHT = 160;
const TFT_BG = '#FFFF00';
const TRACE_COLOR = '#FF0000';
const DARK_YELLOW_CSS = '#808000';

const tftCanvas = document.getElementById('tftCanvas');
const tftCtx = tftCanvas.getContext('2d');

const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const resetBtn = document.getElementById('resetBtn');
const showAllBtn = document.getElementById('showAllBtn');
const speedInput = document.getElementById('speed');
const speedValue = document.getElementById('speedValue');
const delayInput = document.getElementById('delay');
const delayValue = document.getElementById('delayValue');
const radiusInput = document.getElementById('radius');
const radiusValue = document.getElementById('radiusValue');
const ballModeSelect = document.getElementById('ballMode');
const noEraseCheckbox = document.getElementById('noErase');
const noEraseRow = document.getElementById('noEraseRow');
const radiusRow = document.getElementById('radiusRow');
const stateBadge = document.getElementById('stateBadge');
const countBadge = document.getElementById('countBadge');
const distBadge = document.getElementById('distBadge');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');

speedInput.addEventListener('input', () => speedValue.textContent = parseFloat(speedInput.value).toFixed(1));
delayInput.addEventListener('input', () => delayValue.textContent = delayInput.value);
radiusInput.addEventListener('input', () => {
  radiusValue.textContent = radiusInput.value;
  if (sim.running) drawMovingCircle(sim.currentX, sim.currentY);
});

function updateBallModeUI() {
  const mode = ballModeSelect.value;
  noEraseRow.style.display = (mode === 'circle') ? 'flex' : 'none';
  radiusRow.style.display  = (mode === 'circle') ? 'flex' : 'none';
}
ballModeSelect.addEventListener('change', updateBallModeUI);
noEraseCheckbox.addEventListener('change', () => {
  if (sim.running) drawMovingCircle(sim.currentX, sim.currentY);
});
updateBallModeUI();

const sim = {
  points: [],
  index: 0,
  posX: 0, posY: 0,
  currentX: 0, currentY: 0,
  targetX: 0, targetY: 0,
  running: false,
  timer: null,
  drawnLength: 0
};

function initSimDisplay() {
  tftCtx.fillStyle = TFT_BG;
  tftCtx.fillRect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT);
}

function drawPixel(x, y, color) {
  if (x < 0 || y < 0 || x >= DISPLAY_WIDTH || y >= DISPLAY_HEIGHT) return;
  tftCtx.fillStyle = color;
  tftCtx.fillRect(x, y, 1, 1);
}

function drawLine(x0, y0, x1, y1, color) {
  x0 = Math.round(x0); y0 = Math.round(y0); x1 = Math.round(x1); y1 = Math.round(y1);
  let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
  let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
  let err = dx + dy;
  while (true) {
    drawPixel(x0, y0, color);
    if (x0 === x1 && y0 === y1) break;
    const e2 = 2 * err;
    if (e2 >= dy) { err += dy; x0 += sx; }
    if (e2 <= dx) { err += dx; y0 += sy; }
  }
}

function fillCircle(cx, cy, r, color) {
  cx = Math.round(cx); cy = Math.round(cy);
  tftCtx.fillStyle = color;
  for (let y = -r; y <= r; y++) {
    for (let x = -r; x <= r; x++) {
      if (x*x + y*y <= r*r) {
        const px = cx + x, py = cy + y;
        if (px >= 0 && py >= 0 && px < DISPLAY_WIDTH && py < DISPLAY_HEIGHT) {
          tftCtx.fillRect(px, py, 1, 1);
        }
      }
    }
  }
}

function drawCircle(cx, cy, r, color) {
  cx = Math.round(cx); cy = Math.round(cy);
  tftCtx.strokeStyle = color;
  tftCtx.lineWidth = 1;
  tftCtx.beginPath();
  tftCtx.arc(cx + 0.5, cy + 0.5, r, 0, Math.PI * 2);
  tftCtx.stroke();
}

function drawTracePoint(x, y) { drawPixel(x, y, TRACE_COLOR); }

// Шарик: три режима, влияет только на HTML
function drawMovingCircle(x, y) {
  const mode = ballModeSelect.value;
  const r = parseInt(radiusInput.value);

  if (mode === 'dot') {
    // Одна точка цвета контура, ничего не стирает
    drawPixel(x, y, DARK_YELLOW_CSS);
  } else if (mode === 'cross') {
    // Крестик из 4 пикселей, ничего не стирает
    drawPixel(x - 1, y, DARK_YELLOW_CSS);
    drawPixel(x + 1, y, DARK_YELLOW_CSS);
    drawPixel(x, y - 1, DARK_YELLOW_CSS);
    drawPixel(x, y + 1, DARK_YELLOW_CSS);
  } else {
    // Круг как в Arduino: заливка фоном + контур.
    // Если «Не стирать след» — заливку не делаем, только контур.
    if (!noEraseCheckbox.checked) {
      fillCircle(x, y, r, TFT_BG);
    }
    drawCircle(x, y, r, DARK_YELLOW_CSS);
  }
}

function resetSimulation() {
  if (sim.timer) { clearTimeout(sim.timer); sim.timer = null; }
  sim.points = allPoints.map(p => ({x: p.x, y: p.y}));
  sim.index = 0;
  sim.posX = 0; sim.posY = 0;
  sim.currentX = 0; sim.currentY = 0;
  sim.targetX = 0; sim.targetY = 0;
  sim.running = false;
  sim.drawnLength = 0;
  initSimDisplay();
  stateBadge.textContent = 'Ожидание';
  countBadge.textContent = 'Точек: 0';
  distBadge.textContent = 'Пройдено: 0 px';
  progressBar.style.width = '0%';
  progressText.textContent = `0 / ${sim.points.length}`;
  if (sim.points.length > 0) {
    sim.targetX = sim.points[0].x;
    sim.targetY = sim.points[0].y;
    sim.posX = sim.targetX; sim.posY = sim.targetY;
    sim.currentX = sim.targetX; sim.currentY = sim.targetY;
    drawTracePoint(sim.currentX, sim.currentY);
    drawMovingCircle(sim.currentX, sim.currentY);
    sim.index = 1;
    countBadge.textContent = `Точек: 1`;
  }
}

function startSimulation() {
  if (sim.points.length === 0) return;
  if (sim.running) return;
  if (sim.index >= sim.points.length) resetSimulation();
  sim.running = true;
  stateBadge.textContent = 'Рисует...';
  stopBtn.disabled = false;
  startBtn.disabled = true;
  sim.timer = setTimeout(step, parseInt(delayInput.value));
}

function stopSimulation() {
  sim.running = false;
  if (sim.timer) { clearTimeout(sim.timer); sim.timer = null; }
  stateBadge.textContent = 'Пауза';
  startBtn.disabled = false;
  stopBtn.disabled = true;
}

function step() {
  if (!sim.running) return;

  const speed = parseFloat(speedInput.value);
  let guard = 0;

  while (sim.index < sim.points.length && guard++ < 100000) {
    const target = sim.points[sim.index];
    sim.targetX = target.x;
    sim.targetY = target.y;

    const dx = sim.targetX - sim.posX;
    const dy = sim.targetY - sim.posY;
    const distance = Math.sqrt(dx*dx + dy*dy);

    if (distance < speed) {
      sim.posX = sim.targetX;
      sim.posY = sim.targetY;
      drawTracePoint(Math.round(sim.posX), Math.round(sim.posY));
      sim.currentX = Math.round(sim.posX);
      sim.currentY = Math.round(sim.posY);
      drawMovingCircle(sim.currentX, sim.currentY);
      sim.index++;
      updateStats();
    } else {
      const prevX = sim.currentX, prevY = sim.currentY;
      sim.posX += (dx / distance) * speed;
      sim.posY += (dy / distance) * speed;
      const nx = Math.round(sim.posX), ny = Math.round(sim.posY);
      if (nx !== sim.currentX || ny !== sim.currentY) {
        drawLine(prevX, prevY, nx, ny, TRACE_COLOR);
        sim.currentX = nx; sim.currentY = ny;
        drawMovingCircle(sim.currentX, sim.currentY);
        const ddx = nx - prevX, ddy = ny - prevY;
        sim.drawnLength += Math.sqrt(ddx*ddx + ddy*ddy);
        distBadge.textContent = `Пройдено: ${Math.round(sim.drawnLength)} px`;
      }
      break;
    }
  }

  if (sim.index >= sim.points.length) {
    sim.running = false;
    stateBadge.textContent = 'Готово!';
    startBtn.disabled = false;
    stopBtn.disabled = true;
    progressBar.style.width = '100%';
    progressText.textContent = `${sim.points.length} / ${sim.points.length}`;
    return;
  }

  updateProgress();
  sim.timer = setTimeout(step, parseInt(delayInput.value));
}

function updateStats() {
  countBadge.textContent = `Точек: ${Math.min(sim.index, sim.points.length)}`;
}

function updateProgress() {
  const total = sim.points.length;
  const done = Math.min(sim.index, total);
  const pct = total > 0 ? (done / total) * 100 : 0;
  progressBar.style.width = pct.toFixed(1) + '%';
  progressText.textContent = `${done} / ${total}`;
}

function showAllPoints() {
  if (sim.timer) { clearTimeout(sim.timer); sim.timer = null; }
  sim.running = false;
  initSimDisplay();
  stateBadge.textContent = 'Все точки';
  startBtn.disabled = false;
  stopBtn.disabled = true;

  let totalLength = 0;
  let prev = null;
  for (let i = 0; i < allPoints.length; i++) {
    const p = allPoints[i];
    drawPixel(p.x, p.y, TRACE_COLOR);
    if (prev) {
      const dx = p.x - prev.x, dy = p.y - prev.y;
      totalLength += Math.sqrt(dx*dx + dy*dy);
    }
    prev = p;
  }
  sim.drawnLength = totalLength;
  distBadge.textContent = `Пройдено: ${Math.round(totalLength)} px`;
  progressBar.style.width = '100%';
  progressText.textContent = `${allPoints.length} / ${allPoints.length}`;

  if (allPoints.length > 0) {
    drawCircle(allPoints[0].x, allPoints[0].y, 3, '#00FF00');
  }
}

startBtn.addEventListener('click', startSimulation);
stopBtn.addEventListener('click', stopSimulation);
resetBtn.addEventListener('click', () => { resetSimulation(); stateBadge.textContent = 'Ожидание'; });
showAllBtn.addEventListener('click', showAllPoints);

/* ============================================================
   ЧАСТЬ 4. Код Arduino — БЕЗ ИЗМЕНЕНИЙ
   ============================================================ */
const ARDUINO_CODE = `#include <TFT_eSPI.h>
#include <SPI.h>
#include <SD.h>

TFT_eSPI tft = TFT_eSPI();

// Пины SD-карты (для ESP32)
#define SD_CS    5
#define SD_SCLK 18
#define SD_MOSI 23
#define SD_MISO 19

#define LIGHT_YELLOW TFT_YELLOW
#define DARK_YELLOW  0x8400
#define TRACE_COLOR  TFT_RED

#define CIRCLE_RADIUS 2
#define DISPLAY_WIDTH  128
#define DISPLAY_HEIGHT 160

int currentX = 0, currentY = 0;
int targetX = 0, targetY = 0;
float posX = 0, posY = 0;
float speed = 1.5;

File dataFile;
bool hasMorePoints = true;
int pointCount = 0;

#define SOURCE_MIN 0
#define SOURCE_MAX 168

void setup() {
  Serial.begin(115200);
  tft.init();
  tft.setRotation(2);
  tft.fillScreen(LIGHT_YELLOW);
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);

  SPI.begin(SD_SCLK, SD_MISO, SD_MOSI, SD_CS);
  if (!SD.begin(SD_CS)) { Serial.println("SD card failed!"); showError("SD Error"); return; }
  dataFile = SD.open("/trace.txt", FILE_READ);
  if (!dataFile) { Serial.println("File not found!"); showError("No trace.txt"); return; }
  dataFile.readStringUntil('\\n');
  if (readNextPoint()) {
    posX = targetX; posY = targetY;
    currentX = targetX; currentY = targetY;
    drawTracePoint(currentX, currentY);
  }
}

void centerCoordinates(int &x, int &y) {
  float scaleX = (float)(DISPLAY_WIDTH - 20) / SOURCE_MAX;
  float scaleY = (float)(DISPLAY_HEIGHT - 20) / SOURCE_MAX;
  float scale = min(scaleX, scaleY);
  int scaledX = x * scale, scaledY = y * scale;
  int offsetX = (DISPLAY_WIDTH - (SOURCE_MAX * scale)) / 2;
  int offsetY = (DISPLAY_HEIGHT - (SOURCE_MAX * scale)) / 2;
  x = scaledX + offsetX; y = scaledY + offsetY;
}

bool readNextPoint() {
  if (!dataFile || !dataFile.available()) { hasMorePoints = false; return false; }
  String line = dataFile.readStringUntil('\\n');
  line.trim();
  if (line.length() == 0) return readNextPoint();
  int commaIndex = line.indexOf(',');
  if (commaIndex == -1) return readNextPoint();
  targetX = line.substring(0, commaIndex).toInt();
  targetY = line.substring(commaIndex + 1).toInt();
  centerCoordinates(targetX, targetY);
  targetX = constrain(targetX, 0, DISPLAY_WIDTH - 1);
  targetY = constrain(targetY, 0, DISPLAY_HEIGHT - 1);
  pointCount++;
  return true;
}

void drawTracePoint(int x, int y) { tft.drawPixel(x, y, TRACE_COLOR); }
void drawMovingCircle(int x, int y) {
  tft.fillCircle(x, y, CIRCLE_RADIUS, LIGHT_YELLOW);
  tft.drawCircle(x, y, CIRCLE_RADIUS, DARK_YELLOW);
}

void showError(String msg) {
  tft.fillScreen(TFT_RED);
  tft.setTextColor(TFT_WHITE);
  tft.setTextSize(2);
  tft.setCursor(20, 50);
  tft.print(msg);
  while(1);
}

void loop() {
  if (!hasMorePoints) {
    for (int i = 0; i < 5; i++) {
      tft.fillCircle(currentX, currentY, CIRCLE_RADIUS + 2, TFT_WHITE);
      delay(200);
      drawMovingCircle(currentX, currentY);
      delay(200);
    }
    while(1) delay(1000);
  }
  float dx = targetX - posX;
  float dy = targetY - posY;
  float distance = sqrt(dx*dx + dy*dy);
  if (distance < speed) {
    posX = targetX; posY = targetY;
    drawTracePoint((int)posX, (int)posY);
    currentX = (int)posX; currentY = (int)posY;
    drawMovingCircle(currentX, currentY);
    readNextPoint();
  } else {
    posX += (dx / distance) * speed;
    posY += (dy / distance) * speed;
    int newX = (int)posX, newY = (int)posY;
    if (newX != currentX || newY != currentY) {
      tft.drawLine(currentX, currentY, newX, newY, TRACE_COLOR);
      currentX = newX; currentY = newY;
      drawMovingCircle(currentX, currentY);
    }
  }
  delay(10);
}`;

function updateArduinoCode() {
  document.getElementById('arduinoCode').textContent = ARDUINO_CODE;
}
updateArduinoCode();

document.getElementById('copyCodeBtn').addEventListener('click', () => {
  navigator.clipboard.writeText(ARDUINO_CODE).then(() => {
    const btn = document.getElementById('copyCodeBtn');
    const old = btn.textContent;
    btn.textContent = '✅ Скопировано!';
    setTimeout(() => btn.textContent = old, 1500);
  });
});

/* Инициализация */
resetToInitial();
updateDimensions();
</script>
</body>
</html>

хотел показать реальную работу, и сам уперся в то что это скорее симуляция)))

хотелось бы вообще что бы все учитывалось, но и так офигительно! еще и схема есть у вас, и куча всего…

Спасибо за добрые слова. Проект стараюсь дорабатывать, за последние дни немного переработаны делали и добавлена макетная плата, теперь все делали можно поставить на макетку. С обратной связью вашей проект будет развиваться дальше.

Обновление на PinPort, деталь на схеме можно повернуть на 45, 90, 180 градусов.

Добавляет удобства для работы.

Именно поэтому надписи на ESP перевёрнуты относительно разъёма и модуля? Чтобы “повеселить” невнимательных кто будет собирать в живую?

Спасибо, отличная обратная связь. Проблему пофиксил.