数智星河 发表于 2026-7-10 16:58:19

[Windows] 智能房屋区域与家具布局器 V3

先上链接再聊其他https://wwart.lanzout.com/b01d71vhcj 密码:52pj

客厅杂乱,于是我在上班摸鱼的时候做了这个工具,方便上班时多模拟摆放几次家具,
我本来想发布在原创,奈何AI写代码总版主不认,于是我在软件标题处增加论坛网址,发到精品软件总可以了吧
实测会报毒,但无毒!不需要联网!不需要登陆!无任何信息采集!
软件智能根据房间类型摆放家具,并非AI思考,而是根据家具摆放逻辑计算得出
功能是我使用不畅慢慢推敲逐渐增加的,目前已经满足我自身使用,留下python代码后不再更新


主要功能:
1. 手动或自动规划房间区域
2. 选中区域后直接修改位置和尺寸,拖动角点缩放
3. 重叠区域自动尝试挤到旁边
4. 增加、删除门窗,可设置所在墙、位置和宽度
5. 一键隐藏/显示全部家具
6. 选中家具后直接修改尺寸、位置和角度
7. 拖动家具时若与其他家具重叠,自动把其他家具挤开
8. 一键自动分散家具,尽量保证不重叠
9. 推荐家具尺寸根据所属区域自动缩放,并统一尝试放入
10. 家具带矢量俯视图标和连续旋转手柄

说明:
自动布局是启发式参考方案,不能替代建筑、消防、水电和施工设计。





查看代码:
#!/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 = 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 - 吾爱破解论坛 www.52pojie.cn")
      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 = []
      self.furniture: list = []
      self.openings: list = [
            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 = None
      self.selected_furniture_id: Optional = None
      self.selected_opening_id: Optional = None

      self.furniture_hidden = False
      self.region_draw_mode = False
      self.interaction_mode: Optional = 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 = 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 =
      if not selected:
            messagebox.showinfo("提示", "请至少勾选一种区域类型。")
            return

      specs = [
            (name, *self.REGION_TYPES)
            for name in selected
      ]
      priority = {
            "客厅": 0, "餐厅": 1, "厨房": 2,
            "主卧": 3, "次卧": 4, "书房": 5,
            "卫生间": 6, "阳台": 7, "储物间": 8, "其他": 9,
      }
      specs.sort(key=lambda item: priority.get(item, 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
            return [(name, kind, color, x, y, width, depth)]

      total = sum(item for item in specs)
      target = total / 2
      running = 0
      split = 1
      for index, item in enumerate(specs[:-1], start=1):
            running += item
            split = index
            if running >= target:
                break

      first, second = specs[:split], specs
      ratio = sum(item 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:
      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)
      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
      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 =
      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 = []
      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
    ) -> Optional]:
      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 - region.x, point - 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 =
      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) + abs(move))

                moved = False
                assigned =
                assigned_old = [(item, item.x, item.y) for item in assigned]

                for move_x, move_y in moves:
                  other.x = old_state + move_x
                  other.y = old_state + move_y
                  self.clamp_region(other)
                  actual_dx = other.x - old_state
                  actual_dy = other.y - old_state
                  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,
                  )
                  if free is None:
                        return False
                  dx = free - old_state
                  dy = free - old_state
                  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:
      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
      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)
      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
                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, region: Region, dense=False
    ) -> bool:
      placed: list = []
      ordered = sorted(items, key=lambda item: item.width * item.depth, reverse=True)

      for item in ordered:
            best = None
            best_score = float("inf")
            angles =
            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, list] = {}
      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 =
      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
    ) -> Optional]:
      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 - item.x, p - 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 =
      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
      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 =
      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)
            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 - point, nxt - point
            length = math.hypot(ex, ey)
            if length > 1e-9:
                axes.append((-ey / length, ex / length))
      return axes

    @staticmethod
    def project_polygon(polygon, axis):
      values = + y * axis 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.05)
            item.y = self.snap(wy - self.drag_offset, 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.05)
            region.y = self.snap(wy - self.drag_offset, 0.05)
            self.clamp_region(region)

            original_regions, original_furniture = self.drag_snapshot
            original_region = next(state for state in original_regions if state == region.id)
            dx = region.x - original_region
            dy = region.y - original_region
            for item in self.furniture:
                original = next((s for s in original_furniture if s == item.id), None)
                if original and original == region.id:
                  item.x = original + dx
                  item.y = original + 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 == 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
      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.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
                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 = counts.get(region.name, 0) + 1
            suffix = f" {counts}" if counts > 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,
                  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 =
            self.canvas.create_polygon(
                coords, fill="", outline="#d97706", dash=(5, 4), width=2
            )

    def draw_furniture(self) -> None:
      for item in self.furniture:
            polygon =
            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 =
      return self.canvas.create_polygon(coords, **kwargs)

    def local_line(self, item, points, **kwargs):
      coords =
      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
      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": ,
            "furniture": ,
            "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 = )]
            self.furniture = )]
            self.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 =

    def set_pixel(self, x: int, y: int, color):
      if 0 <= x < self.width and 0 <= y < self.height:
            offset = x * 3
            self.pixels = 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
            for x in range(left, right + 1):
                offset = x * 3
                row = 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
                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)))
                end_x = min(self.width - 1, int(math.floor(intersections)))
                row = self.pixels
                for x in range(start_x, end_x + 1):
                  offset = x * 3
                  row = bytes(color)

    def draw_polygon(self, points, color, thickness=1):
      if len(points) < 2:
            return
      for i in range(len(points)):
            x1, y1 = points
            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, prev, 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 - 吾爱破解论坛 www.52pojie.cn")
      self.status_var.set("可一键生成多套规划方案,并导出 PNG")

    def build_ui(self):
      super().build_ui()

      # 顶部工具栏是第一个子组件
      toolbar = self.winfo_children()

      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": ,
            "furniture": ,
            "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 = )]
      self.furniture = )]
      self.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.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.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.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 =
      if not selected_names:
            messagebox.showinfo("提示", "请在“房间区域”中勾选至少一种区域类型。")
            return

      self.status_var.set("正在生成多套规划方案,请稍候……")
      self.update_idletasks()

      base_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": ,
                "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)
      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
            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, 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
            return [(name, kind, color, x, y, width, depth)]

      total = sum(item 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
            split_index = idx
            if running >= target:
                break

      left_specs = specs[:split_index]
      right_specs = specs
      left_ratio = sum(item 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
                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 =
            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:
                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 =
                renderer.draw_polygon(poly, (217, 119, 6), thickness=2)

      # 家具
      if not self.furniture_hidden:
            for item in self.furniture:
                poly =
                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, 16) for i in (0, 2, 4))


if __name__ == "__main__":
    PlannerV4().mainloop()



微信图片_2026-07-03_114012_073.png (72.91 KB, 下载次数: 1)
https://attach.52pojie.cn/forum/202607/03/135433zdp3hd4cl88opps3.png
微信图片_2026-07-03_134822_084.png (99.93 KB, 下载次数: 0)
https://attach.52pojie.cn/forum/202607/03/135436cowqjjqajmjseojl.png
微信图片_2026-07-03_134843_606.png (116.7 KB, 下载次数: 1)
https://attach.52pojie.cn/forum/202607/03/135438dmzzz3ee000i5gtd.png



页: [1]
查看完整版本: [Windows] 智能房屋区域与家具布局器 V3