#!/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 = """