[Python] 纯文本查看 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
智能房屋区域与家具布局器 V3
================================
运行:
python smart_house_layout_v3.py
仅使用 Python 标准库 Tkinter,无需安装第三方模块。
V3 主要功能:
1. 手动或自动规划房间区域
2. 选中区域后直接修改位置和尺寸,拖动角点缩放
3. 重叠区域自动尝试挤到旁边
4. 增加、删除门窗,可设置所在墙、位置和宽度
5. 一键隐藏/显示全部家具
6. 选中家具后直接修改尺寸、位置和角度
7. 拖动家具时若与其他家具重叠,自动把其他家具挤开
8. 一键自动分散家具,尽量保证不重叠
9. 推荐家具尺寸根据所属区域自动缩放,并统一尝试放入
10. 家具带矢量俯视图标和连续旋转手柄
说明:
自动布局是启发式参考方案,不能替代建筑、消防、水电和施工设计。
"""
from __future__ import annotations
import json
import math
import random
import tkinter as tk
from dataclasses import asdict, dataclass
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Optional
@dataclass
class Region:
id: int
kind: str
name: str
x: float
y: float
width: float
depth: float
color: str
@property
def area(self) -> float:
return self.width * self.depth
@dataclass
class Furniture:
id: int
kind: str
name: str
width: float
depth: float
color: str
x: float = 0.0
y: float = 0.0
rotation: float = 0.0
region_id: Optional[int] = None
wall_preference: float = 0.0
center_preference: float = 0.0
@dataclass
class Opening:
id: int
kind: str # door / window
wall: str # top / bottom / left / right
offset: float # 从墙起点开始的距离
width: float
name: str
class PlannerV3(tk.Tk):
REGION_TYPES = {
"主卧": ("master", "#f7cad0", 1.45),
"次卧": ("secondary", "#d8e2dc", 1.08),
"客厅": ("living", "#cde7f0", 1.70),
"厨房": ("kitchen", "#ffe5b4", 0.82),
"卫生间": ("bathroom", "#cddafd", 0.55),
"餐厅": ("dining", "#faedcd", 0.85),
"书房": ("study", "#d9ed92", 0.72),
"阳台": ("balcony", "#bde0fe", 0.45),
"储物间": ("storage", "#dedbd2", 0.40),
"其他": ("other", "#e9ecef", 0.65),
}
FURNITURE_PRESETS = {
"沙发": ("sofa", 2.20, 0.90, "#78b159", 1.2, 0.2),
"双人床": ("double_bed", 2.00, 1.80, "#e9c46a", 2.5, 0.0),
"单人床": ("single_bed", 2.00, 1.00, "#f4a261", 2.3, 0.0),
"餐桌": ("dining_table", 1.60, 0.90, "#d98c4a", 0.0, 1.8),
"书桌": ("desk", 1.20, 0.60, "#2a9d8f", 1.7, 0.0),
"衣柜": ("wardrobe", 1.80, 0.60, "#64748b", 2.9, 0.0),
"电视柜": ("tv", 1.60, 0.45, "#277da1", 3.1, 0.0),
"茶几": ("coffee_table", 1.20, 0.60, "#a66a3f", 0.0, 1.2),
"冰箱": ("fridge", 0.75, 0.75, "#adb5bd", 2.5, 0.0),
"灶台": ("stove", 1.20, 0.60, "#6c757d", 3.0, 0.0),
"洗衣机": ("washer", 0.65, 0.65, "#b8c0c8", 2.4, 0.0),
"马桶": ("toilet", 0.70, 0.42, "#d7e3fc", 2.2, 0.0),
"淋浴区": ("shower", 0.90, 0.90, "#90caf9", 2.0, 0.0),
"自定义家具": ("custom", 1.00, 1.00, "#8ecae6", 0.5, 0.5),
}
REGION_FURNITURE_TEMPLATES = {
"master": ["双人床", "衣柜", "书桌"],
"secondary": ["单人床", "衣柜", "书桌"],
"living": ["沙发", "电视柜", "茶几"],
"kitchen": ["冰箱", "灶台", "洗衣机"],
"bathroom": ["马桶", "淋浴区", "洗衣机"],
"dining": ["餐桌"],
"study": ["书桌", "自定义家具"],
"balcony": ["洗衣机"],
"storage": ["衣柜"],
"other": ["自定义家具"],
}
DEFAULT_AUTO_REGIONS = ("主卧", "次卧", "客厅", "厨房", "卫生间", "餐厅")
def __init__(self) -> None:
super().__init__()
self.title("智能房屋区域与家具布局器 V3 - 吾爱破解论坛 [url=www.52pojie.cn]www.52pojie.cn[/url]")
self.geometry("1510x930")
self.minsize(1220, 760)
self.room_width = 10.0
self.room_depth = 8.0
self.scale = 70.0
self.origin_x = 90.0
self.origin_y = 75.0
self.grid_step = 0.10
self.regions: list[Region] = []
self.furniture: list[Furniture] = []
self.openings: list[Opening] = [
Opening(1, "door", "bottom", 0.55, 0.95, "入户门"),
Opening(2, "window", "top", 3.70, 2.60, "窗户"),
]
self.next_region_id = 1
self.next_furniture_id = 1
self.next_opening_id = 3
self.selected_region_id: Optional[int] = None
self.selected_furniture_id: Optional[int] = None
self.selected_opening_id: Optional[int] = None
self.furniture_hidden = False
self.region_draw_mode = False
self.interaction_mode: Optional[str] = None
self.drag_start_world = (0.0, 0.0)
self.drag_offset = (0.0, 0.0)
self.drag_snapshot = None
self.preview_region = None
self.solution_number = 0
self.status_var = tk.StringVar(value="可先自动划分区域,再生成自适应家具")
self.score_var = tk.StringVar(value="布局状态:—")
self.build_ui()
self.bind_events()
self.after(120, self.fit_view)
# =========================================================
# UI
# =========================================================
def build_ui(self) -> None:
toolbar = ttk.Frame(self, padding=(9, 7))
toolbar.pack(fill=tk.X)
ttk.Label(toolbar, text="房屋宽度/米").pack(side=tk.LEFT)
self.room_width_var = tk.StringVar(value="10.00")
ttk.Entry(toolbar, textvariable=self.room_width_var, width=8).pack(
side=tk.LEFT, padx=(5, 12)
)
ttk.Label(toolbar, text="房屋进深/米").pack(side=tk.LEFT)
self.room_depth_var = tk.StringVar(value="8.00")
ttk.Entry(toolbar, textvariable=self.room_depth_var, width=8).pack(
side=tk.LEFT, padx=(5, 12)
)
ttk.Button(toolbar, text="应用尺寸", command=self.apply_room_size).pack(side=tk.LEFT)
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=9)
ttk.Button(toolbar, text="自动划分区域", command=self.auto_partition_regions).pack(
side=tk.LEFT, padx=3
)
ttk.Button(toolbar, text="生成自适应家具", command=self.generate_adaptive_furniture).pack(
side=tk.LEFT, padx=3
)
ttk.Button(toolbar, text="家具自动分散", command=self.auto_disperse_furniture).pack(
side=tk.LEFT, padx=3
)
ttk.Button(toolbar, text="区域自动分散", command=self.auto_disperse_regions).pack(
side=tk.LEFT, padx=3
)
self.hide_button = ttk.Button(
toolbar, text="隐藏全部家具", command=self.toggle_furniture_visibility
)
self.hide_button.pack(side=tk.LEFT, padx=(12, 3))
ttk.Button(toolbar, text="适合窗口", command=self.fit_view).pack(side=tk.LEFT, padx=3)
ttk.Button(toolbar, text="保存", command=self.save_layout).pack(side=tk.RIGHT, padx=3)
ttk.Button(toolbar, text="读取", command=self.load_layout).pack(side=tk.RIGHT, padx=3)
pane = ttk.Panedwindow(self, orient=tk.HORIZONTAL)
pane.pack(fill=tk.BOTH, expand=True)
canvas_frame = ttk.Frame(pane)
side = ttk.Frame(pane, width=390, padding=10)
pane.add(canvas_frame, weight=5)
pane.add(side, weight=0)
self.canvas = tk.Canvas(
canvas_frame,
bg="#f7f8fa",
highlightthickness=0,
cursor="crosshair",
)
self.canvas.pack(fill=tk.BOTH, expand=True)
notebook = ttk.Notebook(side)
notebook.pack(fill=tk.BOTH, expand=True)
region_tab = ttk.Frame(notebook, padding=10)
furniture_tab = ttk.Frame(notebook, padding=10)
opening_tab = ttk.Frame(notebook, padding=10)
help_tab = ttk.Frame(notebook, padding=10)
notebook.add(region_tab, text="房间区域")
notebook.add(furniture_tab, text="家具")
notebook.add(opening_tab, text="门窗")
notebook.add(help_tab, text="说明")
self.build_region_tab(region_tab)
self.build_furniture_tab(furniture_tab)
self.build_opening_tab(opening_tab)
self.build_help_tab(help_tab)
bottom = ttk.Frame(self, padding=(10, 5))
bottom.pack(fill=tk.X)
ttk.Label(bottom, textvariable=self.status_var).pack(side=tk.LEFT)
ttk.Label(bottom, textvariable=self.score_var).pack(side=tk.RIGHT)
def build_region_tab(self, parent: ttk.Frame) -> None:
ttk.Label(parent, text="区域规划", font=("TkDefaultFont", 12, "bold")).pack(anchor=tk.W)
draw_box = ttk.LabelFrame(parent, text="创建区域", padding=9)
draw_box.pack(fill=tk.X, pady=(8, 8))
self.region_type_var = tk.StringVar(value="客厅")
ttk.Combobox(
draw_box,
textvariable=self.region_type_var,
values=list(self.REGION_TYPES),
state="readonly",
).pack(fill=tk.X)
self.draw_region_button = ttk.Button(
draw_box, text="开始手动画区域", command=self.toggle_region_draw_mode
)
self.draw_region_button.pack(fill=tk.X, pady=(7, 0))
auto_box = ttk.LabelFrame(parent, text="自动区域类型", padding=9)
auto_box.pack(fill=tk.X, pady=(0, 8))
self.auto_region_vars = {}
check_frame = ttk.Frame(auto_box)
check_frame.pack(fill=tk.X)
for index, name in enumerate(self.REGION_TYPES):
var = tk.BooleanVar(value=name in self.DEFAULT_AUTO_REGIONS)
self.auto_region_vars[name] = var
ttk.Checkbutton(check_frame, text=name, variable=var).grid(
row=index // 2, column=index % 2, sticky=tk.W, padx=(0, 18), pady=2
)
ttk.Button(
auto_box, text="按勾选类型自动划分", command=self.auto_partition_regions
).pack(fill=tk.X, pady=(7, 0))
list_box = ttk.LabelFrame(parent, text="区域清单", padding=8)
list_box.pack(fill=tk.BOTH, expand=True)
self.region_tree = ttk.Treeview(
list_box,
columns=("area", "size"),
show="tree headings",
selectmode="browse",
height=8,
)
self.region_tree.heading("#0", text="区域")
self.region_tree.heading("area", text="面积")
self.region_tree.heading("size", text="尺寸")
self.region_tree.column("#0", width=85)
self.region_tree.column("area", width=65, anchor=tk.CENTER)
self.region_tree.column("size", width=105, anchor=tk.CENTER)
self.region_tree.pack(fill=tk.BOTH, expand=True)
self.region_tree.bind("<<TreeviewSelect>>", self.on_region_tree_select)
inspector = ttk.LabelFrame(parent, text="选中区域直接调节", padding=9)
inspector.pack(fill=tk.X, pady=(8, 0))
self.region_name_var = tk.StringVar()
self.region_x_var = tk.StringVar()
self.region_y_var = tk.StringVar()
self.region_w_var = tk.StringVar()
self.region_d_var = tk.StringVar()
self.add_labeled_entry(inspector, "类型", self.region_name_var, readonly=True)
grid = ttk.Frame(inspector)
grid.pack(fill=tk.X)
self.add_grid_entry(grid, "X", self.region_x_var, 0, 0)
self.add_grid_entry(grid, "Y", self.region_y_var, 0, 1)
self.add_grid_entry(grid, "宽", self.region_w_var, 1, 0)
self.add_grid_entry(grid, "深", self.region_d_var, 1, 1)
ttk.Button(
inspector, text="应用区域位置和尺寸", command=self.apply_region_inspector
).pack(fill=tk.X, pady=(7, 0))
row = ttk.Frame(inspector)
row.pack(fill=tk.X, pady=(6, 0))
ttk.Button(row, text="改变区域类型", command=self.apply_region_type).pack(
side=tk.LEFT, expand=True, fill=tk.X
)
ttk.Button(row, text="删除区域", command=self.delete_selected_region).pack(
side=tk.LEFT, expand=True, fill=tk.X, padx=(6, 0)
)
def build_furniture_tab(self, parent: ttk.Frame) -> None:
ttk.Label(parent, text="家具布局", font=("TkDefaultFont", 12, "bold")).pack(anchor=tk.W)
add_box = ttk.LabelFrame(parent, text="添加家具", padding=9)
add_box.pack(fill=tk.X, pady=(8, 8))
self.furniture_preset_var = tk.StringVar(value="沙发")
ttk.Combobox(
add_box,
textvariable=self.furniture_preset_var,
values=list(self.FURNITURE_PRESETS),
state="readonly",
).pack(fill=tk.X)
row = ttk.Frame(add_box)
row.pack(fill=tk.X, pady=(7, 0))
ttk.Label(row, text="数量").pack(side=tk.LEFT)
self.quantity_var = tk.IntVar(value=1)
ttk.Spinbox(row, from_=1, to=10, textvariable=self.quantity_var, width=7).pack(
side=tk.LEFT, padx=8
)
ttk.Button(row, text="添加", command=self.add_furniture).pack(side=tk.RIGHT)
list_box = ttk.LabelFrame(parent, text="家具清单", padding=8)
list_box.pack(fill=tk.BOTH, expand=True)
self.furniture_tree = ttk.Treeview(
list_box,
columns=("size", "angle", "region"),
show="tree headings",
selectmode="browse",
height=8,
)
self.furniture_tree.heading("#0", text="家具")
self.furniture_tree.heading("size", text="尺寸")
self.furniture_tree.heading("angle", text="角度")
self.furniture_tree.heading("region", text="区域")
self.furniture_tree.column("#0", width=75)
self.furniture_tree.column("size", width=90, anchor=tk.CENTER)
self.furniture_tree.column("angle", width=52, anchor=tk.CENTER)
self.furniture_tree.column("region", width=70, anchor=tk.CENTER)
self.furniture_tree.pack(fill=tk.BOTH, expand=True)
self.furniture_tree.bind("<<TreeviewSelect>>", self.on_furniture_tree_select)
inspector = ttk.LabelFrame(parent, text="选中家具直接调节", padding=9)
inspector.pack(fill=tk.X, pady=(8, 0))
self.furniture_name_var = tk.StringVar()
self.furniture_x_var = tk.StringVar()
self.furniture_y_var = tk.StringVar()
self.furniture_w_var = tk.StringVar()
self.furniture_d_var = tk.StringVar()
self.furniture_angle_var = tk.StringVar()
self.add_labeled_entry(inspector, "名称", self.furniture_name_var)
grid = ttk.Frame(inspector)
grid.pack(fill=tk.X)
self.add_grid_entry(grid, "X", self.furniture_x_var, 0, 0)
self.add_grid_entry(grid, "Y", self.furniture_y_var, 0, 1)
self.add_grid_entry(grid, "宽", self.furniture_w_var, 1, 0)
self.add_grid_entry(grid, "深", self.furniture_d_var, 1, 1)
self.add_grid_entry(grid, "角度", self.furniture_angle_var, 2, 0)
ttk.Button(
inspector, text="应用家具参数并自动挤开", command=self.apply_furniture_inspector
).pack(fill=tk.X, pady=(7, 0))
row2 = ttk.Frame(inspector)
row2.pack(fill=tk.X, pady=(6, 0))
ttk.Button(row2, text="旋转15°", command=lambda: self.rotate_selected_furniture(15)).pack(
side=tk.LEFT, expand=True, fill=tk.X
)
ttk.Button(row2, text="删除家具", command=self.delete_selected_furniture).pack(
side=tk.LEFT, expand=True, fill=tk.X, padx=(6, 0)
)
ttk.Button(
parent, text="生成各区域自适应家具", command=self.generate_adaptive_furniture
).pack(fill=tk.X, pady=(8, 0))
ttk.Button(
parent, text="一键自动分散全部家具", command=self.auto_disperse_furniture
).pack(fill=tk.X, pady=(6, 0))
ttk.Button(
parent, text="清空全部家具", command=self.clear_furniture
).pack(fill=tk.X, pady=(6, 0))
def build_opening_tab(self, parent: ttk.Frame) -> None:
ttk.Label(parent, text="门窗管理", font=("TkDefaultFont", 12, "bold")).pack(anchor=tk.W)
form = ttk.LabelFrame(parent, text="新增门或窗", padding=10)
form.pack(fill=tk.X, pady=(8, 10))
self.opening_kind_var = tk.StringVar(value="门")
self.opening_wall_var = tk.StringVar(value="下墙")
self.opening_offset_var = tk.StringVar(value="0.50")
self.opening_width_var = tk.StringVar(value="0.90")
ttk.Label(form, text="类型").pack(anchor=tk.W)
ttk.Combobox(
form,
textvariable=self.opening_kind_var,
values=["门", "窗"],
state="readonly",
).pack(fill=tk.X)
ttk.Label(form, text="所在墙").pack(anchor=tk.W, pady=(6, 0))
ttk.Combobox(
form,
textvariable=self.opening_wall_var,
values=["上墙", "下墙", "左墙", "右墙"],
state="readonly",
).pack(fill=tk.X)
grid = ttk.Frame(form)
grid.pack(fill=tk.X, pady=(7, 0))
self.add_grid_entry(grid, "距墙起点", self.opening_offset_var, 0, 0)
self.add_grid_entry(grid, "宽度", self.opening_width_var, 0, 1)
ttk.Button(form, text="添加门窗", command=self.add_opening).pack(fill=tk.X, pady=(8, 0))
list_box = ttk.LabelFrame(parent, text="门窗清单", padding=8)
list_box.pack(fill=tk.BOTH, expand=True)
self.opening_tree = ttk.Treeview(
list_box,
columns=("wall", "offset", "width"),
show="tree headings",
selectmode="browse",
height=12,
)
self.opening_tree.heading("#0", text="名称")
self.opening_tree.heading("wall", text="墙")
self.opening_tree.heading("offset", text="位置")
self.opening_tree.heading("width", text="宽度")
self.opening_tree.column("#0", width=80)
self.opening_tree.column("wall", width=55, anchor=tk.CENTER)
self.opening_tree.column("offset", width=65, anchor=tk.CENTER)
self.opening_tree.column("width", width=65, anchor=tk.CENTER)
self.opening_tree.pack(fill=tk.BOTH, expand=True)
self.opening_tree.bind("<<TreeviewSelect>>", self.on_opening_tree_select)
ttk.Button(
list_box, text="删除选中门窗", command=self.delete_selected_opening
).pack(fill=tk.X, pady=(8, 0))
def build_help_tab(self, parent: ttk.Frame) -> None:
text = (
"V3 操作说明\n\n"
"区域:\n"
"• 自动划分或手动画区域。\n"
"• 点击区域后可直接修改 X、Y、宽、深。\n"
"• 拖动区域可移动,拖动四角可缩放。\n"
"• 区域重叠时会尝试把另一个区域挤开。\n\n"
"家具:\n"
"• 选中后可直接修改位置、尺寸和角度。\n"
"• 拖动家具发生重叠时,会把另一个家具推到旁边。\n"
"• 家具上方圆点是连续旋转手柄。\n"
"• 滚轮旋转选中家具,Ctrl+滚轮缩放画布。\n"
"• 可一键隐藏或显示全部家具。\n\n"
"门窗:\n"
"• 可选择门/窗、所在墙、距墙起点和宽度。\n"
"• 门附近会保留通行安全区。\n\n"
"自适应家具:\n"
"• 程序按区域类型生成家具。\n"
"• 会根据区域面积和边长缩放家具。\n"
"• 如果仍放不下,会继续缩小后重新尝试。\n"
)
ttk.Label(parent, text=text, justify=tk.LEFT, wraplength=330).pack(anchor=tk.NW)
@staticmethod
def add_labeled_entry(parent, label, variable, readonly=False) -> None:
ttk.Label(parent, text=label).pack(anchor=tk.W)
entry = ttk.Entry(parent, textvariable=variable)
if readonly:
entry.configure(state="readonly")
entry.pack(fill=tk.X, pady=(2, 5))
@staticmethod
def add_grid_entry(parent, label, variable, row, column) -> None:
frame = ttk.Frame(parent)
frame.grid(row=row, column=column, sticky=tk.EW, padx=3, pady=3)
parent.columnconfigure(column, weight=1)
ttk.Label(frame, text=label).pack(anchor=tk.W)
ttk.Entry(frame, textvariable=variable, width=10).pack(fill=tk.X)
def bind_events(self) -> None:
self.canvas.bind("<Configure>", lambda _event: self.redraw())
self.canvas.bind("<ButtonPress-1>", self.on_canvas_press)
self.canvas.bind("<B1-Motion>", self.on_canvas_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_canvas_release)
self.canvas.bind("<Double-Button-1>", self.on_canvas_double_click)
self.canvas.bind("<MouseWheel>", self.on_mousewheel)
self.canvas.bind("<Button-4>", lambda event: self.on_linux_wheel(event, 1))
self.canvas.bind("<Button-5>", lambda event: self.on_linux_wheel(event, -1))
self.bind("<r>", lambda _event: self.rotate_selected_furniture(15))
self.bind("<R>", lambda _event: self.rotate_selected_furniture(-15))
self.bind("<Delete>", lambda _event: self.delete_current_selection())
self.bind("<Escape>", lambda _event: self.cancel_current_mode())
# =========================================================
# 房屋与区域
# =========================================================
def apply_room_size(self) -> None:
try:
new_w = float(self.room_width_var.get())
new_d = float(self.room_depth_var.get())
if new_w < 2.0 or new_d < 2.0:
raise ValueError
except ValueError:
messagebox.showerror("输入错误", "房屋宽度和进深必须是不小于2米的数字。")
return
old_w, old_d = self.room_width, self.room_depth
sx, sy = new_w / old_w, new_d / old_d
self.room_width, self.room_depth = new_w, new_d
for region in self.regions:
region.x *= sx
region.y *= sy
region.width *= sx
region.depth *= sy
for item in self.furniture:
item.x *= sx
item.y *= sy
for opening in self.openings:
wall_length_old = old_w if opening.wall in ("top", "bottom") else old_d
wall_length_new = new_w if opening.wall in ("top", "bottom") else new_d
ratio = opening.offset / wall_length_old if wall_length_old else 0
opening.offset = ratio * wall_length_new
opening.width = min(opening.width, wall_length_new - opening.offset)
self.auto_disperse_regions(show_message=False)
self.auto_disperse_furniture(show_message=False)
self.refresh_all()
self.fit_view()
self.status_var.set("房屋尺寸已更新,区域和家具已重新整理")
def toggle_region_draw_mode(self) -> None:
self.region_draw_mode = not self.region_draw_mode
self.draw_region_button.configure(
text="取消手动画区域" if self.region_draw_mode else "开始手动画区域"
)
self.interaction_mode = None
self.preview_region = None
self.status_var.set(
"请在房屋内按住鼠标拖出区域"
if self.region_draw_mode
else "已退出区域绘制"
)
self.redraw()
def auto_partition_regions(self) -> None:
selected = [name for name, var in self.auto_region_vars.items() if var.get()]
if not selected:
messagebox.showinfo("提示", "请至少勾选一种区域类型。")
return
specs = [
(name, *self.REGION_TYPES[name])
for name in selected
]
priority = {
"客厅": 0, "餐厅": 1, "厨房": 2,
"主卧": 3, "次卧": 4, "书房": 5,
"卫生间": 6, "阳台": 7, "储物间": 8, "其他": 9,
}
specs.sort(key=lambda item: priority.get(item[0], 99))
rects = self.weighted_partition(0, 0, self.room_width, self.room_depth, specs)
self.regions.clear()
self.next_region_id = 1
for name, kind, color, x, y, width, depth in rects:
self.regions.append(
Region(self.next_region_id, kind, name, x, y, width, depth, color)
)
self.next_region_id += 1
for item in self.furniture:
item.region_id = None
self.selected_region_id = None
self.refresh_all()
self.redraw()
self.status_var.set("区域已自动划分")
def weighted_partition(self, x, y, width, depth, specs):
if len(specs) == 1:
name, kind, color, _weight = specs[0]
return [(name, kind, color, x, y, width, depth)]
total = sum(item[3] for item in specs)
target = total / 2
running = 0
split = 1
for index, item in enumerate(specs[:-1], start=1):
running += item[3]
split = index
if running >= target:
break
first, second = specs[:split], specs[split:]
ratio = sum(item[3] for item in first) / total
result = []
if width >= depth:
w1 = width * ratio
result += self.weighted_partition(x, y, w1, depth, first)
result += self.weighted_partition(x + w1, y, width - w1, depth, second)
else:
d1 = depth * ratio
result += self.weighted_partition(x, y, width, d1, first)
result += self.weighted_partition(x, y + d1, width, depth - d1, second)
return result
def get_selected_region(self) -> Optional[Region]:
return next(
(region for region in self.regions if region.id == self.selected_region_id),
None,
)
def on_region_tree_select(self, _event=None) -> None:
selection = self.region_tree.selection()
if not selection:
return
self.selected_region_id = int(selection[0])
self.selected_furniture_id = None
self.selected_opening_id = None
self.update_region_inspector()
self.refresh_furniture_tree()
self.redraw()
def update_region_inspector(self) -> None:
region = self.get_selected_region()
if region is None:
for var in (
self.region_name_var,
self.region_x_var,
self.region_y_var,
self.region_w_var,
self.region_d_var,
):
var.set("")
return
self.region_name_var.set(region.name)
self.region_x_var.set(f"{region.x:.2f}")
self.region_y_var.set(f"{region.y:.2f}")
self.region_w_var.set(f"{region.width:.2f}")
self.region_d_var.set(f"{region.depth:.2f}")
self.region_type_var.set(region.name)
def apply_region_type(self) -> None:
region = self.get_selected_region()
if region is None:
return
name = self.region_type_var.get()
kind, color, _weight = self.REGION_TYPES[name]
region.name = name
region.kind = kind
region.color = color
self.refresh_all()
self.redraw()
def apply_region_inspector(self) -> None:
region = self.get_selected_region()
if region is None:
messagebox.showinfo("提示", "请先选择一个区域。")
return
try:
new_x = float(self.region_x_var.get())
new_y = float(self.region_y_var.get())
new_w = float(self.region_w_var.get())
new_d = float(self.region_d_var.get())
if new_w < 0.5 or new_d < 0.5:
raise ValueError
except ValueError:
messagebox.showerror("输入错误", "区域坐标应为数字,宽和深至少为0.5米。")
return
old = (region.x, region.y, region.width, region.depth)
region.x, region.y, region.width, region.depth = new_x, new_y, new_w, new_d
self.clamp_region(region)
# 尺寸变化后,所属家具会按比例移动,并在必要时缩小。
old_x, old_y, old_w, old_d = old
for item in self.furniture:
if item.region_id == region.id:
rx = (item.x - old_x) / old_w if old_w else 0.5
ry = (item.y - old_y) / old_d if old_d else 0.5
item.x = region.x + rx * region.width
item.y = region.y + ry * region.depth
self.scale_furniture_to_fit_region(item, region)
if not self.push_overlapping_regions(region):
region.x, region.y, region.width, region.depth = old
messagebox.showwarning("无法调整", "区域调整后没有足够空间安置其他区域。")
return
self.auto_layout_single_region(region)
self.refresh_all()
self.redraw()
self.status_var.set("区域尺寸已更新,重叠区域已自动挪开")
def delete_selected_region(self) -> None:
if self.selected_region_id is None:
return
rid = self.selected_region_id
self.regions = [region for region in self.regions if region.id != rid]
for item in self.furniture:
if item.region_id == rid:
item.region_id = None
self.selected_region_id = None
self.refresh_all()
self.redraw()
def auto_disperse_regions(self, show_message=True) -> bool:
placed: list[Region] = []
for region in sorted(self.regions, key=lambda r: r.area, reverse=True):
self.clamp_region(region)
if any(self.regions_overlap(region, other) for other in placed):
position = self.find_free_region_position(region, placed)
if position is None:
scale = 0.94
success = False
for _ in range(12):
region.width = max(0.5, region.width * scale)
region.depth = max(0.5, region.depth * scale)
position = self.find_free_region_position(region, placed)
if position is not None:
success = True
break
if not success:
if show_message:
messagebox.showwarning("区域分散失败", "房屋空间不足,部分区域无法完全分开。")
return False
region.x, region.y = position
placed.append(region)
# 将区域内家具跟随区域重新布局。
for region in self.regions:
self.auto_layout_single_region(region)
self.refresh_all()
self.redraw()
if show_message:
self.status_var.set("区域已自动分散")
return True
def find_free_region_position(
self, region: Region, obstacles: list[Region]
) -> Optional[tuple[float, float]]:
step = 0.15
max_x = self.room_width - region.width
max_y = self.room_depth - region.depth
if max_x < 0 or max_y < 0:
return None
candidates = []
y = 0.0
while y <= max_y + 1e-8:
x = 0.0
while x <= max_x + 1e-8:
candidates.append((x, y))
x += step
y += step
candidates.sort(
key=lambda point: math.hypot(point[0] - region.x, point[1] - region.y)
)
old_x, old_y = region.x, region.y
for x, y in candidates:
region.x, region.y = x, y
if not any(self.regions_overlap(region, other) for other in obstacles):
return x, y
region.x, region.y = old_x, old_y
return None
def push_overlapping_regions(self, source: Region) -> bool:
# 迭代把与 source 或已移动区域重叠的其他区域推开。
queue = [source]
processed = 0
while queue and processed < 100:
current = queue.pop(0)
processed += 1
for other in self.regions:
if other.id == current.id or not self.regions_overlap(current, other):
continue
old_state = (other.x, other.y)
dx_left = (current.x + current.width) - other.x
dx_right = (other.x + other.width) - current.x
dy_top = (current.y + current.depth) - other.y
dy_bottom = (other.y + other.depth) - current.y
moves = [
(dx_left + 0.05, 0),
(-dx_right - 0.05, 0),
(0, dy_top + 0.05),
(0, -dy_bottom - 0.05),
]
moves.sort(key=lambda move: abs(move[0]) + abs(move[1]))
moved = False
assigned = [item for item in self.furniture if item.region_id == other.id]
assigned_old = [(item, item.x, item.y) for item in assigned]
for move_x, move_y in moves:
other.x = old_state[0] + move_x
other.y = old_state[1] + move_y
self.clamp_region(other)
actual_dx = other.x - old_state[0]
actual_dy = other.y - old_state[1]
for item, ix, iy in assigned_old:
item.x = ix + actual_dx
item.y = iy + actual_dy
if (
not self.regions_overlap(current, other)
and self.region_inside_room(other)
and all(self.furniture_inside_region(item, other) for item in assigned)
):
moved = True
queue.append(other)
break
other.x, other.y = old_state
for item, ix, iy in assigned_old:
item.x, item.y = ix, iy
if not moved:
free = self.find_free_region_position(
other, [r for r in self.regions if r.id != other.id]
)
if free is None:
return False
dx = free[0] - old_state[0]
dy = free[1] - old_state[1]
other.x, other.y = free
for item, ix, iy in assigned_old:
item.x, item.y = ix + dx, iy + dy
queue.append(other)
return True
# =========================================================
# 家具
# =========================================================
def get_selected_furniture(self) -> Optional[Furniture]:
return next(
(item for item in self.furniture if item.id == self.selected_furniture_id),
None,
)
def add_furniture(self) -> None:
name = self.furniture_preset_var.get()
kind, width, depth, color, wall, center = self.FURNITURE_PRESETS[name]
try:
quantity = max(1, min(10, int(self.quantity_var.get())))
except Exception:
quantity = 1
target_region = self.get_selected_region()
for index in range(quantity):
item = Furniture(
self.next_furniture_id,
kind,
name,
width,
depth,
color,
x=(target_region.x + target_region.width / 2 if target_region else self.room_width / 2),
y=(target_region.y + target_region.depth / 2 if target_region else self.room_depth / 2),
rotation=0,
region_id=target_region.id if target_region else None,
wall_preference=wall,
center_preference=center,
)
if target_region:
self.scale_furniture_to_fit_region(item, target_region)
self.furniture.append(item)
self.next_furniture_id += 1
self.auto_disperse_furniture(show_message=False)
self.refresh_all()
self.redraw()
self.status_var.set(f"已添加 {quantity} 件{name}")
def on_furniture_tree_select(self, _event=None) -> None:
selection = self.furniture_tree.selection()
if not selection:
return
self.selected_furniture_id = int(selection[0])
self.selected_region_id = None
self.selected_opening_id = None
self.update_furniture_inspector()
self.refresh_region_tree()
self.redraw()
def update_furniture_inspector(self) -> None:
item = self.get_selected_furniture()
if item is None:
for var in (
self.furniture_name_var,
self.furniture_x_var,
self.furniture_y_var,
self.furniture_w_var,
self.furniture_d_var,
self.furniture_angle_var,
):
var.set("")
return
self.furniture_name_var.set(item.name)
self.furniture_x_var.set(f"{item.x:.2f}")
self.furniture_y_var.set(f"{item.y:.2f}")
self.furniture_w_var.set(f"{item.width:.2f}")
self.furniture_d_var.set(f"{item.depth:.2f}")
self.furniture_angle_var.set(f"{item.rotation:.1f}")
def apply_furniture_inspector(self) -> None:
item = self.get_selected_furniture()
if item is None:
messagebox.showinfo("提示", "请先选择家具。")
return
try:
x = float(self.furniture_x_var.get())
y = float(self.furniture_y_var.get())
width = float(self.furniture_w_var.get())
depth = float(self.furniture_d_var.get())
angle = float(self.furniture_angle_var.get())
if width < 0.15 or depth < 0.15:
raise ValueError
except ValueError:
messagebox.showerror("输入错误", "坐标、尺寸和角度必须为数字,尺寸至少0.15米。")
return
snapshot = (item.name, item.x, item.y, item.width, item.depth, item.rotation, item.region_id)
item.name = self.furniture_name_var.get().strip() or item.name
item.x, item.y = x, y
item.width, item.depth = width, depth
item.rotation = angle % 360
self.update_item_region(item)
if not self.furniture_inside_room(item) or self.item_intersects_any_door_zone(item):
(
item.name, item.x, item.y, item.width, item.depth,
item.rotation, item.region_id
) = snapshot
messagebox.showwarning("无法应用", "家具会超出房屋或进入门口安全区。")
return
if not self.push_overlapping_furniture(item):
(
item.name, item.x, item.y, item.width, item.depth,
item.rotation, item.region_id
) = snapshot
messagebox.showwarning("无法应用", "没有足够空间把重叠家具挤开。")
return
self.update_item_region(item)
self.refresh_all()
self.redraw()
self.status_var.set("家具参数已更新,重叠家具已自动挪开")
def rotate_selected_furniture(self, delta: float) -> None:
item = self.get_selected_furniture()
if item is None:
return
old = item.rotation
item.rotation = (item.rotation + delta) % 360
if (
not self.furniture_inside_room(item)
or self.item_intersects_any_door_zone(item)
or not self.push_overlapping_furniture(item)
):
item.rotation = old
self.status_var.set("该方向空间不足,已取消旋转")
self.update_furniture_inspector()
self.refresh_furniture_tree()
self.redraw()
def delete_selected_furniture(self) -> None:
if self.selected_furniture_id is None:
return
self.furniture = [
item for item in self.furniture if item.id != self.selected_furniture_id
]
self.selected_furniture_id = None
self.refresh_all()
self.redraw()
def clear_furniture(self) -> None:
if self.furniture and not messagebox.askyesno("清空家具", "确定删除全部家具吗?"):
return
self.furniture.clear()
self.selected_furniture_id = None
self.refresh_all()
self.redraw()
def toggle_furniture_visibility(self) -> None:
self.furniture_hidden = not self.furniture_hidden
self.hide_button.configure(
text="显示全部家具" if self.furniture_hidden else "隐藏全部家具"
)
self.redraw()
self.status_var.set(
"家具已隐藏" if self.furniture_hidden else "家具已显示"
)
def generate_adaptive_furniture(self) -> None:
if not self.regions:
messagebox.showinfo("提示", "请先划分房间区域。")
return
self.furniture.clear()
self.next_furniture_id = 1
for region in self.regions:
names = self.REGION_FURNITURE_TEMPLATES.get(region.kind, ["自定义家具"])
items = []
for name in names:
kind, width, depth, color, wall, center = self.FURNITURE_PRESETS[name]
items.append(
Furniture(
self.next_furniture_id,
kind,
name,
width,
depth,
color,
x=region.x + region.width / 2,
y=region.y + region.depth / 2,
rotation=0,
region_id=region.id,
wall_preference=wall,
center_preference=center,
)
)
self.next_furniture_id += 1
# 根据区域面积和最短边计算初始比例。
base_area = sum(item.width * item.depth for item in items)
area_scale = math.sqrt(max(region.area * 0.42 / max(base_area, 0.01), 0.10))
side_scale = min(
region.width / max(max(item.width for item in items), 0.01),
region.depth / max(max(item.depth for item in items), 0.01),
) * 0.82
scale = min(1.15, area_scale, side_scale)
scale = max(0.42, scale)
for item in items:
item.width *= scale
item.depth *= scale
# 如果一次放不下,就逐步缩小,直到所有需要家具都放进去。
success = False
for _ in range(18):
if self.place_items_in_region(items, region):
success = True
break
for item in items:
item.width *= 0.92
item.depth *= 0.92
if not success:
# 最终保护:使用更小的最简尺寸再尝试。
for item in items:
item.width = max(0.25, item.width * 0.75)
item.depth = max(0.25, item.depth * 0.75)
self.place_items_in_region(items, region, dense=True)
self.furniture.extend(items)
self.selected_furniture_id = None
self.refresh_all()
self.redraw()
self.status_var.set("已按区域大小生成并放入自适应家具")
self.score_var.set("布局状态:推荐家具已全部生成")
def place_items_in_region(
self, items: list[Furniture], region: Region, dense=False
) -> bool:
placed: list[Furniture] = []
ordered = sorted(items, key=lambda item: item.width * item.depth, reverse=True)
for item in ordered:
best = None
best_score = float("inf")
angles = [0, 90]
step = 0.10 if dense else 0.16
for angle in angles:
item.rotation = angle
min_x = region.x + 0.05
max_x = region.x + region.width - 0.05
min_y = region.y + 0.05
max_y = region.y + region.depth - 0.05
y = min_y
while y <= max_y + 1e-8:
x = min_x
while x <= max_x + 1e-8:
item.x, item.y = x, y
if (
self.furniture_inside_region(item, region)
and not self.item_intersects_any_door_zone(item)
and not any(
self.oriented_rectangles_overlap(item, other, 0.05)
for other in placed
)
):
wall_distance = self.distance_to_region_wall(item, region)
center_distance = math.hypot(
item.x - (region.x + region.width / 2),
item.y - (region.y + region.depth / 2),
)
score = (
item.wall_preference * wall_distance * 8
+ item.center_preference * center_distance * 4
)
if score < best_score:
best_score = score
best = (x, y, angle)
x += step
y += step
if best is None:
return False
item.x, item.y, item.rotation = best
placed.append(item)
return True
def auto_disperse_furniture(self, show_message=True) -> bool:
if not self.furniture:
return True
groups: dict[Optional[int], list[Furniture]] = {}
for item in self.furniture:
groups.setdefault(item.region_id, []).append(item)
all_success = True
for region_id, items in groups.items():
region = next((r for r in self.regions if r.id == region_id), None)
if region is None:
region = Region(
-1, "room", "全屋", 0, 0,
self.room_width, self.room_depth, "#ffffff"
)
# 先按当前尺寸摆放;放不下时逐步缩小。
success = False
for _ in range(15):
if self.place_items_in_region(items, region):
success = True
break
for item in items:
item.width = max(0.20, item.width * 0.95)
item.depth = max(0.20, item.depth * 0.95)
if not success:
all_success = False
self.refresh_all()
self.redraw()
if show_message:
self.status_var.set(
"家具已自动分散"
if all_success
else "部分家具已缩小并分散,但空间仍较紧张"
)
return all_success
def push_overlapping_furniture(self, source: Furniture) -> bool:
queue = [source]
processed = 0
while queue and processed < 150:
current = queue.pop(0)
processed += 1
for other in self.furniture:
if other.id == current.id:
continue
if not self.oriented_rectangles_overlap(current, other, 0.03):
continue
old = (other.x, other.y, other.rotation, other.region_id)
dx = other.x - current.x
dy = other.y - current.y
if abs(dx) + abs(dy) < 1e-7:
angle = random.random() * math.tau
dx, dy = math.cos(angle), math.sin(angle)
length = math.hypot(dx, dy)
ux, uy = dx / length, dy / length
moved = False
directions = [
(ux, uy),
(-uy, ux),
(uy, -ux),
(-ux, -uy),
]
for direction_x, direction_y in directions:
other.x, other.y, other.rotation, other.region_id = old
for _ in range(80):
other.x += direction_x * 0.06
other.y += direction_y * 0.06
self.clamp_furniture_to_target(other)
if (
not self.oriented_rectangles_overlap(current, other, 0.03)
and self.furniture_inside_room(other)
and not self.item_intersects_any_door_zone(other)
and not any(
third.id not in (current.id, other.id)
and self.oriented_rectangles_overlap(other, third, 0.03)
for third in self.furniture
)
):
moved = True
self.update_item_region(other)
queue.append(other)
break
if moved:
break
if not moved:
other.x, other.y, other.rotation, other.region_id = old
target_region = next(
(r for r in self.regions if r.id == other.region_id), None
)
free = self.find_free_furniture_position(other, target_region)
if free is None:
return False
other.x, other.y, other.rotation = free
queue.append(other)
return True
def find_free_furniture_position(
self, item: Furniture, region: Optional[Region]
) -> Optional[tuple[float, float, float]]:
target = region or Region(
-1, "room", "全屋", 0, 0, self.room_width, self.room_depth, "#ffffff"
)
step = 0.12
candidates = []
y = target.y + 0.05
while y <= target.y + target.depth - 0.05:
x = target.x + 0.05
while x <= target.x + target.width - 0.05:
candidates.append((x, y))
x += step
y += step
candidates.sort(key=lambda p: math.hypot(p[0] - item.x, p[1] - item.y))
old = (item.x, item.y, item.rotation)
for angle in (item.rotation, 0, 90, 180, 270):
item.rotation = angle
for x, y in candidates:
item.x, item.y = x, y
if (
self.furniture_inside_region(item, target)
and not self.item_intersects_any_door_zone(item)
and not any(
other.id != item.id
and self.oriented_rectangles_overlap(item, other, 0.03)
for other in self.furniture
)
):
return x, y, angle
item.x, item.y, item.rotation = old
return None
def auto_layout_single_region(self, region: Region) -> None:
items = [item for item in self.furniture if item.region_id == region.id]
if not items:
return
for item in items:
self.scale_furniture_to_fit_region(item, region)
for _ in range(12):
if self.place_items_in_region(items, region):
return
for item in items:
item.width = max(0.20, item.width * 0.94)
item.depth = max(0.20, item.depth * 0.94)
def scale_furniture_to_fit_region(self, item: Furniture, region: Region) -> None:
max_w = max(region.width * 0.82, 0.20)
max_d = max(region.depth * 0.82, 0.20)
factor = min(1.0, max_w / item.width, max_d / item.depth)
item.width *= factor
item.depth *= factor
item.x = min(max(item.x, region.x), region.x + region.width)
item.y = min(max(item.y, region.y), region.y + region.depth)
def update_item_region(self, item: Furniture) -> None:
containing = [
region for region in self.regions
if self.furniture_inside_region(item, region)
]
item.region_id = min(containing, key=lambda r: r.area).id if containing else None
# =========================================================
# 门窗
# =========================================================
def add_opening(self) -> None:
kind = "door" if self.opening_kind_var.get() == "门" else "window"
wall_map = {"上墙": "top", "下墙": "bottom", "左墙": "left", "右墙": "right"}
wall = wall_map[self.opening_wall_var.get()]
try:
offset = float(self.opening_offset_var.get())
width = float(self.opening_width_var.get())
if offset < 0 or width <= 0.2:
raise ValueError
except ValueError:
messagebox.showerror("输入错误", "位置必须不小于0,宽度必须大于0.2米。")
return
wall_length = self.room_width if wall in ("top", "bottom") else self.room_depth
if offset + width > wall_length:
messagebox.showerror("超出墙体", "门窗位置加宽度不能超过所在墙长度。")
return
same_wall = [o for o in self.openings if o.wall == wall]
for opening in same_wall:
if not (
offset + width <= opening.offset
or opening.offset + opening.width <= offset
):
messagebox.showerror("位置重叠", "该门窗与同一墙上的已有门窗重叠。")
return
name = "门" if kind == "door" else "窗"
self.openings.append(
Opening(self.next_opening_id, kind, wall, offset, width, name)
)
self.next_opening_id += 1
self.refresh_opening_tree()
self.redraw()
self.status_var.set(f"已添加{name}")
def on_opening_tree_select(self, _event=None) -> None:
selection = self.opening_tree.selection()
if selection:
self.selected_opening_id = int(selection[0])
self.selected_region_id = None
self.selected_furniture_id = None
self.redraw()
def delete_selected_opening(self) -> None:
if self.selected_opening_id is None:
return
self.openings = [
opening for opening in self.openings
if opening.id != self.selected_opening_id
]
self.selected_opening_id = None
self.refresh_opening_tree()
self.redraw()
def item_intersects_any_door_zone(self, item: Furniture) -> bool:
for opening in self.openings:
if opening.kind != "door":
continue
polygon = self.door_clearance_polygon(opening)
if self.polygons_overlap(self.furniture_corners(item), polygon):
return True
return False
def door_clearance_polygon(self, opening: Opening):
clearance = max(1.10, opening.width)
if opening.wall == "bottom":
return [
(opening.offset - 0.10, self.room_depth - clearance),
(opening.offset + opening.width + 0.10, self.room_depth - clearance),
(opening.offset + opening.width + 0.10, self.room_depth),
(opening.offset - 0.10, self.room_depth),
]
if opening.wall == "top":
return [
(opening.offset - 0.10, 0),
(opening.offset + opening.width + 0.10, 0),
(opening.offset + opening.width + 0.10, clearance),
(opening.offset - 0.10, clearance),
]
if opening.wall == "left":
return [
(0, opening.offset - 0.10),
(clearance, opening.offset - 0.10),
(clearance, opening.offset + opening.width + 0.10),
(0, opening.offset + opening.width + 0.10),
]
return [
(self.room_width - clearance, opening.offset - 0.10),
(self.room_width, opening.offset - 0.10),
(self.room_width, opening.offset + opening.width + 0.10),
(self.room_width - clearance, opening.offset + opening.width + 0.10),
]
# =========================================================
# 几何
# =========================================================
@staticmethod
def rotate_point(cx, cy, lx, ly, angle):
radians = math.radians(angle)
cosine, sine = math.cos(radians), math.sin(radians)
return (
cx + lx * cosine - ly * sine,
cy + lx * sine + ly * cosine,
)
def furniture_corners(self, item: Furniture, expansion=0.0):
hw = item.width / 2 + expansion
hd = item.depth / 2 + expansion
return [
self.rotate_point(item.x, item.y, lx, ly, item.rotation)
for lx, ly in [(-hw, -hd), (hw, -hd), (hw, hd), (-hw, hd)]
]
@staticmethod
def polygon_axes(polygon):
axes = []
for index, point in enumerate(polygon):
nxt = polygon[(index + 1) % len(polygon)]
ex, ey = nxt[0] - point[0], nxt[1] - point[1]
length = math.hypot(ex, ey)
if length > 1e-9:
axes.append((-ey / length, ex / length))
return axes
@staticmethod
def project_polygon(polygon, axis):
values = [x * axis[0] + y * axis[1] for x, y in polygon]
return min(values), max(values)
def polygons_overlap(self, a, b):
for axis in self.polygon_axes(a) + self.polygon_axes(b):
min_a, max_a = self.project_polygon(a, axis)
min_b, max_b = self.project_polygon(b, axis)
if max_a <= min_b or max_b <= min_a:
return False
return True
def oriented_rectangles_overlap(self, a, b, clearance=0.0):
return self.polygons_overlap(
self.furniture_corners(a, clearance / 2),
self.furniture_corners(b, clearance / 2),
)
def furniture_inside_room(self, item):
return all(
0 <= x <= self.room_width and 0 <= y <= self.room_depth
for x, y in self.furniture_corners(item)
)
def furniture_inside_region(self, item, region):
return all(
region.x <= x <= region.x + region.width
and region.y <= y <= region.y + region.depth
for x, y in self.furniture_corners(item)
)
@staticmethod
def regions_overlap(a, b):
return not (
a.x + a.width <= b.x
or b.x + b.width <= a.x
or a.y + a.depth <= b.y
or b.y + b.depth <= a.y
)
def region_inside_room(self, region):
return (
region.x >= 0
and region.y >= 0
and region.x + region.width <= self.room_width
and region.y + region.depth <= self.room_depth
)
def clamp_region(self, region):
region.width = min(max(region.width, 0.5), self.room_width)
region.depth = min(max(region.depth, 0.5), self.room_depth)
region.x = min(max(region.x, 0), self.room_width - region.width)
region.y = min(max(region.y, 0), self.room_depth - region.depth)
def clamp_furniture_to_target(self, item):
region = next((r for r in self.regions if r.id == item.region_id), None)
target = region or Region(
-1, "room", "全屋", 0, 0, self.room_width, self.room_depth, "#fff"
)
radius = math.hypot(item.width / 2, item.depth / 2)
item.x = min(max(item.x, target.x + radius), target.x + target.width - radius)
item.y = min(max(item.y, target.y + radius), target.y + target.depth - radius)
def distance_to_region_wall(self, item, region):
corners = self.furniture_corners(item)
return min(
min(x for x, _ in corners) - region.x,
region.x + region.width - max(x for x, _ in corners),
min(y for _, y in corners) - region.y,
region.y + region.depth - max(y for _, y in corners),
)
# =========================================================
# 画布交互
# =========================================================
def world_to_canvas(self, x, y):
return self.origin_x + x * self.scale, self.origin_y + y * self.scale
def canvas_to_world(self, x, y):
return (x - self.origin_x) / self.scale, (y - self.origin_y) / self.scale
def snap(self, value, step=None):
actual = self.grid_step if step is None else step
return round(value / actual) * actual
def fit_view(self) -> None:
self.update_idletasks()
width = max(self.canvas.winfo_width(), 500)
height = max(self.canvas.winfo_height(), 400)
margin = 75
self.scale = min(
(width - margin * 2) / self.room_width,
(height - margin * 2) / self.room_depth,
)
self.scale = max(24, min(125, self.scale))
self.origin_x = (width - self.room_width * self.scale) / 2
self.origin_y = (height - self.room_depth * self.scale) / 2
self.redraw()
def on_mousewheel(self, event) -> None:
if self.selected_furniture_id is not None and not (event.state & 0x0004):
self.rotate_selected_furniture(15 if event.delta > 0 else -15)
else:
self.zoom_at(1.12 if event.delta > 0 else 0.89, event.x, event.y)
def on_linux_wheel(self, event, direction):
if self.selected_furniture_id is not None:
self.rotate_selected_furniture(direction * 15)
else:
self.zoom_at(1.12 if direction > 0 else 0.89, event.x, event.y)
def zoom_at(self, factor, canvas_x, canvas_y):
wx, wy = self.canvas_to_world(canvas_x, canvas_y)
self.scale = max(18, min(190, self.scale * factor))
self.origin_x = canvas_x - wx * self.scale
self.origin_y = canvas_y - wy * self.scale
self.redraw()
def on_canvas_press(self, event) -> None:
self.focus_set()
wx, wy = self.canvas_to_world(event.x, event.y)
self.drag_start_world = (wx, wy)
if self.region_draw_mode:
if not (0 <= wx <= self.room_width and 0 <= wy <= self.room_depth):
return
self.interaction_mode = "draw_region"
self.preview_region = (wx, wy, wx, wy)
return
if not self.furniture_hidden and self.rotation_handle_hit(event.x, event.y):
item = self.get_selected_furniture()
if item:
self.interaction_mode = "rotate_furniture"
self.drag_snapshot = (
item.x, item.y, item.rotation, item.region_id,
[(o.id, o.x, o.y, o.rotation, o.region_id) for o in self.furniture]
)
return
if not self.furniture_hidden:
item = self.hit_furniture(wx, wy)
if item:
self.selected_furniture_id = item.id
self.selected_region_id = None
self.selected_opening_id = None
self.interaction_mode = "move_furniture"
self.drag_offset = (wx - item.x, wy - item.y)
self.drag_snapshot = [
(o.id, o.x, o.y, o.rotation, o.region_id) for o in self.furniture
]
self.update_furniture_inspector()
self.refresh_all()
self.redraw()
return
handle = self.hit_region_handle(event.x, event.y)
region = self.get_selected_region()
if handle and region:
self.interaction_mode = f"resize_region_{handle}"
self.drag_snapshot = (
[(r.id, r.x, r.y, r.width, r.depth) for r in self.regions],
[(o.id, o.x, o.y, o.rotation, o.region_id) for o in self.furniture],
)
return
region = self.hit_region(wx, wy)
if region:
self.selected_region_id = region.id
self.selected_furniture_id = None
self.selected_opening_id = None
self.interaction_mode = "move_region"
self.drag_offset = (wx - region.x, wy - region.y)
self.drag_snapshot = (
[(r.id, r.x, r.y, r.width, r.depth) for r in self.regions],
[(o.id, o.x, o.y, o.rotation, o.region_id) for o in self.furniture],
)
self.update_region_inspector()
self.refresh_all()
self.redraw()
return
self.selected_region_id = None
self.selected_furniture_id = None
self.selected_opening_id = None
self.refresh_all()
self.redraw()
def on_canvas_drag(self, event) -> None:
if not self.interaction_mode:
return
wx, wy = self.canvas_to_world(event.x, event.y)
if self.interaction_mode == "draw_region":
wx = min(max(wx, 0), self.room_width)
wy = min(max(wy, 0), self.room_depth)
x1, y1 = self.drag_start_world
self.preview_region = (x1, y1, wx, wy)
self.redraw()
return
if self.interaction_mode == "move_furniture":
item = self.get_selected_furniture()
if item is None:
return
item.x = self.snap(wx - self.drag_offset[0], 0.05)
item.y = self.snap(wy - self.drag_offset[1], 0.05)
self.update_item_region(item)
if (
not self.furniture_inside_room(item)
or self.item_intersects_any_door_zone(item)
or not self.push_overlapping_furniture(item)
):
self.restore_furniture_snapshot(self.drag_snapshot)
self.update_furniture_inspector()
self.refresh_furniture_tree()
self.redraw()
return
if self.interaction_mode == "rotate_furniture":
item = self.get_selected_furniture()
if item is None:
return
angle = math.degrees(math.atan2(wy - item.y, wx - item.x)) + 90
old = item.rotation
item.rotation = self.snap(angle % 360, 5)
if (
not self.furniture_inside_room(item)
or self.item_intersects_any_door_zone(item)
or not self.push_overlapping_furniture(item)
):
item.rotation = old
self.update_furniture_inspector()
self.refresh_furniture_tree()
self.redraw()
return
region = self.get_selected_region()
if region is None:
return
if self.interaction_mode == "move_region":
region.x = self.snap(wx - self.drag_offset[0], 0.05)
region.y = self.snap(wy - self.drag_offset[1], 0.05)
self.clamp_region(region)
original_regions, original_furniture = self.drag_snapshot
original_region = next(state for state in original_regions if state[0] == region.id)
dx = region.x - original_region[1]
dy = region.y - original_region[2]
for item in self.furniture:
original = next((s for s in original_furniture if s[0] == item.id), None)
if original and original[4] == region.id:
item.x = original[1] + dx
item.y = original[2] + dy
if not self.push_overlapping_regions(region):
self.restore_region_snapshot(*self.drag_snapshot)
self.update_region_inspector()
self.refresh_all()
self.redraw()
return
if self.interaction_mode.startswith("resize_region_"):
handle = self.interaction_mode.replace("resize_region_", "")
original_regions, _original_furniture = self.drag_snapshot
original = next(state for state in original_regions if state[0] == region.id)
_, ox, oy, ow, od = original
right, bottom = ox + ow, oy + od
if "w" in handle:
new_left = self.snap(wx, 0.05)
else:
new_left = ox
if "e" in handle:
new_right = self.snap(wx, 0.05)
else:
new_right = right
if "n" in handle:
new_top = self.snap(wy, 0.05)
else:
new_top = oy
if "s" in handle:
new_bottom = self.snap(wy, 0.05)
else:
new_bottom = bottom
region.x = min(new_left, new_right)
region.y = min(new_top, new_bottom)
region.width = abs(new_right - new_left)
region.depth = abs(new_bottom - new_top)
self.clamp_region(region)
if region.width < 0.5 or region.depth < 0.5 or not self.push_overlapping_regions(region):
self.restore_region_snapshot(*self.drag_snapshot)
else:
for item in self.furniture:
if item.region_id == region.id:
self.scale_furniture_to_fit_region(item, region)
self.auto_layout_single_region(region)
self.update_region_inspector()
self.refresh_all()
self.redraw()
def on_canvas_release(self, _event) -> None:
if self.interaction_mode == "draw_region":
self.finish_region_drawing()
self.interaction_mode = None
self.preview_region = None
self.refresh_all()
self.redraw()
def on_canvas_double_click(self, event) -> None:
if self.furniture_hidden:
return
wx, wy = self.canvas_to_world(event.x, event.y)
item = self.hit_furniture(wx, wy)
if item:
self.selected_furniture_id = item.id
self.rotate_selected_furniture(90)
def finish_region_drawing(self) -> None:
if not self.preview_region:
return
x1, y1, x2, y2 = self.preview_region
left, top = min(x1, x2), min(y1, y2)
width, depth = abs(x2 - x1), abs(y2 - y1)
if width < 0.5 or depth < 0.5:
return
name = self.region_type_var.get()
kind, color, _weight = self.REGION_TYPES[name]
region = Region(
self.next_region_id, kind, name,
self.snap(left, 0.05), self.snap(top, 0.05),
self.snap(width, 0.05), self.snap(depth, 0.05),
color
)
self.clamp_region(region)
self.regions.append(region)
self.next_region_id += 1
self.selected_region_id = region.id
self.push_overlapping_regions(region)
self.update_region_inspector()
self.status_var.set(f"已创建{name},重叠区域已尝试挪开")
def restore_furniture_snapshot(self, states) -> None:
by_id = {item.id: item for item in self.furniture}
for item_id, x, y, rotation, region_id in states:
if item_id in by_id:
item = by_id[item_id]
item.x, item.y, item.rotation, item.region_id = x, y, rotation, region_id
def restore_region_snapshot(self, region_states, furniture_states) -> None:
by_region = {region.id: region for region in self.regions}
for rid, x, y, width, depth in region_states:
if rid in by_region:
region = by_region[rid]
region.x, region.y, region.width, region.depth = x, y, width, depth
self.restore_furniture_snapshot(furniture_states)
def cancel_current_mode(self) -> None:
self.region_draw_mode = False
self.interaction_mode = None
self.preview_region = None
self.draw_region_button.configure(text="开始手动画区域")
self.redraw()
def delete_current_selection(self) -> None:
if self.selected_furniture_id is not None:
self.delete_selected_furniture()
elif self.selected_region_id is not None:
self.delete_selected_region()
elif self.selected_opening_id is not None:
self.delete_selected_opening()
def hit_furniture(self, x, y):
for item in reversed(self.furniture):
if self.point_in_polygon(x, y, self.furniture_corners(item)):
return item
return None
def hit_region(self, x, y):
for region in reversed(self.regions):
if (
region.x <= x <= region.x + region.width
and region.y <= y <= region.y + region.depth
):
return region
return None
@staticmethod
def point_in_polygon(x, y, polygon):
inside = False
previous = polygon[-1]
for current in polygon:
x1, y1 = previous
x2, y2 = current
if (y1 > y) != (y2 > y):
crossing = (x2 - x1) * (y - y1) / (y2 - y1) + x1
if x < crossing:
inside = not inside
previous = current
return inside
def region_handles(self, region):
return {
"nw": self.world_to_canvas(region.x, region.y),
"ne": self.world_to_canvas(region.x + region.width, region.y),
"se": self.world_to_canvas(region.x + region.width, region.y + region.depth),
"sw": self.world_to_canvas(region.x, region.y + region.depth),
}
def hit_region_handle(self, canvas_x, canvas_y):
region = self.get_selected_region()
if not region:
return None
for name, (hx, hy) in self.region_handles(region).items():
if math.hypot(canvas_x - hx, canvas_y - hy) <= 12:
return name
return None
def rotation_handle_position(self, item):
world = self.rotate_point(
item.x, item.y, 0, -item.depth / 2 - 0.35, item.rotation
)
return self.world_to_canvas(*world)
def rotation_handle_hit(self, canvas_x, canvas_y):
item = self.get_selected_furniture()
if item is None:
return False
hx, hy = self.rotation_handle_position(item)
return math.hypot(canvas_x - hx, canvas_y - hy) <= 13
# =========================================================
# 列表刷新
# =========================================================
def refresh_all(self) -> None:
self.refresh_region_tree()
self.refresh_furniture_tree()
self.refresh_opening_tree()
self.update_region_inspector()
self.update_furniture_inspector()
def refresh_region_tree(self) -> None:
for row in self.region_tree.get_children():
self.region_tree.delete(row)
counts = {}
for region in self.regions:
counts[region.name] = counts.get(region.name, 0) + 1
suffix = f" {counts[region.name]}" if counts[region.name] > 1 else ""
self.region_tree.insert(
"", tk.END, iid=str(region.id), text=region.name + suffix,
values=(f"{region.area:.1f}㎡", f"{region.width:.1f}×{region.depth:.1f}")
)
if self.selected_region_id is not None and self.region_tree.exists(str(self.selected_region_id)):
self.region_tree.selection_set(str(self.selected_region_id))
def refresh_furniture_tree(self) -> None:
for row in self.furniture_tree.get_children():
self.furniture_tree.delete(row)
region_names = {region.id: region.name for region in self.regions}
for item in self.furniture:
self.furniture_tree.insert(
"", tk.END, iid=str(item.id), text=item.name,
values=(
f"{item.width:.2f}×{item.depth:.2f}",
f"{item.rotation:.0f}°",
region_names.get(item.region_id, "未指定"),
)
)
if self.selected_furniture_id is not None and self.furniture_tree.exists(str(self.selected_furniture_id)):
self.furniture_tree.selection_set(str(self.selected_furniture_id))
def refresh_opening_tree(self) -> None:
wall_names = {"top": "上墙", "bottom": "下墙", "left": "左墙", "right": "右墙"}
for row in self.opening_tree.get_children():
self.opening_tree.delete(row)
for opening in self.openings:
name = "门" if opening.kind == "door" else "窗"
self.opening_tree.insert(
"", tk.END, iid=str(opening.id), text=name,
values=(
wall_names[opening.wall],
f"{opening.offset:.2f}",
f"{opening.width:.2f}",
)
)
if self.selected_opening_id is not None and self.opening_tree.exists(str(self.selected_opening_id)):
self.opening_tree.selection_set(str(self.selected_opening_id))
# =========================================================
# 绘制
# =========================================================
def redraw(self) -> None:
self.canvas.delete("all")
self.draw_grid()
self.draw_room()
self.draw_regions()
self.draw_openings()
if not self.furniture_hidden:
self.draw_furniture()
self.draw_region_preview()
def draw_grid(self) -> None:
width = self.canvas.winfo_width()
height = self.canvas.winfo_height()
if width <= 1 or height <= 1:
return
step = 0.25
while step * self.scale < 12:
step *= 2
left, top = self.canvas_to_world(0, 0)
right, bottom = self.canvas_to_world(width, height)
x = math.floor(left / step) * step
while x <= right:
cx, _ = self.world_to_canvas(x, 0)
major = abs(x - round(x)) < 1e-8
self.canvas.create_line(cx, 0, cx, height, fill="#d9dee5" if major else "#eceff3")
x += step
y = math.floor(top / step) * step
while y <= bottom:
_, cy = self.world_to_canvas(0, y)
major = abs(y - round(y)) < 1e-8
self.canvas.create_line(0, cy, width, cy, fill="#d9dee5" if major else "#eceff3")
y += step
def draw_room(self) -> None:
x1, y1 = self.world_to_canvas(0, 0)
x2, y2 = self.world_to_canvas(self.room_width, self.room_depth)
self.canvas.create_rectangle(
x1, y1, x2, y2, fill="#fffef8", outline="#263238", width=5
)
self.canvas.create_text(
(x1 + x2) / 2, y1 - 20,
text=f"宽度 {self.room_width:.2f} 米",
font=("Arial", 10, "bold")
)
self.canvas.create_text(
x1 - 40, (y1 + y2) / 2,
text=f"进深\n{self.room_depth:.2f} 米",
font=("Arial", 10, "bold"),
justify=tk.CENTER
)
def draw_regions(self) -> None:
for region in self.regions:
x1, y1 = self.world_to_canvas(region.x, region.y)
x2, y2 = self.world_to_canvas(
region.x + region.width, region.y + region.depth
)
selected = region.id == self.selected_region_id
self.canvas.create_rectangle(
x1, y1, x2, y2,
fill=region.color,
stipple="gray50",
outline="#1565c0" if selected else "#6b7280",
width=4 if selected else 2,
)
self.canvas.create_text(
(x1 + x2) / 2, (y1 + y2) / 2,
text=f"{region.name}\n{region.area:.1f}㎡",
font=("Arial", 10, "bold"),
justify=tk.CENTER
)
if selected:
for hx, hy in self.region_handles(region).values():
self.canvas.create_rectangle(
hx - 6, hy - 6, hx + 6, hy + 6,
fill="#ffffff", outline="#1565c0", width=3
)
def draw_openings(self) -> None:
for opening in self.openings:
selected = opening.id == self.selected_opening_id
color = "#d32f2f" if selected else ("#8d4d2d" if opening.kind == "door" else "#1976d2")
self.draw_single_opening(opening, color)
def draw_single_opening(self, opening, color) -> None:
wall = opening.wall
offset = opening.offset
width = opening.width
if wall in ("top", "bottom"):
y = 0 if wall == "top" else self.room_depth
x1, cy = self.world_to_canvas(offset, y)
x2, _ = self.world_to_canvas(offset + width, y)
self.canvas.create_line(x1, cy, x2, cy, fill="#ffffff", width=9)
self.canvas.create_line(x1, cy, x2, cy, fill=color, width=4 if opening.kind == "door" else 7)
label_y = cy + (15 if wall == "top" else -15)
self.canvas.create_text((x1 + x2) / 2, label_y, text=opening.name, fill=color)
else:
x = 0 if wall == "left" else self.room_width
cx, y1 = self.world_to_canvas(x, offset)
_, y2 = self.world_to_canvas(x, offset + width)
self.canvas.create_line(cx, y1, cx, y2, fill="#ffffff", width=9)
self.canvas.create_line(cx, y1, cx, y2, fill=color, width=4 if opening.kind == "door" else 7)
label_x = cx + (18 if wall == "left" else -18)
self.canvas.create_text(label_x, (y1 + y2) / 2, text=opening.name, fill=color)
if opening.kind == "door":
polygon = self.door_clearance_polygon(opening)
coords = [c for p in polygon for c in self.world_to_canvas(*p)]
self.canvas.create_polygon(
coords, fill="", outline="#d97706", dash=(5, 4), width=2
)
def draw_furniture(self) -> None:
for item in self.furniture:
polygon = [c for p in self.furniture_corners(item) for c in self.world_to_canvas(*p)]
selected = item.id == self.selected_furniture_id
collision = any(
other.id != item.id and self.oriented_rectangles_overlap(item, other, 0.02)
for other in self.furniture
)
self.canvas.create_polygon(
polygon,
fill=item.color,
outline="#d62828" if selected or collision else "#374151",
width=4 if selected else 2,
)
self.draw_furniture_icon(item)
cx, cy = self.world_to_canvas(item.x, item.y)
self.canvas.create_text(
cx, cy, text=item.name, font=("Arial", 8, "bold"), fill="#111827"
)
if selected:
hx, hy = self.rotation_handle_position(item)
top_world = self.rotate_point(
item.x, item.y, 0, -item.depth / 2, item.rotation
)
tx, ty = self.world_to_canvas(*top_world)
self.canvas.create_line(tx, ty, hx, hy, fill="#7c3aed", width=2)
self.canvas.create_oval(
hx - 8, hy - 8, hx + 8, hy + 8,
fill="#ffffff", outline="#7c3aed", width=3
)
self.canvas.create_text(hx, hy, text="↻", fill="#7c3aed")
def draw_furniture_icon(self, item) -> None:
if item.kind in ("double_bed", "single_bed"):
self.draw_bed(item)
elif item.kind == "sofa":
self.draw_sofa(item)
elif item.kind == "dining_table":
self.draw_table(item)
elif item.kind == "desk":
self.draw_desk(item)
elif item.kind == "wardrobe":
self.local_line(item, [(0, -item.depth / 2), (0, item.depth / 2)], fill="#263746", width=2)
elif item.kind == "tv":
self.local_polygon(
item,
[(-item.width * .38, -item.depth * .25), (item.width * .38, -item.depth * .25),
(item.width * .38, item.depth * .10), (-item.width * .38, item.depth * .10)],
fill="#1f2937", outline="#111827", width=1
)
elif item.kind == "stove":
self.draw_stove(item)
elif item.kind == "washer":
self.draw_washer(item)
elif item.kind == "toilet":
self.draw_toilet(item)
elif item.kind == "shower":
self.local_line(
item,
[(-item.width / 2, -item.depth / 2), (item.width / 2, item.depth / 2)],
fill="#2c7da0", width=2
)
self.local_line(
item,
[(item.width / 2, -item.depth / 2), (-item.width / 2, item.depth / 2)],
fill="#2c7da0", width=2
)
def local_point(self, item, lx, ly):
return self.world_to_canvas(*self.rotate_point(item.x, item.y, lx, ly, item.rotation))
def local_polygon(self, item, points, **kwargs):
coords = [c for p in points for c in self.local_point(item, *p)]
return self.canvas.create_polygon(coords, **kwargs)
def local_line(self, item, points, **kwargs):
coords = [c for p in points for c in self.local_point(item, *p)]
return self.canvas.create_line(coords, **kwargs)
def draw_bed(self, item):
w, d = item.width, item.depth
inset = min(0.08, min(w, d) * .08)
self.local_polygon(
item,
[(-w/2+inset, -d/2+inset), (w/2-inset, -d/2+inset),
(w/2-inset, d/2-inset), (-w/2+inset, d/2-inset)],
fill="#f7e7b5", outline="#8d6e63"
)
pillow_w = w * (0.36 if item.kind == "double_bed" else 0.55)
centers = [-w*.22, w*.22] if item.kind == "double_bed" else [0]
for center in centers:
self.local_polygon(
item,
[(center-pillow_w/2, -d/2+inset*2),
(center+pillow_w/2, -d/2+inset*2),
(center+pillow_w/2, -d/2+d*.24),
(center-pillow_w/2, -d/2+d*.24)],
fill="#ffffff", outline="#a1887f"
)
def draw_sofa(self, item):
w, d = item.width, item.depth
arm = min(.22, w*.12)
self.local_polygon(
item,
[(-w/2+arm, -d/2+.05), (w/2-arm, -d/2+.05),
(w/2-arm, d/2-.05), (-w/2+arm, d/2-.05)],
fill="#9bd27d", outline="#365c2d"
)
self.local_line(item, [(-w/2+arm, -d*.18), (w/2-arm, -d*.18)], fill="#365c2d", width=2)
self.local_line(item, [(0, -d*.18), (0, d/2-.05)], fill="#5a7d4c")
def draw_table(self, item):
w, d = item.width, item.depth
inset = min(.10, min(w, d)*.12)
self.local_polygon(
item,
[(-w/2+inset, -d/2+inset), (w/2-inset, -d/2+inset),
(w/2-inset, d/2-inset), (-w/2+inset, d/2-inset)],
fill="#c98f55", outline="#6d4c41", width=2
)
def draw_desk(self, item):
w, d = item.width, item.depth
self.local_polygon(
item,
[(-w/2+.04, -d/2+.04), (w/2-.04, -d/2+.04),
(w/2-.04, d*.05), (-w/2+.04, d*.05)],
fill="#7dd3c7", outline="#225e57"
)
def draw_stove(self, item):
for xf in (-.22, .22):
for yf in (-.22, .22):
cx, cy = self.local_point(item, item.width*xf, item.depth*yf)
radius = max(3, int(min(item.width, item.depth)*self.scale*.10))
self.canvas.create_oval(cx-radius, cy-radius, cx+radius, cy+radius, outline="#222", width=2)
def draw_washer(self, item):
cx, cy = self.world_to_canvas(item.x, item.y)
radius = max(5, int(min(item.width, item.depth)*self.scale*.27))
self.canvas.create_oval(cx-radius, cy-radius, cx+radius, cy+radius, fill="#e8eef3", outline="#52616b", width=2)
def draw_toilet(self, item):
cx, cy = self.local_point(item, 0, item.depth*.10)
rx = max(4, int(item.width*self.scale*.25))
ry = max(4, int(item.depth*self.scale*.26))
self.canvas.create_oval(cx-rx, cy-ry, cx+rx, cy+ry, fill="#f8fbff", outline="#54728c")
def draw_region_preview(self) -> None:
if not self.preview_region:
return
x1, y1, x2, y2 = self.preview_region
cx1, cy1 = self.world_to_canvas(x1, y1)
cx2, cy2 = self.world_to_canvas(x2, y2)
self.canvas.create_rectangle(
cx1, cy1, cx2, cy2, outline="#e11d48", dash=(7, 4), width=3
)
# =========================================================
# 保存读取
# =========================================================
def save_layout(self) -> None:
path = filedialog.asksaveasfilename(
title="保存布局",
defaultextension=".json",
filetypes=[("布局文件", "*.json"), ("所有文件", "*.*")],
)
if not path:
return
data = {
"version": 3,
"room_width": self.room_width,
"room_depth": self.room_depth,
"regions": [asdict(region) for region in self.regions],
"furniture": [asdict(item) for item in self.furniture],
"openings": [asdict(opening) for opening in self.openings],
}
try:
Path(path).write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
self.status_var.set(f"已保存:{Path(path).name}")
except OSError as error:
messagebox.showerror("保存失败", str(error))
def load_layout(self) -> None:
path = filedialog.askopenfilename(
title="读取布局",
filetypes=[("布局文件", "*.json"), ("所有文件", "*.*")],
)
if not path:
return
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
self.room_width = float(data["room_width"])
self.room_depth = float(data["room_depth"])
self.regions = [Region(**item) for item in data.get("regions", [])]
self.furniture = [Furniture(**item) for item in data.get("furniture", [])]
self.openings = [Opening(**item) for item in data.get("openings", [])]
self.next_region_id = max((r.id for r in self.regions), default=0) + 1
self.next_furniture_id = max((f.id for f in self.furniture), default=0) + 1
self.next_opening_id = max((o.id for o in self.openings), default=0) + 1
self.room_width_var.set(f"{self.room_width:.2f}")
self.room_depth_var.set(f"{self.room_depth:.2f}")
self.selected_region_id = None
self.selected_furniture_id = None
self.selected_opening_id = None
self.refresh_all()
self.fit_view()
self.status_var.set(f"已读取:{Path(path).name}")
except Exception as error:
messagebox.showerror("读取失败", f"文件格式不正确:\n{error}")
import copy
import struct
import zlib
from datetime import datetime
class SimplePNGRenderer:
"""纯 Python PNG 绘制器,用于导出当前布局为 PNG。"""
def __init__(self, width: int, height: int, background=(255, 255, 255)):
self.width = max(1, int(width))
self.height = max(1, int(height))
row = bytearray()
for _ in range(self.width):
row.extend(background)
self.pixels = [bytearray(row) for _ in range(self.height)]
def set_pixel(self, x: int, y: int, color):
if 0 <= x < self.width and 0 <= y < self.height:
offset = x * 3
self.pixels[y][offset:offset+3] = bytes(color)
def draw_disc(self, cx: int, cy: int, radius: int, color):
radius = max(0, int(radius))
r2 = radius * radius
for y in range(cy - radius, cy + radius + 1):
if y < 0 or y >= self.height:
continue
for x in range(cx - radius, cx + radius + 1):
if x < 0 or x >= self.width:
continue
if (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r2:
self.set_pixel(x, y, color)
def draw_line(self, x1, y1, x2, y2, color, thickness=1):
x1 = float(x1); y1 = float(y1); x2 = float(x2); y2 = float(y2)
steps = int(max(abs(x2 - x1), abs(y2 - y1))) + 1
radius = max(0, int(thickness // 2))
if steps <= 0:
self.draw_disc(int(round(x1)), int(round(y1)), radius, color)
return
for i in range(steps + 1):
t = i / steps
x = int(round(x1 + (x2 - x1) * t))
y = int(round(y1 + (y2 - y1) * t))
self.draw_disc(x, y, radius, color)
def fill_rect(self, x1, y1, x2, y2, color):
left = max(0, min(int(round(x1)), int(round(x2))))
right = min(self.width - 1, max(int(round(x1)), int(round(x2))))
top = max(0, min(int(round(y1)), int(round(y2))))
bottom = min(self.height - 1, max(int(round(y1)), int(round(y2))))
for y in range(top, bottom + 1):
row = self.pixels[y]
for x in range(left, right + 1):
offset = x * 3
row[offset:offset+3] = bytes(color)
def fill_polygon(self, points, color):
if len(points) < 3:
return
pts = [(float(x), float(y)) for x, y in points]
min_y = max(0, int(math.floor(min(y for _, y in pts))))
max_y = min(self.height - 1, int(math.ceil(max(y for _, y in pts))))
for y in range(min_y, max_y + 1):
intersections = []
for i in range(len(pts)):
x1, y1 = pts[i]
x2, y2 = pts[(i + 1) % len(pts)]
if y1 == y2:
continue
if (y1 <= y < y2) or (y2 <= y < y1):
x = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
intersections.append(x)
intersections.sort()
for i in range(0, len(intersections), 2):
if i + 1 >= len(intersections):
break
start_x = max(0, int(math.ceil(intersections[i])))
end_x = min(self.width - 1, int(math.floor(intersections[i + 1])))
row = self.pixels[y]
for x in range(start_x, end_x + 1):
offset = x * 3
row[offset:offset+3] = bytes(color)
def draw_polygon(self, points, color, thickness=1):
if len(points) < 2:
return
for i in range(len(points)):
x1, y1 = points[i]
x2, y2 = points[(i + 1) % len(points)]
self.draw_line(x1, y1, x2, y2, color, thickness=thickness)
def draw_circle(self, cx, cy, radius, color, fill=None, thickness=1):
radius = int(radius)
if fill is not None:
self.draw_disc(int(cx), int(cy), radius, fill)
steps = max(30, int(radius * 6))
prev = None
for i in range(steps + 1):
angle = math.tau * i / steps
x = cx + math.cos(angle) * radius
y = cy + math.sin(angle) * radius
if prev is not None:
self.draw_line(prev[0], prev[1], x, y, color, thickness=thickness)
prev = (x, y)
def save(self, path: str):
def png_chunk(tag: bytes, data: bytes) -> bytes:
return (
struct.pack("!I", len(data))
+ tag
+ data
+ struct.pack("!I", zlib.crc32(tag + data) & 0xffffffff)
)
raw = b"".join(b"\x00" + bytes(row) for row in self.pixels)
png = b"\x89PNG\r\n\x1a\n"
png += png_chunk(
b"IHDR",
struct.pack("!IIBBBBB", self.width, self.height, 8, 2, 0, 0, 0),
)
png += png_chunk(b"IDAT", zlib.compress(raw, 9))
png += png_chunk(b"IEND", b"")
Path(path).write_bytes(png)
class PlannerV4(PlannerV3):
"""V4:多套自动规划 + PNG 导出"""
def __init__(self):
self.plan_schemes = []
self.current_plan_index = -1
self.plan_count_var = None
self.plan_label_var = None
super().__init__()
self.title("智能房屋区域与家具布局器 V4 - 吾爱破解论坛 [url=www.52pojie.cn]www.52pojie.cn[/url]")
self.status_var.set("可一键生成多套规划方案,并导出 PNG")
def build_ui(self):
super().build_ui()
# 顶部工具栏是第一个子组件
toolbar = self.winfo_children()[0]
self.plan_count_var = tk.IntVar(value=4)
self.plan_label_var = tk.StringVar(value="当前方案:手动 / 单套")
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8)
ttk.Label(toolbar, text="方案数").pack(side=tk.LEFT)
ttk.Spinbox(toolbar, from_=2, to=8, textvariable=self.plan_count_var, width=5).pack(
side=tk.LEFT, padx=(4, 6)
)
ttk.Button(toolbar, text="生成多套方案", command=self.generate_multiple_plans).pack(
side=tk.LEFT, padx=3
)
ttk.Button(toolbar, text="上一套", command=self.prev_plan).pack(side=tk.LEFT, padx=3)
ttk.Button(toolbar, text="下一套", command=self.next_plan).pack(side=tk.LEFT, padx=3)
ttk.Button(toolbar, text="导出PNG", command=self.export_current_plan_png).pack(
side=tk.LEFT, padx=(10, 3)
)
ttk.Label(toolbar, textvariable=self.plan_label_var).pack(side=tk.LEFT, padx=(8, 0))
# ---------------------------
# 方案快照
# ---------------------------
def snapshot_state(self, score=None, title=None):
return {
"room_width": self.room_width,
"room_depth": self.room_depth,
"regions": [asdict(region) for region in self.regions],
"furniture": [asdict(item) for item in self.furniture],
"openings": [asdict(opening) for opening in self.openings],
"score": score,
"title": title or "",
}
def load_state(self, state):
self.room_width = float(state["room_width"])
self.room_depth = float(state["room_depth"])
self.regions = [Region(**item) for item in state.get("regions", [])]
self.furniture = [Furniture(**item) for item in state.get("furniture", [])]
self.openings = [Opening(**item) for item in state.get("openings", [])]
self.next_region_id = max((r.id for r in self.regions), default=0) + 1
self.next_furniture_id = max((f.id for f in self.furniture), default=0) + 1
self.next_opening_id = max((o.id for o in self.openings), default=0) + 1
self.selected_region_id = None
self.selected_furniture_id = None
self.selected_opening_id = None
self.room_width_var.set(f"{self.room_width:.2f}")
self.room_depth_var.set(f"{self.room_depth:.2f}")
self.refresh_all()
self.redraw()
def update_plan_label(self):
if self.plan_schemes and 0 <= self.current_plan_index < len(self.plan_schemes):
score = self.plan_schemes[self.current_plan_index].get("score")
score_text = f" / 评分 {score:.1f}" if score is not None else ""
self.plan_label_var.set(
f"当前方案:{self.current_plan_index + 1}/{len(self.plan_schemes)}{score_text}"
)
else:
self.plan_label_var.set("当前方案:手动 / 单套")
def prev_plan(self):
if not self.plan_schemes:
messagebox.showinfo("提示", "请先点击“生成多套方案”。")
return
self.current_plan_index = (self.current_plan_index - 1) % len(self.plan_schemes)
self.load_state(self.plan_schemes[self.current_plan_index])
self.update_plan_label()
self.status_var.set("已切换到上一套方案")
def next_plan(self):
if not self.plan_schemes:
messagebox.showinfo("提示", "请先点击“生成多套方案”。")
return
self.current_plan_index = (self.current_plan_index + 1) % len(self.plan_schemes)
self.load_state(self.plan_schemes[self.current_plan_index])
self.update_plan_label()
self.status_var.set("已切换到下一套方案")
# ---------------------------
# 多套自动规划
# ---------------------------
def generate_multiple_plans(self):
try:
count = max(2, min(8, int(self.plan_count_var.get())))
except Exception:
count = 4
selected_names = [name for name, var in self.auto_region_vars.items() if var.get()]
if not selected_names:
messagebox.showinfo("提示", "请在“房间区域”中勾选至少一种区域类型。")
return
self.status_var.set("正在生成多套规划方案,请稍候……")
self.update_idletasks()
base_openings = [asdict(opening) for opening in self.openings]
variants = []
signature_seen = set()
# 多做几轮,再挑最优和差异较大的方案
attempts = max(count * 5, 12)
seed_base = random.randint(1, 1_000_000)
for attempt in range(attempts):
rnd = random.Random(seed_base + attempt * 97)
regions = self.random_partition_regions(selected_names, rnd)
furniture = self.generate_adaptive_furniture_variant(regions, rnd)
score = self.evaluate_layout_variant(regions, furniture)
state = {
"room_width": self.room_width,
"room_depth": self.room_depth,
"regions": [asdict(region) for region in regions],
"furniture": [asdict(item) for item in furniture],
"openings": copy.deepcopy(base_openings),
"score": score,
"title": f"方案 {attempt + 1}",
}
# 简单去重:按区域骨架+家具骨架
signature = (
tuple((r["name"], round(r["x"], 1), round(r["y"], 1),
round(r["width"], 1), round(r["depth"], 1)) for r in state["regions"]),
tuple((f["name"], f["region_id"], int(round(f["rotation"])) % 180,
round(f["x"], 1), round(f["y"], 1)) for f in state["furniture"]),
)
if signature in signature_seen:
continue
signature_seen.add(signature)
variants.append(state)
if not variants:
messagebox.showwarning("生成失败", "没有找到可用的自动规划方案。")
return
variants.sort(key=lambda state: state["score"])
self.plan_schemes = variants[:count]
self.current_plan_index = 0
self.load_state(self.plan_schemes[0])
self.update_plan_label()
self.status_var.set(f"已生成 {len(self.plan_schemes)} 套自动规划方案")
self.score_var.set("布局状态:可用上一套 / 下一套进行切换")
def random_partition_regions(self, selected_names, rnd: random.Random):
specs = []
for name in selected_names:
kind, color, weight = self.REGION_TYPES[name]
weight *= rnd.uniform(0.88, 1.15)
specs.append((name, kind, color, weight))
# 优先级组,组内允许随机化
priority = {
"客厅": 0, "餐厅": 1, "厨房": 2,
"主卧": 3, "次卧": 4, "书房": 5,
"卫生间": 6, "阳台": 7, "储物间": 8, "其他": 9,
}
specs.sort(key=lambda item: (priority.get(item[0], 99), rnd.random()))
rects = self.weighted_partition_variant(
0.0, 0.0, self.room_width, self.room_depth, specs, rnd
)
regions = []
for index, (name, kind, color, x, y, width, depth) in enumerate(rects, start=1):
regions.append(Region(index, kind, name, x, y, width, depth, color))
return regions
def weighted_partition_variant(self, x, y, width, depth, specs, rnd):
if len(specs) == 1:
name, kind, color, _weight = specs[0]
return [(name, kind, color, x, y, width, depth)]
total = sum(item[3] for item in specs)
target = total * rnd.uniform(0.43, 0.57)
running = 0.0
split_index = 1
for idx, item in enumerate(specs[:-1], start=1):
running += item[3]
split_index = idx
if running >= target:
break
left_specs = specs[:split_index]
right_specs = specs[split_index:]
left_ratio = sum(item[3] for item in left_specs) / total
# 更灵活的分割方向,增强方案差异
if abs(width - depth) < 1.4:
split_vertical = rnd.random() < 0.5
else:
split_vertical = width >= depth
if rnd.random() < 0.25:
split_vertical = not split_vertical
result = []
if split_vertical:
w1 = width * left_ratio
result.extend(self.weighted_partition_variant(x, y, w1, depth, left_specs, rnd))
result.extend(self.weighted_partition_variant(x + w1, y, width - w1, depth, right_specs, rnd))
else:
d1 = depth * left_ratio
result.extend(self.weighted_partition_variant(x, y, width, d1, left_specs, rnd))
result.extend(self.weighted_partition_variant(x, y + d1, width, depth - d1, right_specs, rnd))
return result
def generate_adaptive_furniture_variant(self, regions, rnd: random.Random):
furniture = []
next_id = 1
for region in regions:
template_names = list(self.REGION_FURNITURE_TEMPLATES.get(region.kind, ["自定义家具"]))
# 为增加差异,某些区域会随机重排家具优先级
if len(template_names) > 1:
rnd.shuffle(template_names)
items = []
for name in template_names:
kind, width, depth, color, wall, center = self.FURNITURE_PRESETS[name]
items.append(
Furniture(
id=next_id,
kind=kind,
name=name,
width=width,
depth=depth,
color=color,
x=region.x + region.width / 2,
y=region.y + region.depth / 2,
rotation=0,
region_id=region.id,
wall_preference=wall,
center_preference=center,
)
)
next_id += 1
if not items:
continue
area_sum = sum(item.width * item.depth for item in items)
area_scale = math.sqrt(max(region.area * rnd.uniform(0.34, 0.44) / max(area_sum, 0.01), 0.10))
side_scale = min(
region.width / max(max(item.width for item in items), 0.01),
region.depth / max(max(item.depth for item in items), 0.01),
) * rnd.uniform(0.74, 0.88)
scale = max(0.35, min(1.20, area_scale, side_scale))
for item in items:
item.width *= scale
item.depth *= scale
success = False
for _ in range(18):
if self.place_items_in_region_variant(items, region, rnd):
success = True
break
for item in items:
item.width = max(0.18, item.width * 0.92)
item.depth = max(0.18, item.depth * 0.92)
if not success:
# 最终兜底:继续缩小并更密集摆放
for _ in range(10):
if self.place_items_in_region_variant(items, region, rnd, dense=True):
success = True
break
for item in items:
item.width = max(0.16, item.width * 0.92)
item.depth = max(0.16, item.depth * 0.92)
furniture.extend(items)
return furniture
def place_items_in_region_variant(self, items, region, rnd: random.Random, dense=False):
placed = []
ordered = sorted(
items,
key=lambda item: (item.width * item.depth, rnd.random()),
reverse=True,
)
step = 0.10 if dense else 0.14
for item in ordered:
best = None
best_score = float("inf")
angles = [0, 90, 180, 270]
rnd.shuffle(angles)
# 构造候选点:墙边、中心、网格点混合
candidates = []
# 四角附近和中心附近
cx, cy = region.x + region.width / 2, region.y + region.depth / 2
candidates.extend([
(cx, cy),
(region.x + region.width * 0.25, region.y + region.depth * 0.25),
(region.x + region.width * 0.75, region.y + region.depth * 0.25),
(region.x + region.width * 0.25, region.y + region.depth * 0.75),
(region.x + region.width * 0.75, region.y + region.depth * 0.75),
])
y = region.y + 0.06
while y <= region.y + region.depth - 0.06:
x = region.x + 0.06
while x <= region.x + region.width - 0.06:
candidates.append((x, y))
x += step
y += step
rnd.shuffle(candidates)
for angle in angles:
item.rotation = angle
for x, y in candidates:
item.x = x
item.y = y
if not self.furniture_inside_region(item, region):
continue
if self.item_intersects_any_door_zone(item):
continue
if any(self.oriented_rectangles_overlap(item, other, 0.04) for other in placed):
continue
wall_distance = self.distance_to_region_wall(item, region)
center_distance = math.hypot(item.x - cx, item.y - cy)
score = item.wall_preference * wall_distance * 7.0
score += item.center_preference * center_distance * 4.0
score += rnd.random() * 2.5
if item.kind == "desk":
score += item.y * 2.0
if item.kind in ("sofa", "tv", "wardrobe", "fridge", "stove"):
score -= min(region.width, region.depth) * 0.2
if score < best_score:
best_score = score
best = (x, y, angle)
if best is None:
return False
item.x, item.y, item.rotation = best
placed.append(item)
return True
def evaluate_layout_variant(self, regions, furniture):
score = 0.0
# 区域形态:尽量避免过细长
for region in regions:
ratio = max(region.width, region.depth) / max(0.01, min(region.width, region.depth))
score += max(0.0, ratio - 1.0) * 8.0
if region.kind in ("bathroom", "kitchen") and region.area < 3.0:
score += 25
if region.kind == "living" and region.area < 8.0:
score += 35
region_map = {region.id: region for region in regions}
# 家具:靠近适配区域、不过度拥挤
for index, item in enumerate(furniture):
region = region_map.get(item.region_id)
if region is None:
score += 30
continue
score += self.distance_to_region_wall(item, region) * item.wall_preference * 2.0
score += math.hypot(
item.x - (region.x + region.width / 2),
item.y - (region.y + region.depth / 2),
) * item.center_preference * 1.8
for other in furniture[index + 1:]:
if other.region_id == item.region_id:
dist = math.hypot(item.x - other.x, item.y - other.y)
if dist < 0.35:
score += 100
# 门口通畅奖励/惩罚
for item in furniture:
if self.item_intersects_any_door_zone(item):
score += 120
return score
# ---------------------------
# PNG 导出
# ---------------------------
def export_current_plan_png(self):
default_name = (
f"house_layout_plan_{self.current_plan_index + 1 if self.current_plan_index >= 0 else 1}_"
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
)
path = filedialog.asksaveasfilename(
title="导出当前布局为 PNG",
defaultextension=".png",
initialfile=default_name,
filetypes=[("PNG 图片", "*.png"), ("所有文件", "*.*")],
)
if not path:
return
try:
self.render_plan_to_png(path)
self.status_var.set(f"PNG 已导出:{Path(path).name}")
messagebox.showinfo("导出成功", f"已导出 PNG:\n{path}")
except Exception as error:
messagebox.showerror("导出失败", str(error))
def render_plan_to_png(self, path: str):
padding = 60
max_w, max_h = 1500, 1100
scale = min((max_w - padding * 2) / self.room_width, (max_h - padding * 2) / self.room_depth)
scale = max(30, min(160, scale))
img_w = int(self.room_width * scale + padding * 2)
img_h = int(self.room_depth * scale + padding * 2)
renderer = SimplePNGRenderer(img_w, img_h, background=(250, 250, 248))
def wc(x, y):
return int(round(padding + x * scale)), int(round(padding + y * scale))
# 背景网格
grid_step = 0.5
gx = 0.0
while gx <= self.room_width + 1e-8:
x1, y1 = wc(gx, 0)
x2, y2 = wc(gx, self.room_depth)
renderer.draw_line(x1, y1, x2, y2, (234, 238, 242), thickness=1)
gx += grid_step
gy = 0.0
while gy <= self.room_depth + 1e-8:
x1, y1 = wc(0, gy)
x2, y2 = wc(self.room_width, gy)
renderer.draw_line(x1, y1, x2, y2, (234, 238, 242), thickness=1)
gy += grid_step
# 房屋边框
rx1, ry1 = wc(0, 0)
rx2, ry2 = wc(self.room_width, self.room_depth)
renderer.fill_rect(rx1, ry1, rx2, ry2, (255, 254, 248))
renderer.draw_line(rx1, ry1, rx2, ry1, (38, 50, 56), thickness=5)
renderer.draw_line(rx2, ry1, rx2, ry2, (38, 50, 56), thickness=5)
renderer.draw_line(rx2, ry2, rx1, ry2, (38, 50, 56), thickness=5)
renderer.draw_line(rx1, ry2, rx1, ry1, (38, 50, 56), thickness=5)
# 区域
for region in self.regions:
color = self.hex_to_rgb(region.color)
fill = tuple(min(255, int(c * 0.92 + 20)) for c in color)
x1, y1 = wc(region.x, region.y)
x2, y2 = wc(region.x + region.width, region.y + region.depth)
renderer.fill_rect(x1, y1, x2, y2, fill)
# 外框
renderer.draw_line(x1, y1, x2, y1, (92, 107, 115), thickness=2)
renderer.draw_line(x2, y1, x2, y2, (92, 107, 115), thickness=2)
renderer.draw_line(x2, y2, x1, y2, (92, 107, 115), thickness=2)
renderer.draw_line(x1, y2, x1, y1, (92, 107, 115), thickness=2)
# 门窗
for opening in self.openings:
wall = opening.wall
color = (141, 77, 45) if opening.kind == "door" else (25, 118, 210)
if wall == "top":
x1, y = wc(opening.offset, 0)
x2, _ = wc(opening.offset + opening.width, 0)
renderer.draw_line(x1, y, x2, y, color, thickness=6)
elif wall == "bottom":
x1, y = wc(opening.offset, self.room_depth)
x2, _ = wc(opening.offset + opening.width, self.room_depth)
renderer.draw_line(x1, y, x2, y, color, thickness=6)
elif wall == "left":
x, y1 = wc(0, opening.offset)
_, y2 = wc(0, opening.offset + opening.width)
renderer.draw_line(x, y1, x, y2, color, thickness=6)
else:
x, y1 = wc(self.room_width, opening.offset)
_, y2 = wc(self.room_width, opening.offset + opening.width)
renderer.draw_line(x, y1, x, y2, color, thickness=6)
if opening.kind == "door":
poly = [wc(x, y) for x, y in self.door_clearance_polygon(opening)]
renderer.draw_polygon(poly, (217, 119, 6), thickness=2)
# 家具
if not self.furniture_hidden:
for item in self.furniture:
poly = [wc(x, y) for x, y in self.furniture_corners(item)]
fill = self.hex_to_rgb(item.color)
renderer.fill_polygon(poly, fill)
renderer.draw_polygon(poly, (55, 65, 81), thickness=2)
# 简单图标
cx, cy = wc(item.x, item.y)
if item.kind in ("washer",):
renderer.draw_circle(cx, cy, max(6, int(min(item.width, item.depth) * scale * 0.22)),
(82, 97, 107), fill=(232, 238, 243), thickness=2)
elif item.kind in ("stove",):
r = max(3, int(min(item.width, item.depth) * scale * 0.10))
for dx in (-0.18, 0.18):
for dy in (-0.18, 0.18):
px, py = wc(item.x + dx * item.width, item.y + dy * item.depth)
renderer.draw_circle(px, py, r, (34, 34, 34), thickness=2)
else:
# 画中心方向线
tip = self.rotate_point(item.x, item.y, 0, -item.depth / 2 + 0.08, item.rotation)
tx, ty = wc(*tip)
renderer.draw_line(cx, cy, tx, ty, (33, 33, 33), thickness=2)
renderer.save(path)
@staticmethod
def hex_to_rgb(hex_color: str):
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return (180, 180, 180)
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
if __name__ == "__main__":
PlannerV4().mainloop()