Корпус для датчика в 217 строк JavaScript
На столе три вещи. Плата nRF52840 Pro Micro, 34 × 17 мм. Литиевый аккумулятор 12 × 30. Датчик температуры, влажности и давления — квадратик 15 × 15.
Им нужна коробка.
Не красивая и не на века — такая, чтобы плата не болталась, USB-C доставал до кабеля, а датчик мерил воздух в комнате, а не тепло от собственного аккумулятора. Ниже — как эта коробка написана на cadcode, целиком, от размеров до двух STL на столе принтера. Файл лежит в репозитории как examples/sensor-case.js, в нём 217 строк, и каждую картинку здесь нарисовал он сам.
Сначала размеры, потом форма
Любой корпус начинается со штангенциркуля. Всё, что намерили, сразу ложится в params:
export const params = {
// Print
wall: 2.0, // wall thickness
floorT: 1.6, // floor
lidT: 1.6, // lid
fit: 0.15, // gap between the lid skirt and the wall, per side
cornerR: 2.0, // outer corner radius
// Pro Micro board
boardL: 34,
boardW: 17,
boardT: 1.6,
boardStandoff: 2.0, // board sits on rails, room for wires/pads underneath
// Battery
batD: 12,
batL: 30,
batClr: 0.5,
// Sensor
sensorS: 15,
sensorPadH: 3, // tape pad, raised so air flows underneath
sensL: 17, // sensor compartment length along X
// ...
};
Это не просто константы в начале файла. Это спецификация — и одновременно ручки. Всё, что здесь объявлено, можно переопределить из командной строки, и всё это же появится панелью в превью. До них мы ещё дойдём.
Первым делом компоненты рисуются как есть — болванками нужного размера:

Пока это три примитива. Но именно от них дальше будет считаться всё остальное.
Внутренность считается, а не задаётся
Внешний размер коробки нигде не написан. Он выводится из того, что должно поместиться внутрь:
const mainL = boardX0 + boardL + 0.7; // main compartment
const divT = 1.6; // divider between compartments
const batY0 = boardY0 + boardW + 1.5;
const iL = mainL + divT + sensL;
const iW = batY0 + batD + batClr + 1.0; // +1 mm: battery clears the lid skirt
const iH = batD + batClr + 0.5;
Два отсека: в большом плата и аккумулятор, в маленьком датчик, между ними перегородка. Длина — по плате, ширина — по плате и аккумулятору рядом, высота — по аккумулятору, он самый толстый. Возьмёте аккумулятор потолще — коробка станет шире и выше сама, вместе с крышкой, защёлкой и порами.
Это главное, ради чего CAD вообще стоит писать кодом. Размер, который выведен, не может разойтись с тем, из чего он выведен. В программе с мышкой это называется «параметрическая модель» и требует дисциплины. Здесь это просто арифметика.
Чтобы видеть итог, программа печатает его в консоль:
console.log(`Outer size: ${iL + 2 * wall} x ${iW + 2 * wall} x ${iH + floorT + lidT} mm`);
Outer size: 57.6 x 36.5 x 16.2 mm
Скруглённая коробка — это hull из четырёх цилиндров
Коробка со скруглёнными углами — это выпуклая оболочка вокруг четырёх цилиндров, по одному в каждом углу:
const roundedBox = (l, w, h, r) =>
hull(product([r, l - r], [r, w - r]).map(([x, y]) => cylinder({ r, h }).translate([x, y, 0])));
product — это вложенный for из OpenSCAD: все пары x и y, четыре угла. Корпус — такая коробка минус внутренность:
difference(
roundedBox(iL + 2 * wall, iW + 2 * wall, iH + floorT, cornerR).translate([-wall, -wall, -floorT]),
cube([iL, iW, iH + 1]),
// ...
)

Внутренний куб на миллиметр выше коробки — чтобы вычитание гарантированно прорезало верх, а не оставило плёнку нулевой толщины. Старая привычка из OpenSCAD, и она тут по-прежнему нужна.
Макеты внутри коробки
Теперь болванки компонентов ставятся туда, где они будут лежать, и красятся:
const dummies = () => [
cube([boardL, boardW, boardT]).translate([boardX0, boardY0, boardStandoff]).color('green'),
cube([7.5, usbW, usbH]).translate([boardX0 - 0.5, usbCy - usbW / 2, boardStandoff + boardT]).color('silver'),
cylinder({ d: batD, h: batL })
.rotate([0, 90, 0])
.translate([mainL / 2 - batL / 2, batY0 + (batD + batClr) / 2, (batD + batClr) / 2])
.color('royalblue'),
cube([sensorS, sensorS, 1.2]).translate([sx0 + (sensL - sensorS) / 2, (iW - sensorS) / 2, sensorPadH]).color('purple'),
];
В печать они не пойдут никогда. Они нужны, чтобы видеть. Корпус в сборке полупрозрачный, крышка приподнята на 8 мм и тоже полупрозрачная — и сразу видно, где упирается, а где болтается:

Цвет и прозрачность у каждого тела свои и переживают булевы операции. Для STL это ничего не значит — там всё сливается в одно тело. Для глаза — всё.
Окно под USB-C — это 2D-эскиз
Штекер USB-C скруглён, и окно под него тоже стоит скруглить. Удобнее всего нарисовать его плоским, скруглить и только потом выдавить:
// USB-C: through window + recess for the plug outside
square([usbH + usbSlotExtra - 2, usbW + usbSlotExtra - 2], { center: true })
.offset(1)
.extrude(wall + 2)
.rotate([0, 90, 0])
.translate([-wall - 1, usbCy, usbCz]),
square([plugH - 3, plugW - 3], { center: true })
.offset(1.5)
.extrude(plugDepth + 1)
.rotate([0, 90, 0])
.translate([-wall - 1, usbCy, usbCz]),
Квадрат на 2 мм меньше, offset(1) возвращает этот размер обратно, но уже со скруглёнными углами. Второе окно — неглубокая ниша 1.2 мм под пластиковый корпус штекера, чтобы он входил до упора.
Читать цепочку стоит сверху вниз: эскиз, скругление, выдавливание, поворот на стенку, сдвиг на место. В OpenSCAD то же самое читалось бы снизу вверх.

Крышка держится на защёлке
Винтов нет. Крышка опускает внутрь юбку высотой 4 мм, на юбке — валики, в стенках корпуса — пазы под них. Валик и паз — это один и тот же лежачий цилиндр:
const snapLine = (len) => cylinder({ r: bumpR, h: len, center: true }).rotate([0, 90, 0]);
В корпусе он вычитается, на крышке — прибавляется, но обрезанный, чтобы торчал только наружу:
product(bumpXs, [fit, iW - fit]).map(([x, y]) =>
intersection(
snapLine(bumpLen).translate([x, y, iH - lipD / 2]),
cube([iL, iW - 2 * fit + 2 * bumpR, iH]).translate([0, fit - bumpR, 0]),
),
),
Сплошная юбка гнулась бы плохо, поэтому по бокам каждого валика в ней прорезаны щели — получаются язычки. Под перегородку в юбке паз. С внутренней стороны крышки — три штыря: один прижимает разъём USB-C, два — дальний край платы. Ещё один упор держит аккумулятор.

Зазор между юбкой и стенкой — тот самый fit: 0.15 на сторону. Запомните его.
Датчику нужен воздух
Датчик в закрытой коробке мерит коробку. Поэтому его отсек весь в дырках: в торце, в обеих боковых стенках, в дне и в крышке над ним. Сетку отверстий удобно описать один раз — функцией от размера площадки:
const poreGrid = (a, b) => {
const na = Math.floor((a - poreD) / porePitch) + 1;
const nb = Math.floor((b - poreD) / porePitch) + 1;
const oa = (a - (na - 1) * porePitch) / 2;
const ob = (b - (nb - 1) * porePitch) / 2;
return union(
product(range(0, na - 1), range(0, nb - 1)).map(([i, j]) =>
cylinder({ d: poreD, h: 10, center: true }).translate([oa + i * porePitch, ob + j * porePitch, 0]),
),
);
};
Сколько отверстий влезает с шагом 3.2 мм — она считает сама, и сама же центрует сетку на площадке. Дальше её поворачивают на нужную стенку. Пять мест, одна функция.
Сам датчик лежит не на дне, а на площадке высотой 3 мм — чтобы воздух проходил и под ним. Плата — на двух рельсах 2 мм: под ней остаётся место для проводов. Между отсеками в перегородке окошко под провода I²C.

Последняя мелочь — выемка под ноготь в длинной стенке. Защёлка, которая хорошо держит, плохо открывается.
Четыре детали из одного файла
Программа возвращает не одно тело, а объект именованных частей:
return {
assembly: () => [box().color('lightgray', 0.5), lid().translate([0, 0, 8]).color('orange', 0.35), dummies()],
box,
lid: () => lidForPrint().translate([0, 0, iH + lidT]),
print: () => [box(), lidForPrint().translate([0, iW + 2 * wall + 6, iH + lidT])],
};
assembly — чтобы смотреть. box и lid — по отдельности. print — обе детали, как они лягут на стол: корпус дном вниз, крышка перевёрнута крышей вниз, чтобы юбка с валиками печаталась без поддержек.
const lidForPrint = () => lid().translate([0, -iW, 0]).rotate([180, 0, 0]);

Части — функции, и вызывается только та, которую показывают или выгружают. Макеты не считаются, когда печатаете корпус.
cadcode parts sensor-case.js
Outer size: 57.6 x 36.5 x 16.2 mm
assembly
box
lid
print
cadcode stl sensor-case.js --all -o out/
Outer size: 57.6 x 36.5 x 16.2 mm
assembly → out/sensor-case-assembly.stl (51 ms)
Outer size: 57.6 x 36.5 x 16.2 mm
box → out/sensor-case-box.stl (185 ms)
Outer size: 57.6 x 36.5 x 16.2 mm
lid → out/sensor-case-lid.stl (77 ms)
Outer size: 57.6 x 36.5 x 16.2 mm
print → out/sensor-case-print.stl (264 ms)
Для проверки глазами есть cadcode png — и вид sheet, изометрия плюс три проекции на одной картинке:
cadcode png sensor-case.js --part assembly --view sheet --size 1600x1200

Рендерит его сам cadcode, без браузера и без видеокарты. Поэтому такой лист получается и в CI, и у агента, который пишет деталь и хочет на неё посмотреть.
Зазор подбирается флагом
fit: 0.15 — это догадка. Сколько на самом деле нужно, знает только ваш принтер, ваш пластик и ваш слайсер. Если крышка не садится, перерисовывать ничего не надо:
cadcode stl sensor-case.js --part print --set fit=0.2
Outer size: 57.6 x 36.5 x 16.2 mm
print → sensor-case-print.stl (291 ms)
Юбка стала уже, валики сдвинулись вместе с ней. Всё, что от fit зависело, пересчиталось.
Опечатка при этом не пройдёт тихо:
cadcode stl sensor-case.js --set fti=0.2
cadcode: Unknown parameter "fti". Declared: wall, floorT, lidT, fit, cornerR, boardL, boardW, boardT, boardStandoff, boardX0, boardY0, usbW, usbH, usbSlotExtra, plugW, plugH, plugDepth, batD, batL, batClr, sensorS, sensorPadH, sensL, lipD, lipT, bumpR, bumpLen, poreD, porePitch
Не «напечатал со старым значением». Не «проигнорировал». Отказался.
А пока подбираете на глаз, удобнее cadcode dev. Он открывает модель в браузере и пересобирает её на каждое сохранение файла, не сбивая камеру. Те же params там — панель справа: число можно вписать или потянуть за имя вбок. Изменённое значение помечено и сбрасывается отдельно, а кнопка Download STL отдаёт деталь уже с ним. В углу — то, что напечатал console.log.

Что в итоге
Двести с небольшим строк. Треть из них — размеры и комментарии, которые вы бы всё равно где-то записали. Остальное — функции, которые описывают не форму, а намерение: «коробка вокруг этого», «сетка дырок на этой площадке», «защёлка здесь и здесь».
Другая плата — это три других числа в params.
Комментарии 0
Комментариев пока нет.