Files
3d_model/generate_box.py
T

333 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()