javascript

Модель не влезает на стол: почему плоскость — плохой нож

  • вторник, 25 августа 2026 г. в 00:00:06
https://habr.com/ru/articles/1073940/

У меня браузерный генератор вывесок: человек набирает надпись, получает STL-модель корпуса под светодиодную ленту. Всё считается на его же машине, файл никуда не уходит. Однажды пришёл вопрос, который сломал мне неделю: надпись 500 мм, стол принтера 256×256. Что делать?

Стандартный ответ — «режьте в слайсере». Слайсер режет плоскостью, и это честно работает ровно до момента, когда вы забираете со стола две половинки со срезами и понимаете, что совместить их нормально не получится они не совпадают. Клей на PLA держит на честном слове и еще и срезы не ровные, при склейке половинки едут, и на видном месте остаётся ступенька.

Я решил сделать рез внутри генератора: чтобы части сразу выходили с замками и собирались без клея. Дальше — про то, как я трижды всё переделал, и про шесть граблей, каждая из которых стоила мне вечера.

Первая версия: плоскость. И почему она не годится

Первое, что приходит в голову — вертикальная плоскость: всё левее неё в одну часть, всё правее в другую. Пятнадцать строк кода, работает.

Ровно до первой рукописной надписи. Плоскость режет всё, чего касается, — и если она задела заглавную «N» краем, буква разваливается пополам. При этом рядом, между буквами, есть просвет, где резать вообще нечего: там пусто.

Человек, который смотрит на превью, видит это мгновенно и говорит: «нож цепляет N, хотя мог обойти». И он прав.

Вторая версия: рез — это путь

Если сформулировать задачу словами, получается: провести линию сверху вниз так, чтобы она как можно меньше шла по материалу. Это ровно поиск кратчайшего пути на сетке, только «расстояние» — это не длина, а сколько материала мы режем.

Беру силуэт модели в плане, растеризую с шагом 2 мм и раскладываю динамикой:

// Рез как поиск дешёвого пути: материал дорогой, пустота дешёвая
const cost = ys.map(y => xs.map(x => inMat(rings, x, y) ? 400 : 1));

for (let j = 1; j < ys.length; j++) {
  for (let i = 0; i < W; i++) {
    for (const d of [-1, 0, 1]) {        // вниз: прямо или наискось
      const k = i + d;
      if (k < 0 || k >= W) continue;
      const c = dp[j - 1][k] + cost[j][i] + (d ? 0.4 : 0);
      if (c < dp[j][i]) { dp[j][i] = c; from[j][i] = k; }
    }
  }
}

Три числа, каждое из которых я потрогал руками:

Те же части в разносе: разрез прошёл по связке между буквами, обе половины надписи целые. Зелёная метка — место, где встал замок
Те же части в разносе: разрез прошёл по связке между буквами, обе половины надписи целые. Зелёная метка — место, где встал замок

Материал стоит 400, пустота — 1. При соотношении вроде 10 линия соглашалась резать букву, чтобы срезать угол. С четырьмястами она лезет в материал, только если обхода нет вовсе.

Шаг растра 2 мм. Сначала было 3 — и нож не видел просветы шириной ровно три миллиметра, то есть резал буквы там, где был проход. Уменьшил — и он нашёл дорогу между буквами, которую до этого «не замечал».

Боковой шаг штрафуется на 0.4. Без штрафа путь виляет по пустоте, потому что все пустые клетки для него одинаковые, и рез получается диагональным зигзагом. Со штрафом линия идёт прямо, пока не появится причина свернуть.

Итог на моей тестовой надписи: линия перестала быть прямой и теперь ищет, где пересечь материал подешевле. На этом кадре она проходит по тонкой связке между «N» и «e» — заглавная буква остаётся целой, а режется перемычка толщиной в пару миллиметров. Прямая плоскость на том же месте прошла бы через середину штриха.

Обойти материал совсем удаётся не всегда: если надпись плотная и её надо разложить на три куска, сплошного просвета сверху донизу может не найтись. Тогда алгоритм выбирает самое узкое пересечение из возможных — и в этом месте ставит замок.

Замки: как соединить без клея

Там, где путь всё-таки пересёк материал, нужен замок. Я сделал три варианта и выбираю по тому, что физически влезает.

Рез идёт по тонкой связке между буквами: «N» остаётся целой. Зелёная метка — место, где путь пересёк материал, там ставится замок
Рез идёт по тонкой связке между буквами: «N» остаётся целой. Зелёная метка — место, где путь пересёк материал, там ставится замок

Ласточкин хвост в теле. Трапеция: узкая часть на шве, широкая уходит внутрь. Размер не константа, а доля от локальной толщины сечения — примерно 0.28. Константа плоха тем, что на тонком месте хвост прорезает стенку насквозь, а на толстом выглядит игрушечным. Снаружи шва не видно ничего.

Вставка-бабочка с тыла. Когда на хвост не хватает материала: в задней грани через шов фрезеруется карман, к нему печатается отдельная пластина-бабочка. С лица по-прежнему чисто.

Ничего. У круглых неоновых трубок прижимать нечего физически: сечение касается плоскости линией. Там части держит задняя панель, и генератор об этом честно пишет, вместо того чтобы обещать несуществующую защёлку.

Перед тем как врезать замок, я проверяю его объёмом: пересекаю тело будущего шипа с обеими частями и смотрю, что он реально сидит в материале и слева, и справа. Не прошло — сдвигаюсь вдоль линии реза и пробую следующее место. Именно эта проверка избавила от «шипов-обрубков», торчащих в воздухе.

Шесть граблей

Контур режущей призмы обязан идти против часовой стрелки. Библиотека геометрии читает контур в обратном обходе как дырку. Призма получается пустой, и рез просто не происходит: ни ошибки, ни исключения — модель остаётся целой, как будто ты ничего не нажимал. Полчаса я искал ошибку в логике, а надо было посмотреть на знак площади.

Путь нельзя прореживать на выдаче. Я возвращал каждую вторую точку, чтобы превью рисовалось легче. После двух-трёх правок подряд кривая вырождалась в грубую ломаную — «точки пропадают». Данные для пересчёта и данные для показа — разные вещи, и прореживать можно только вторые.

Плоскость перетаскивания строится через сам взятый объект. Я строил её через центр всей линии реза — и на наклонном ракурсе узел улетал от движения мыши на пару пикселей. Это не «дрожащая мышь», это параллакс: чем дальше точка от плоскости, тем сильнее промах.

Осколки надо пришивать с обеих сторон. Линия, дважды пересёкшая завиток буквы, отрезает её верхушку крошечным островком. Я приваривал такие островки из левой части в правую — и ровно такой же обрубок продолжал жить в правой. Смешно, но заметил не я.

Жёсткий бамп в жёстком пазу не защёлкивается. В первой версии на торце шипа был шарик, а в пазу лунка — «щёлкнет и зафиксируется». Не щёлкает: пластику некуда деформироваться, шип просто не входит. Убрал шарик: в плане деталь держит сам хвост, по вертикали — крышка корпуса.

Шип должен расти от стола, а паз быть открыт доверху. Иначе внутри замка появляется нависание, слайсер лепит туда поддержки, и вычистить их из паза невозможно. А козырёк в 1.2 мм над пазом я не заметил вовсе — шип упирался в него и не заходил, хотя по чертежу всё сходилось.

Ручной режим

Автомат ошибается на нестандартных надписях, поэтому линию реза можно править мышью: она показана лентой по фактическому пути, на ней узлы, каждый тянется вбок. Замок ставится кликом, убирается правой кнопкой.

Отдельно скажу про честность отрисовки. Сначала я рисовал линию реза прямой палкой на средней координате — так проще. Человек смотрел на превью и видел, что нож проходит сквозь букву, хотя настоящий рез её обходил. Он не поверил инструменту, и был прав: показывать надо то, что произойдёт, а не упрощённую схему того, что задумано.

Что в итоге

Модель 500 × 197 × 24 мм режется на три части, каждая влезает в 256×256, собирается без клея, шов снаружи чистый. Живой посадки я пока не проверял на всех вариантах — зазор 0.25 мм подобран на пробных купонах, и это то место, где теория заканчивается и начинается конкретный принтер.

Всё описанное работает в бесплатном инструменте, ссылка в профиле.