#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Параметрический генератор органайзера для кошачьего корма (вариант 2). Дизайн по мотивам cat_organaizer: высокая коробка + крышка из 2 частей (верх с кошачьими ушками + низ с защёлками). БЕЗ боковой конструкции. Коробка делится на сегменты для печати на любом принтере. Экспорт: box_seg_*.stl, lid_top.stl, lid_bottom.stl, box_one_piece.stl, box.obj, box.glb, preview.png, preview.html. """ import base64 import numpy as np import trimesh from trimesh.creation import extrude_polygon, cone, box from shapely.geometry import Polygon # ============================ ПАРАМЕТРЫ ============================ # Размеры ОДНОГО пакета (пауча) влажного корма (Д × Ш × В), мм PACKET = (135.0, 90.0, 18.0) # Whiskas 85 г (оценка — ИЗМЕРЬ СВОЙ ПАКЕТ!) PACKETS = 20 # сколько пакетов должно помещаться H_CLEAR = 5.0 # зазор по высоте стопки, мм # Габариты коробки (как в оригинальном дизайне cat_organaizer) BOX_L = 131.5 # длина коробки (X), мм BOX_W = 156.2 # ширина коробки (Y), мм WALL = 3.0 # толщина стенок, мм BASE = 15.0 # толщина сплошного дна, мм CORNER_R = 10.0 # радиус скругления углов, мм # Соединение сегментов RIM_OVERHANG = 3.0 # выступ бортика, мм RIM_HEIGHT = 8.0 # высота бортика, мм LID_CLEARANCE = 0.4 # зазор посадки, мм SEG_OVERLAP = 12.0 # высота юбки-соединения, мм SEGMENTS = 2 # на сколько частей резать коробку # Крышка LID_THICK = 24.0 # толщина крышки-верха, мм LID_BOTTOM_WALL = 6.0 # толщина дна крышки-верха (под защёлки), мм LID_BOTTOM_THICK = 9.0 # толщина крышки-низа, мм CLIP = 8.0 # размер защёлки, мм CLIP_H = 8.0 # высота защёлки, мм CLIP_INSET = 10.0 # отступ защёлок от краёв, мм HOLE_OVER = 0.5 # запас отверстия под защёлку, мм HOLE_DEPTH = 12.0 # глубина отверстий, мм # Кошачьи ушки EAR_R = 15.0 # радиус основания ушка, мм EAR_H = 24.0 # высота ушка, мм EAR_W_SCALE = 0.65 # сжатие ушка по ширине (эллипс) EAR_TILT = 18.0 # наклон ушек наружу, градусы EAR_FWD = 8.0 # наклон ушек вперёд, градусы EAR_SIDE = 22.0 # отступ ушек от боковых кромок, мм EAR_BACK = 12.0 # отступ ушек от задней кромки, мм SEG = 64 # сегменты окружностей # =================================================================== def rounded_rect(w, h, r, seg=SEG): """Скруглённый прямоугольник (shapely Polygon), центр в (0,0).""" rect = Polygon([(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)]) return rect.buffer(-r, resolution=seg).buffer(r, resolution=seg) def make_box_segments(): """Коробка из SEGMENTS сегментов. Возвращает (список мешей, габариты).""" H_int = PACKETS * PACKET[2] + H_CLEAR L_ext, W_ext = BOX_L, BOX_W L_int, W_int = L_ext - 2 * WALL, W_ext - 2 * WALL H_ext = H_int + BASE seg_h = H_ext / SEGMENTS inner_r = max(CORNER_R - WALL, 0.5) outer_prof = rounded_rect(L_ext, W_ext, CORNER_R) inner_prof = rounded_rect(L_int, W_int, inner_r) segments = [] for i in range(SEGMENTS): is_top = (i == SEGMENTS - 1) if i == 0: # нижний сегмент: с дном seg = extrude_polygon(outer_prof, seg_h).difference( extrude_polygon(inner_prof, seg_h - BASE).apply_translation([0, 0, BASE])) else: # верхние сегменты: кольцо без дна seg = extrude_polygon(outer_prof, seg_h).difference(extrude_polygon(inner_prof, seg_h)) # юбка-соединение (низ): свисает вниз и надевается на бортик нижнего сегмента sk_out = rounded_rect(L_ext + 2 * (RIM_OVERHANG + WALL + LID_CLEARANCE), W_ext + 2 * (RIM_OVERHANG + WALL + LID_CLEARANCE), CORNER_R + RIM_OVERHANG + WALL + LID_CLEARANCE) sk_in = rounded_rect(L_ext + 2 * (RIM_OVERHANG + LID_CLEARANCE), W_ext + 2 * (RIM_OVERHANG + LID_CLEARANCE), CORNER_R + RIM_OVERHANG + LID_CLEARANCE) skirt = extrude_polygon(sk_out, SEG_OVERLAP).difference(extrude_polygon(sk_in, SEG_OVERLAP)) skirt.apply_translation([0, 0, -SEG_OVERLAP]) step = extrude_polygon(sk_in, WALL).difference(extrude_polygon(outer_prof, WALL)) seg = seg.union(skirt).union(step) # верхний бортик (рим) — на всех сегментах, кроме самого верхнего if not is_top: rim_outer = rounded_rect(L_ext + 2 * RIM_OVERHANG, W_ext + 2 * RIM_OVERHANG, CORNER_R + RIM_OVERHANG) rim_inner = rounded_rect(L_ext + 0.1, W_ext + 0.1, CORNER_R + 0.05) rim = extrude_polygon(rim_outer, RIM_HEIGHT).difference( extrude_polygon(rim_inner, RIM_HEIGHT)) rim.apply_translation([0, 0, seg_h - RIM_HEIGHT]) seg = seg.union(rim) seg.apply_translation([0, 0, i * seg_h]) segments.append(seg) return segments, (L_ext, W_ext, H_ext) def make_lid_top(L_ext, W_ext): """Крышка-верх: полая пластина с ушками и 4 отверстиями под защёлки.""" # внешняя пластина outer = extrude_polygon(rounded_rect(L_ext, W_ext, CORNER_R), LID_THICK) # полость (оболочка: верх WALL, дно LID_BOTTOM_WALL, стенки WALL) cavity = extrude_polygon(rounded_rect(L_ext - 2 * WALL, W_ext - 2 * WALL, CORNER_R - WALL), LID_THICK - WALL - LID_BOTTOM_WALL) cavity.apply_translation([0, 0, LID_BOTTOM_WALL]) lid = outer.difference(cavity) # кошачьи ушки (задняя кромка, -Y) for side in (-1, 1): ear = cone(radius=EAR_R, height=EAR_H, sections=SEG) ear.apply_scale([1.0, EAR_W_SCALE, 1.0]) R = trimesh.transformations.rotation_matrix(np.radians(-EAR_FWD), [1, 0, 0], point=[0, 0, 0]) R = trimesh.transformations.rotation_matrix(np.radians(side * EAR_TILT), [0, 1, 0], point=[0, 0, 0]) @ R ear.apply_transform(R) ear.apply_translation([side * (L_ext / 2 - EAR_SIDE), -(W_ext / 2 - EAR_BACK), LID_THICK]) lid = lid.union(ear) # 4 отверстия под защёлки (снизу, по углам) — позиции совпадают с защёлками cx = L_ext / 2 - CLIP_INSET - CLIP / 2 cy = W_ext / 2 - CLIP_INSET - CLIP / 2 for sx in (-1, 1): for sy in (-1, 1): hole = box(extents=[CLIP + HOLE_OVER, CLIP + HOLE_OVER, HOLE_DEPTH]) hole.apply_translation([sx * cx, sy * cy, HOLE_DEPTH / 2]) lid = lid.difference(hole) return lid def make_lid_bottom(L_ext, W_ext): """Крышка-низ: пластина-заглушка с 4 защёлками по углам.""" L_bot = L_ext - 2 * WALL - LID_CLEARANCE W_bot = W_ext - 2 * WALL - LID_CLEARANCE plate = extrude_polygon(rounded_rect(L_bot, W_bot, CORNER_R - WALL), LID_BOTTOM_THICK) lid = plate # защёлки — на тех же позициях, что и отверстия в крышке-верх cx = L_ext / 2 - CLIP_INSET - CLIP / 2 cy = W_ext / 2 - CLIP_INSET - CLIP / 2 for sx in (-1, 1): for sy in (-1, 1): clip = box(extents=[CLIP, CLIP, CLIP_H]) clip.apply_translation([sx * cx, sy * cy, LID_BOTTOM_THICK + CLIP_H / 2]) lid = lid.union(clip) return lid def write_preview_html(glb_path, out_path, info_lines): glb = open(glb_path, 'rb').read() b64 = base64.b64encode(glb).decode() info_html = "
".join(info_lines) html = """ Органайзер для кошачьего корма — 3D просмотр
Органайзер для кошачьего корма
__INFO__
ЛКМ — вращение · колесо — масштаб · ПКМ — сдвиг
""" html = html.replace("__GLB_B64__", b64).replace("__INFO__", info_html) open(out_path, 'w', encoding='utf-8').write(html) def main(): segments, (L_ext, W_ext, H_ext) = make_box_segments() lid_top = make_lid_top(L_ext, W_ext) lid_bottom = make_lid_bottom(L_ext, W_ext) H_int = PACKETS * PACKET[2] + H_CLEAR print("=== РАЗМЕРЫ (мм) ===") print(f"Пакет (оценка): {PACKET[0]} x {PACKET[1]} x {PACKET[2]} | пакетов: {PACKETS}") print(f"Внутренний размер коробки: {L_ext-2*WALL:.1f} x {W_ext-2*WALL:.1f} x {H_int:.1f}") print(f"Наружный размер коробки: {L_ext:.1f} x {W_ext:.1f} x {H_ext:.1f}") print(f"Сегментов: {SEGMENTS} (по {H_ext/SEGMENTS:.1f} мм)") print(f"Крышка-верх: {lid_top.bounds[1][0]-lid_top.bounds[0][0]:.1f} x " f"{lid_top.bounds[1][1]-lid_top.bounds[0][1]:.1f} x " f"{lid_top.bounds[1][2]-lid_top.bounds[0][2]:.1f} мм") print(f"Крышка-низ: {lid_bottom.bounds[1][0]-lid_bottom.bounds[0][0]:.1f} x " f"{lid_bottom.bounds[1][1]-lid_bottom.bounds[0][1]:.1f} x " f"{lid_bottom.bounds[1][2]-lid_bottom.bounds[0][2]:.1f} мм") for i, m in enumerate(segments): print(f"сегмент {i+1}: водонепроницаем={m.is_watertight}, объём={m.volume/1000:.0f} см3") for name, m in (("крышка-верх", lid_top), ("крышка-низ", lid_bottom)): print(f"{name}: водонепроницаема={m.is_watertight}, объём={m.volume/1000:.0f} см3") # экспорт for i, m in enumerate(segments): m.export(f"box_seg_{i+1}.stl") lid_top.export("lid_top.stl") lid_bottom.export("lid_bottom.stl") one = segments[0] for m in segments[1:]: one = one.union(m) one.export("box_one_piece.stl") # сборка для просмотра: коробка + крышка-низ (в отверстии) + крышка-верх lid_bot_on = lid_bottom.copy().apply_translation([0, 0, H_ext]) lid_top_on = lid_top.copy().apply_translation([0, 0, H_ext + LID_BOTTOM_THICK]) scene = trimesh.Scene(segments + [lid_bot_on, lid_top_on]) scene.export("box.glb") combined = one.union(lid_bot_on).union(lid_top_on) combined.export("box.obj") print("\nЭкспортировано: box_seg_1.stl..box_seg_N.stl, lid_top.stl, lid_bottom.stl, box_one_piece.stl, box.obj, box.glb") # превью PNG try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from mpl_toolkits.mplot3d.art3d import Poly3DCollection fig = plt.figure(figsize=(9, 11)) ax = fig.add_subplot(111, projection="3d") colors = ["#8ecae6", "#a8dadc", "#bde0fe", "#cdb4db"] for i, m in enumerate(segments): pc = Poly3DCollection(m.triangles, alpha=0.85, facecolor=colors[i % len(colors)], edgecolor="k", linewidth=0.1) ax.add_collection3d(pc) pc = Poly3DCollection(lid_bot_on.triangles, alpha=0.9, facecolor="#90e0ef", edgecolor="k", linewidth=0.1) ax.add_collection3d(pc) pc = Poly3DCollection(lid_top_on.triangles, alpha=0.9, facecolor="#ffb703", edgecolor="k", linewidth=0.1) ax.add_collection3d(pc) b = combined.bounds ax.set_xlim(b[0][0], b[1][0]); ax.set_ylim(b[0][1], b[1][1]); ax.set_zlim(b[0][2], b[1][2]) ax.set_box_aspect((b[1][0]-b[0][0], b[1][1]-b[0][1], b[1][2]-b[0][2])) ax.set_xlabel("X, мм"); ax.set_ylabel("Y, мм"); ax.set_zlabel("Z, мм") ax.view_init(elev=20, azim=-60) plt.tight_layout() plt.savefig("preview.png", dpi=100) print("Превью: preview.png") except Exception as e: print("Превью PNG не создано:", e) # интерактивный просмотрщик info = [ f"Вмещает {PACKETS} пакетов ({PACKET[0]:.0f}×{PACKET[1]:.0f}×{PACKET[2]:.0f} мм)", f"Внутр.: {L_ext-2*WALL:.0f} × {W_ext-2*WALL:.0f} × {H_int:.0f} мм", f"Наруж.: {L_ext:.0f} × {W_ext:.0f} × {H_ext:.0f} мм", f"Сегментов: {SEGMENTS} · Без боковой конструкции", f"Крышка: верх с ушками + низ с защёлками", ] write_preview_html("box.glb", "preview.html", info) print("Просмотрщик: preview.html") if __name__ == "__main__": main()