333 lines
17 KiB
Python
333 lines
17 KiB
Python
#!/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() |