Органайзер для кошачьего корма: 3D-модель, STL для печати, просмотрщик
This commit is contained in:
commit
bd44338cbd
19 files changed
+61107
No files matched your search
+333
@@ -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()
|
||||
Reference in new issue
Block a user