Органайзер для кошачьего корма: 3D-модель, STL для печати, просмотрщик

This commit is contained in:
apuc committed 2026-09-29 12:43:43 +03:00
commit bd44338cbd
19 files changed
+61107

No files matched your search

+333
View File
@@ -0,0 +1,333 @@
#!/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 = "<br>".join(info_lines)
html = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Органайзер для кошачьего корма — 3D просмотр</title>
<style>
body { margin:0; overflow:hidden; font-family:'Segoe UI',Arial,sans-serif; background:#1a1d24; color:#e8e8e8; }
#info { position:absolute; top:12px; left:12px; z-index:10; background:rgba(20,24,32,.85);
padding:10px 14px; border-radius:8px; font-size:13px; line-height:1.5; max-width:320px;
border:1px solid rgba(255,255,255,.12); backdrop-filter:blur(4px); }
#info b { color:#ffb703; }
#hint { position:absolute; bottom:12px; left:50%; transform:translateX(-50%); z-index:10;
font-size:12px; color:#9aa3b2; background:rgba(20,24,32,.7); padding:6px 12px; border-radius:20px; }
canvas { display:block; }
</style>
</head>
<body>
<div id="info"><b>Органайзер для кошачьего корма</b><br>__INFO__</div>
<div id="hint">ЛКМ — вращение · колесо — масштаб · ПКМ — сдвиг</div>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1d24);
const camera = new THREE.PerspectiveCamera(45, innerWidth/innerHeight, 1, 3000);
camera.position.set(420, 340, 500);
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 180, 0);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x444455, 0.9));
const sun = new THREE.DirectionalLight(0xffffff, 1.6);
sun.position.set(200, 500, 150); sun.castShadow = true; scene.add(sun);
const fill = new THREE.DirectionalLight(0x88aaff, 0.5);
fill.position.set(-300, 200, -250); scene.add(fill);
const grid = new THREE.GridHelper(500, 25, 0x3a4150, 0x2a2f3a);
grid.position.y = -1; scene.add(grid);
const b64 = "__GLB_B64__";
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i=0;i<bin.length;i++) arr[i] = bin.charCodeAt(i);
const loader = new GLTFLoader();
loader.parse(arr.buffer, '', (gltf) => {
const model = gltf.scene;
model.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
scene.add(model);
const box = new THREE.Box3().setFromObject(model);
const c = box.getCenter(new THREE.Vector3());
controls.target.copy(c);
camera.position.copy(c).add(new THREE.Vector3(420, 340, 500));
}, (e) => console.error(e));
window.addEventListener('resize', () => {
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
function animate(){ requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }
animate();
</script>
</body>
</html>"""
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()