总图概念设计(site-planning)
把「规划条件 + 甲方需求 + 地形红线」变成一份可落地到 CAD 的概念总平面图,
并且在排布过程中就把国家规范的硬约束算进去,而不是画完再人工查。
Boundary(不做什么)
- 不做施工图深度:出的是概念总图(轮廓级),不含基础、结构、管网、竖向高程、边坡支挡。
- 不做日照分析:本机无日照间距系数与地方规定,此项一律标
manual。 - 不做竖向与土方:无等高线高程时,坡度类判据一律标
manual。 - 不替代地方标准:停车位配建、退线、绿地折算、人防、海绵城市指标均为地方口径,
本技能只给通用替代值并强制列入「待补规范清单」。 - 不联网:全部本机计算,不上传任何数据。
Core Workflow
输入 payload.json(规划条件 / 甲方需求 / 地形 / brief 描述性指令)
→ sp_dwg DWG 输入条件 → DXF(ODA 或中望 COM),可选前置步
→ sp_intent 描述性文字指令 → directives(认不出的句子原样回报,不猜)
→ sp_validate 契约校验 + 降级 + 白名单过滤
→ sp_geom 红线归一化 → 退线内缩 → 栅格化(S0–S1)
→ sp_layout 道路骨架 → 地块 → 建筑体块 → 出入口接驳 → 消防 → 停车 → 绿地(S2–S7)
├ 多栋落位保障:骨架 full→no_sub→ring 逐级简化 + 外轮廓试旋转,取「排得最多」那套
├ 道路:分环道/主轴/支路/接驳道四类**净增面积**(之和 = 道路总面积)+ 路面矩形 bands
├ 绿地:剩余可用格按连通域归并为地块,给出位置(东北/中部…)、范围、面积
└ 出入口:每栋选「指令指定朝向 → 接驳距离最短」的边设出口,铺 ≥4m 接驳道连到道路
→ sp_variants **一次出 10 个候选方案**(固定枚举,不是随机)→ 评分排序 → 取最高分作为主方案
→ sp_metrics 技术经济指标(唯一口径)
→ sp_rules SITE_RULES 30 条机算判定 + 待补标记
→ sp_dxf / sp_scr / sp_svg / sp_report 出图与报告(主方案 + 10 个方案各自一套)
→ sp_zwcad / zwcad_cli 连上中望 CAD,COM 直驱把图**画进去**并存 DWG
→ result.json
命令行:
"C:/Users/<user>/.workbuddy/binaries/python/versions/3.11.9/python.exe" \
scripts/siteplan_cli.py --in <payload.json> --out <输出目录>
输出目录:六件套(主结果)+ 方案01…方案10/ 子目录(每个方案独立 SVG/DXF/SCR/报告/result.json)
+ 方案对比.html(横向指标与平面图)+ variants.json(10 个方案的轻量摘要,供页面切换条用)。
六件套 = input.json / 总平面图.dxf / 总平面图.svg / 布图.scr / result.json / 总图设计报告.html。
中望 CAD 连接器(COM 直驱,真实落图)
sp_zwcad.py + zwcad_cli.py 把总平面图真的画进中望 CAD,而不是"把 DXF 丢给 CAD 打开":
PY=scripts/../python # "C:/Users/<user>/.workbuddy/binaries/python/versions/3.11.9/python.exe"
"$PY" scripts/zwcad_cli.py --action status --live # 连接器状态(探安装/注册表/进程 + 实测附着)
"$PY" scripts/zwcad_cli.py --action plan --result out/result.json # 只看绘制计划,不碰 CAD
"$PY" scripts/zwcad_cli.py --action draw --result out/result.json --out out \
--scale 1000 --target new [--no-save-dwg] [--no-zoom]
- 执行层做四件事:建 11 个图层(与
sp_dxf.LAYER_DEFS同源)→ 用AddLightWeightPolyline
/AddText/AddCircle逐条绘实体(几何与 DXF 同源,都读result.json的geometry)
→ZoomExtents→ 另存<out>/中望CAD绘制/总平面图.dwg。 - 图层/文字样式:
SP-RED / SP-SETBACK / SP-ROAD / SP-ROADCL / SP-BUILD / SP-PARK / SP-FIRELN / SP-FIELD / SP-GREEN / SP-ENTRY / SP-TEXT;文字样式SP-CN(simsun.ttc),否则中文不显示。 - 单位:
--scale 1000(默认,米→毫米,CAD 常规)/--scale 1(与 DXF 一致)。指令单内部一律「米」。 - 目标图纸:
--target new(默认,新建图纸,空白的DrawingN.dwg会复用;用户已开的图一律不碰)
/--target active(画进当前图纸)。 - 同名 DWG 正被 CAD 打开时(上一轮画完没关),另存会自动退避到
总平面图_<时间戳>.dwg,
并在dwg_note里说明 —— 不会因为文件锁而白画一场。
三个坑(改这块代码前必读)
- 点坐标必须
_midlSAFEARRAY(c_double).create([x, y, z])直传。
传list/tuple或VARIANT(VT_ARRAY|VT_R8, …)一律COMError -2147352567 发生意外;
中望只认裸 SAFEARRAY 指针,且点要 3 维。 - 附着只能
GetActiveObject(progid, dynamic=True)。dynamic=False会让 comtypes 为类型库
生成包装模块,中文 Windows 下以mbcs读回生成文件 →SyntaxError直接失败。 - 绘制几何不要用
Document.SendCommand()。 它同步、要求精确回车数,少一个换行就把 CAD
卡在命令中途(实测 PLINE 卡死);只用Add*实体接口。
桥接侧对应两个端点:GET /api/siteplan/cad/status[?live=1]、POST /api/siteplan/cad/draw
({scale, target, save_dwg, zoom, base_name});连接器单开短命进程执行(COM 是套间绑定模型,
长驻进程跨线程复用 COM 指针易挂起)。
base_name 为「底图」:把甲方地形图 DWG 原样打开当底图,再在上面叠画本方案(--base <dwg>)。
打开失败(文件被占/版本过高)会自动回退新建图纸,原因写入回执 base_note;doc_open 记录打开时的原图名。
DWG 输入条件(地形图 / 单体轮廓图)
甲方给的 CAD 条件图基本都是 DWG,而内部几何内核只吃 DXF(ezdxf 不解 DWG)。
sp_dwg.py 负责把 DWG 转成 DXF,两条引擎链路按优先级自动选:
| 引擎 | 触发 | 原理 | 本机状态 |
|---|---|---|---|
odafc |
装了 ODA File Converter | 命令行批量转(ODA_OUT_VER=ACAD2018/ODA_OUT_TYPE=DXF) |
未安装,装了即自动优先 |
zwcad |
本机有中望 CAD | COM 开图 → SaveAs(…, 13=DXF) → Close(False) |
可用,实测 2.6–6.1 s |
PY="C:/Users/<user>/.workbuddy/binaries/python/versions/3.11.9/python.exe"
"$PY" scripts/zwcad_cli.py --action convert --src 甲方地形图.dwg --out <输出目录> --engine auto
# → {ok, engine, dxf, ms, dwg_bytes, dxf_bytes, from_dxf, warn, err}
- 入口:
sp_dwg.convert(src, out_dir=None, engine="auto", timeout=180);已装 ODA 优先用它
(快且不弹 CAD),没有则回落中望;engine=odafc|zwcad可强制。 - 传进来就是 DXF 时走
passthrough(from_dxf=True,不动文件)。 MAX_DWG_BYTES = 200 MB,超限在开 CAD 之前拦截;DWG 版本过高(中望打不开)→ 报错,不硬试。- 转出的 DXF 是 AC1032,
sp_bld_dxf.parse_outline/_parse_terrain_redline可直接读。
上传即转、只用文件名(关键设计)
DWG 转换在桥接侧完成,浏览器↔桥接之间只传文件名,不传几十 MB 的 DXF 正文:
浏览器 --POST /api/siteplan/dwg?name=xxx.dwg (二进制体)--> 桥接
桥接:_safe_dwg_name 净化 → _looks_like_dwg 嗅探 → sha1 缓存命中?→ 开 CAD 转 DXF
DXF 落盘 <SP_OUT>/上传源文件/xxx.dxf(DWG 留档同名 .dwg,旁证 .sha1)
浏览器 --POST /api/siteplan {redline_dxf_upload:"xxx.dxf", outline_dxf_upload:[…]}--> 桥接
siteplan_cli._resolve_upload 按相对路径取文件 → 解析 → 出图
- 端点:
POST /api/siteplan/dwg?name=(二进制)、GET /api/siteplan/dwg/status(双引擎 +uploads清单)。 - sha1 缓存:旁证
<名>.dwg.sha1命中则直接复用已转 DXF(实测 0 ms,不再开 CAD)。 - payload 键名用
*_upload后缀 ——sp_validate._strip_paths会剥掉键名含path/dir/file/folder/root
的字段,上传系列键名必须避开这些词。 - 前端:
spFileKey / spUploadDwg / spReadCond,结果卡片显示「DWG 已转 DXF」与来源回溯(#spSrcNote)。
楼栋外轮廓文件选择器只收.dwg(accept=".dwg",标签「外轮廓 DWG」);地形图红线收.dwg,.dxf。
引擎侧仍兼容传入的 DXF 文本(兜底,界面上不宣传)。
三个坑(改这块代码前必读)
- 文件名要三重净化:桥接
_safe_dwg_name(只留末段 + 后缀统一小写)、
sp_validate._upload_name、siteplan_cli._resolve_upload(os.path.basename(name) != name一律拒)。
少一层就可能被../逃逸出输出目录。 - 体积上限必须在开 CAD 前拦。等中望打开一个 500 MB 的图再报错,用户要等很久且可能把 CAD 卡死。
- 别把 DXF 正文塞进 payload。几十 MB 基文本在桥接↔浏览器往返两趟会直接把页面卡住;
一律「上传即转、只传文件名」,DXF 由siteplan_cli自己从<out>/上传源文件/取。
多栋落位与路网骨架自适应(改这块代码前必读)
「先路后房」的致命前提是路网骨架不能比楼栋还密。build_roads 固定「外环 + 长边中线主轴干道 +
每 road.block_depth_m(默认 70m)一道支路」,会把瘦长用地的可建宽度一分为二:
实测 116×470 的红线(退线后约 106m)配 1 栋 52.5×185.4 的单体,长边中线主路把 106m 切成两半(各约 49.5m),
4 栋楼只排上 1 栋,其余全进 unmet。四项对策(都在 sp_layout.build / place_buildings):
- 骨架逐级简化:
build_roads(..., level=)支持三档 ——
full(环道 + 主轴干道 + 支路,原行为)/no_sub(去支路)/ring(仅环道)。
build()先按full排;只要还有unmet就依次试no_sub、ring,按(落位数, -未排数)取优,
严格更优才替换(同分保留骨架更完整的那套,避免无谓改动),排干净即 break。
环道任何一档都保留(GB 50016 7.1.2 消防车道);降级必须往warn写一条带档位名的说明
(不许静默改路网)。当前档位记在stats.road_level,三档中文名见_ROAD_LEVEL_NAME。 - 外轮廓试旋转:
place_buildings对带outline的楼栋先试原朝向(ow, oh),排不下才试(oh, ow);
旋转时多边形要跟着转([[x + (oh - (p[1] - ob[1])), y + (p[0] - ob[0])] …]),并记rec['rotated']。
原朝向优先是硬要求:既有测试锁死了w=30/h=20,改成旋转优先会把既有结论改掉。 - 高度以用户填写值为准:
sp_validate._owner透传height_m(兼容h_m),place_buildings优先用它,
未填才退回floors × floor_h_m。防火间距 / 登高场地判的是真实高度,不能被默认层高带偏。 - 未排布要说清差在哪:
unmet每条带need_m(该栋需的外接框)与max_block_m(当前最大地块),
build()据此生成「需 W×H m 外接框,最大地块仅 W×H m」的告警。
> 前端 index.html 结果卡指标表末尾同步显示「路网骨架」(读 geometry.stats.road_level,非 full 标黄)
> 与「未排布单体」两行。
描述性文字指令(brief → directives)
甲方/设计的意图常常是「主入口设在南侧、道路宽 6 米、冷库放东侧、绿地集中在西北角」这种话。
sp_intent.parse_brief(text, warn) 把它解析为 sp_layout.build(..., directives=) 能直接消费的 dict。
能认的(按固定规则顺序匹配,命中即给出 human 说明):
| 类别 | 写法示例 | 产出键 |
|---|---|---|
| 场地出入口方位 | 主入口设在北侧 / 出入口设在东北角 | entry_dirs |
| 道路宽度 | 道路宽 6 米 / 主干道宽 9m / 支路宽 4 米 | road_main_w / road_sub_w |
| 路网密度 | 不要支路 / 只设环形道路 / 加密支路 | road_level(+ block_depth_m) |
| 支路间距 | 支路间距 60 米 | block_depth_m |
| 楼栋朝向 | 楼栋南北向 / 建筑东西朝向 | orient |
| 建筑间距 | 楼间距不小于 10 米 / 加大楼间距 | gap_min_m / gap_extra_m |
| 退线 | 退线 8 米 | setback_m |
| 绿地率 | 绿地率 35% | green_min_pct |
| 地上车位 | 地上车位 30 个 / 停车全部地下 | parking_above / parking_underground |
| 绿地集中方位 | 绿地集中在西北角 | green_focus |
| 楼栋定位 | 冷库放在东侧 / 1# 布置在西北角 | bld_dir |
| 楼栋出口朝向 | 1#出口朝南 | bld_entry |
三条硬规矩:
- 认不出的短句原样进
unparsed,并往warn写一条「有 N 句未能识别,未参与布局」;
页面与报告都如实列出。宁可说不懂,也不猜 —— 猜错会排错图且用户无从察觉。 - 只覆盖指令确实提到的项,其余键不出现(
.get()取不到就退回规划条件 /site-defaults.json),
绝不把「没说」当成「用默认值覆盖规划条件」。 - 最易误伤的一处:「支路间距 50 米」说的是路网,不是楼间距。
_r_gap必须先排掉
(支路|路网|道路|消防车道|环路|环道)间距,否则会把建筑间距拉成 50m。
指令明确指定 road_level 时不再自动简化骨架(尊重甲方口径,排不下就如实告警 + 记 force_level 说明)。
结果经 result.json 的 directives: {raw, applied[], unparsed[], keys[]} 回传,前端 spRenderDirective 原样展示。
三个坑(指令解析,改这块代码前必读)
road_level是「强限定词优先」,不是「子句先后」。_CLAUSE_SPLIT会把
「不要支路,环形路网」切成两句,若按d[k] = v的后写覆盖语义,第二句的ring
会盖掉第一句的no_sub,结果与用户本意完全相反。故_r_road_level先在子句上扫
否定/加密词并锁定d['road_level'],后续子句一律不改写。- 「中部/中心」不是象限。「绿地集中在中部」若塞进
green_focus的方位列表,
_quadrant()永远匹配不上 → 指令静默失效。必须走独立键green_focus_center:
place_parking按到场地中心的归一化距离排序(中心块最后停车 → 剩下的自然集中),
green_patches(focus_center=True)按中心距离排序 —— 后者直接决定哪块地被
classify_greens()当作主景园。 - 矛盾整句不猜。「环形路网,多设支路」自相矛盾:
parse_brief入口做整句预检,
命中则整句进unparsed回报用户确认,而不是靠子句顺序决定结果。
另外,已被road_level吸收掉的邻句措辞不报 unparsed(报出来用户只会困惑「我都写了啊」)。
道路 / 绿地 / 楼栋出入口(位置 + 面积)
旧版只给「道路广场用地 X㎡」「绿地率 Y%」两个数,看不出在哪。现在三类要素都落到图上并进明细:
- 道路:
build_roads/place_building_entries/fire_provision都接同一个kind_r字典,
按ring / main / sub / access / fire各建一张分类栅格;_stamp_line只对首次成为道路的格计入本类
→ 各类面积之和恒等于道路总面积(交叉口不重复计,这个勾稽关系已写成断言)。
每条路另带bands(逐段路面矩形,供 DXF/SVG 填充)与area_m2(本条净增)。
⚠fire_provision补画的消防车道也必须传kind_r(归入fire),否则它占了road_r
却不计入任何一类,勾稽就断了。 - 绿地:
green_patches(green_r, min_area, site_bbox, focus=, focus_center=)把剩余可用格按连通域归并成地块,
输出id / area / bbox / center / size_m / pos(位置用「东北 / 中部」等相对方位描述)。
green.min_patch_area_m2(默认 60㎡)以下碎斑不单列;focus非空时指定方位的地块排最前;
focus_center=True时按到场地中心的归一化距离排(决定主景园落在哪)。
口径:集中绿地(各块之和) + 退线带绿地 = 绿地总面积,报告与前端都按此拆分。 - 楼栋出入口:
place_building_entries(S4.5,排在消防之前 —— 出入口是「必须有」,先占地)
对每栋取四条边中点作候选,按指令指定朝向 → 接驳距离最短 → N/E/S/W 固定序选一个,
从该点铺一条road.access_width_m(默认 4.0m,不足 3.5 抬到 3.5,兼顾消防车道最小净宽)的
接驳道到最近道路中心线。引道压到其它建筑的边(_seg_blocked)直接弃用;四边都不行则只标位置并告警。
接驳道长 >30m 会提示「偏长,建议增设内部道路」。kind='access'的道路不参与接驳目标计算
(否则 A 栋会去接 B 栋的引道)。
出图:sp_dxf 把接驳道画成路面矩形(色 30)+ 「接驳·X栋」、绿地逐块画外接框与面积、出口画圆点加
「X栋出口·东」;sp_svg 同要素(接驳道橙色、绿地浅绿虚线、出口方块);sp_report 新增
「场地要素明细」章节(指令回执 / 红线内道路 / 绿地布置 / 楼栋出入口与接驳 四张表)。
10 个候选方案(多方案比选)
同一份「楼栋尺寸 + 地块尺寸」本来就有多种排法。概念阶段的价值是把排法摆出来比,
而不是只交一个"看起来还行"的解。sp_variants.py 一次生成 10 个方案:
变体 = 4 个维度写死的枚举(sp_variants.VARIANTS,顺序不要动 —— 方案编号与报告、页面、测试对得上):
| 维度 | 取值 | 落在哪 |
|---|---|---|
路网骨架 level |
full 完整 / no_sub 去支路 / ring 仅环道 |
sp_layout.build_roads(level=),锁定后不再自动简化 |
落位部位 anchor |
sw / nw / ne / se / center / spread(spread = 尽量远离已落位楼栋) |
_search_in_block(anchor=),评分里占 0.9 权重 |
排布次序 order |
area_desc 大栋优先 / area_asc 小栋优先 / input 按输入顺序 |
place_buildings 的 demand 排序 |
朝向策略 rotate |
auto 原朝向优先 / none 不旋转 / force 优先转 90° |
place_buildings 的 size_plans 顺序 |
硬约束:确定性。不用 random,10 个变体是常量表 —— 同一输入跑一万次,方案 1 永远是方案 1
(test_sp_variants.TestVariantEngine.test_deterministic 断言两次跑的落位逐字节一致)。
否则上次选中的方案下次就变了,没法比选。
评分(score_of,权重明写、不黑箱):落位 +40/栋、未排布 −18/栋、规范不符合 −6/条、
人工复核 −0.5/条(概念阶段不可避免,轻罚)、绿地率达标 ±10、栋间距达标 +8/−12、
道路占地占比越接近 12% 越优(+6)、景观品质 0~14(landscape_bonus)。
排序后取最高分为主方案,其余进各自子目录。
landscape_bonus 四项按比例给分:树冠覆盖 6(30% 满分)/ 绿量丰度 3(100 株/ha 满分)/
主景园 3(按完整度给分) / 园路长度 2(200m 满分)。
⚠ 主景园那 3 分必须看完整度:环形路网变体会把建筑间空地连成碎形连通域
(20773㎡、完整度 0.57),只按面积判就给它满分,结果「四面楼房围着 3278㎡ 水面、
园路只有 50m」的方案胜出。旧数据无 compactness 字段时按 1.0 处理(向后兼容)。
输出:<out>/方案NN_骨架-部位-次序[-转90]/ 下放 总平面图.svg · 总平面图.dxf · 布图.scr ·
总图设计报告.html · result.json;<out>/方案对比.html 横向比指标 + 逐方案平面(<img> 引相对路径);
<out>/variants.json 是 10 个方案的轻量摘要。所有方案共用 siteplan_cli._make_res,
口径与主方案完全一致(比选的前提是数字可比)。
页面:结果卡顶部「候选方案」切换条,点一下即换该方案的预览 / 报告 / 指标 / 核查;
「📊 方案对比页」把对比页装进报告 iframe(平台内看,不开外部浏览器)。
桥接两个新端点:GET /api/siteplan/variant?dir=<子目录>(取该方案 result.json)、
GET /api/siteplan/file?name=svg&sub=<子目录>(取该方案的图);sub 做了纵深净化
(只认 SP_OUT 下的直接子目录名,含 .. / : / / / \ 一律拒绝)。
⚠ 单方案(不传 variant)行为完全不变:仍走「骨架逐级简化直到排得下」的旧路径。
⚠ 指令锁定骨架时优先级为 指令 > 变体 > 自动简化:此时 10 个方案都在该骨架下变,只换部位/次序/朝向。
建筑构图评分(让总图"看起来是设计过的")
规范解不止一个。sp_layout._composition_score() 在落点搜索时叠一层美学评分,
让引擎在同等合规的解里挑出构图更好的那个:
| 分项 | 权重 | 判据 |
|---|---|---|
| 对位 | 0.5 | 建筑长边与地块主轴平行 |
| 均质 | 0.25 | 与已落位楼栋的净距接近平均值(避免孤零一角) |
| 向心 | 0.25 | 靠边布置,把中心让给绿地(对 _search_in_block 的 anchor 偏好弱化) |
总权重 0.75 < 「放得下」的基线 1.0 —— 这是刻意的:构图只能在合规解里优选,
绝不放松任何规范间距。compose=False 可整体关闭退回旧行为(build(..., compose=) /
place_buildings(..., compose=) / _search_in_block(..., compose=) 逐层透传)。
stats.compose_on 留痕。
⚠ 构图开启后,单维度变体扰动几乎拉不开差距(score 主导项相同),_collision_fixes 因此
新增「关构图 + 换 anchor/order」的组合扰动维度。
园林化景观层(绿化做到园林效果)
scripts/sp_landscape.py —— 在布局与绿地归并之后叠加一层景观(S7.5 段)。
纯叠加图层:只读栅格、绝不回写,所以绿地率 / 建筑面积 / 道路面积的口径完全不变
(test_sp_landscape.TestNoSideEffect 逐项断言开/关指标相等)。
绿地角色分区(园林的主次骨架)
classify_greens() 把碎形绿地连通域分成 5 种角色,各有不同配植手法:
core 主景园(最大、最完整、有水面)/ cluster 组团绿地 / belt 防护林带 /
edge 边界绿地(密植屏障)/ scratch 零星绿地(点缀)。
⚠ 主景园不按面积最大选,要过三道判据(classify_greens):
- 完整度:
_compactness(g) = 面积 / 外接框面积,门槛CORE_COMPACT_MIN = 0.62。
原因:环形路网(无支路切分)时建筑之间的剩余空地会连成一整片碎形连通域 ——
实测 20773㎡ 但完整度只有 0.41;把它当主景园会导致水面 3278㎡ 却园路全落不下。 - 形状:
_shape_ok(g)要求外接框最短边 ≥CORE_MIN_SIDE_M= 12m。
⚠ 纯几何完整度识别不了细长绿带:一条 74m × 2.5m 的横向隔离带,185㎡/185㎡
= 完整度 1.000(满分),实测把 10362㎡ 的真主景园顶掉了。园林主景的物理条件
是「放得下水面与环园路」,狭长绿带只能做防护绿带/行道绿带。 - 规模:候选面积须 ≥ 最大绿地的
CORE_AREA_RATIO= 0.25。
防止「完整度刚过线的小绿地」压过「面积大数倍但略碎的大绿地」——后者才是园林主景。
三道都过 → 按(完整度↓,面积↓)排序取第一;一个都没过 → 按面积降级选取,
并打 _core_degraded 标记让调用方如实告警(绝不出现「全场无主景」)。
分组用索引而非 dict 本身(两个连通域可能 area/bbox 完全相同,按值比较会混淆)。
报告的绿地角色表列出完整度,便于人工复核选块是否合理。
配植口径(真实设计经验值,不是随手撒点)
- 乔木按株/ha 而非按面积比例:主景园 70、组团 110、边界 140(
site-defaults.json可调)。 - 行道树只沿环路/主干道,间距 10m、退线 2.5m;停车场遮荫树间距 12m;防护林带 6m、内缩 2m。
- 绿篱/灌木层(
_shrub_layer,园林「乔灌草」的中间层,缺了它绿化就是散的):
建筑周边(沿两条长边内退 1.5m)、园路两侧(退width/2 + 1.3m)、水岸(外退 1.5m),
株距 1.2m,上限hedge_max_count=900。去重网格必须按层分档:
乔木 3.0m(冠幅尺度)、绿篱 1.0m(栽植株距)——共用 3.0m 会把绿篱整排吃掉。 - 树冠覆盖率是双向指标,且阈值按绿地率折算:下限
max(8, min(配置15%, 实际绿地率×0.40))、
上限max(配置45%, 下限×2)。居住区经验带 20~40%,但工业/仓储地块绿地率下限
本就只要 10~20%(GB 50187),在那种地块上要求 15% 覆盖率是误报 ——
用地本来就该硬质化,把露天种满乔木反而妨碍装卸与消防扑救。8% 是绝对地板,
保证「乔木几乎为零」这种真问题一定会被报出来。
必须算并集——Σπr²会把重叠树冠重复计入导致虚高,
故_canopy_union()用 2m 格网做布尔并(按 x 分桶,避免 O(格×树))。
且只统计乔木层,绿篱不计入遮荫(它挡视线不遮荫)。 - 冠幅按常见成年乔木冠径之半:孤赏大乔 4.5 / 遮荫乔 3.2 / 组团乔 3.0 / 行道 2.5m(半径)。
- 层次:上层乔木 + 行道亚乔 + 灌木绿篱(
TREE_SPECIES七类,分canopy/sub/hedge三层)。 - 限量也按层分开(
MAX_TREES=1400只管乔木,HEDGE_LIMIT=900只管绿篱)——
合并截断会把绿篱整片砍掉。 - SVG 渲染:乔木逐株
<circle>,绿篱合并成单个<path>(半径/样式固定,多段子路径),
否则几百株 circle 会把 SVG 撑到 MB 级。
空间序列:到景线
入口集散广场 → 园路 → 主景园(置水面,借景倒影加倍景深)→ 环园路(可停留可游览)。
place_building_entries 排在消防车道之前;kind='access' 不参与接驳目标计算。
三个坑(改这块代码前必读)
- 不能用外接框中心判落位。绿地块是碎形连通域,外接框中心常压在道路/建筑上;
园路是环形,中心根本不在环内 → 表现为"水面和园路恒为 0"。
必须用_fit_ratio():按 1.5m 格网采样算图形实际落在空地上的面积占比。 Raster定义了__slots__,挂不了_occ属性 → 占地栅格
occ=(road_r, build_r, park_r, fire_r)由sp_layout显式传参。- 入口广场固定位置必然落空(主入口处必有车行道)。要「进深 × 面宽」双维度搜索、
取最优解而非首个达标解,并用绝对下限plaza_fit_floor=0.62判定
—— 不能因为没有完美位置就不设广场。
三个坑(大绿块场景,改这块代码前必读)
小样例(2 万㎡、2 栋)全绿,大绿块(4.6 万㎡、6 栋、环形路网)里散着 6 栋楼 + 道路,
落位阈值稍严要素就会整体消失。三个坑都是这么暴露的:
| 要素 | 阈值问题 | 修法 |
|---|---|---|
| 水面 | 先卡「≥ 目标比 × 45%」再找落位;但 30% 收缩的几何上限本身只有 389㎡ < 门槛 447㎡ → 全部 continue |
改为枚举尺寸档 × 落位,取面积最大的达标者;面积门槛改在找到之后判(floor = area * ratio_eff * 0.10)。_fit_ratio 的 need 由 0.90 降到 0.70(主景园里散布着场地与园路,边缘被切过正是"有驳岸"的样子) |
| 园路 | 环园路是四条边带,190×190m 绿块任何一圈都有大半压在建筑上 → 整体落不下 | 按 fit 降序在多档内缩里取最优;仍不达标时降级为曲尺形游步道(只取上、左两条边),园林上同样成立 |
| 宅前场地 | 只试两条长边 + 固定尺寸 + need=0.85 → 6 栋全落空,铺装只剩入口广场 280㎡ | 四条边全试 × 三档尺寸,need 降到 0.70,取落位最好的一处 |
另:入口广场的横向维度不可省。主入口坐标常落在用地边缘(如 y=5.0),
只沿法向推进 11×6m 越不过车行道 + 落客带 + 绿篱带时最优解为 0,广场直接不生成。
三个坑(industrial 地块实测——低绿地率会暴露居住区口径的假设)
sp_landscape 最初是按住宅区调的参数,拿到 4.7 万㎡ / 绿地率下限 10% 的工业园区
一跑就出三处问题,都是「数据在、图上有、报告里看不见」这类隐性错误:
metrics必须自描述。enabled只写在返回 dict 的顶层、没写进metrics里,
而sp_report判的是lsm = ls['metrics']再取.enabled→ 拿到None→
整个「景观设计(园林化)」专节被跳过,而sp_metrics走的是顶层取法所以指标行照常出现。
表现:result.json 里有景观数据、SVG 上画了树和水面、HTML 报告里却找不到景观章节。
→metrics字典第一行就写'enabled': True,_empty_metrics()写False。
测试test_report_has_landscape_section专门锁这个(当时它还顺带抓出了下一个 bug)。fmt_tree_line()里的fmt_num未定义。from sp_metrics import fmt_num写在
landscape_rows()函数体内,fmt_tree_line()是另一个作用域 →NameError。
被坑 1 掩盖了一整天:专节从没渲染过,所以这段代码从没被执行过。
→ 两个函数各自 import(不能提到模块级:sp_metrics会 import 本模块,构成循环依赖)。- 细长绿带能凭满分完整度抢走主景园。
74m × 2.5m的横向隔离带,
185㎡ / 185㎡ = 1.000(完整度满分),把 10362㎡ 的真主景园顶掉了。
纯几何「面积/外接框面积」识别不了细长条 → 增加_shape_ok()最短边 ≥12m 的形状判据,
并配套CORE_AREA_RATIO = 0.25的规模判据(详见上文「主景园三道判据」)。
必须再加横向偏移 ±2 档(lane * (w*0.75 + 3.0))——那里正是红线内退让带,
集散广场该在那儿。
确定性
禁用 random,铺点用等距步进 + R2 低差异序列
(a1=0.7548776662466927, a2=0.5698402909980532)。上限 MAX_TREES=1400、
去重间距 TREE_DEDUPE_M=3.0、单绿地角色上限 6 处。
贯通链路
sp_layout(S7.5) → sp_metrics(m['landscape'] 透传 + 条件追加 8 行指标,
禁用时仍 27 行,向后兼容)→ sp_svg(铺装→园路→水面→乔木 分层渲染 + 左上图例)→
sp_report(绿地布置节后新增「景观设计(园林化)」:绿地角色表 + 树种统计 + 水/路/铺装)→
sp_variants(landscape_bonus 0~14 分:树冠覆盖 6 / 绿量丰度 3 / 主景园 3 / 园路成环 2)→
siteplan_cli(geometry.landscape 透传)。开关 build(..., landscape=True)。
⚠ 变体去重必须用双签名:_layout_sig(排序 + 完整 bbox,几何占位判重)
加 _order_sig(不排序 + 起点角点,与页面呈现一致),以 _order_sig 为权威键。
只按 bbox 排序判重会漏判「角点相同但进深不同」的两个方案,导致不足 10 个。
性能:大 DXF 的代理对象剔除
真实地形图(DWG→DXF)里 ACAD_PROXY_OBJECT 动辄十万个:实测一份 85MB / 704 万行 / 350 万组码的
地形图里有 101,597 个,ezdxf 纯 Python 解析要 ~150s;而代理对象的内容对 ezdxf 本就不透明,
对轮廓提取毫无用处。sp_bld_dxf._read_doc 因此对 > 3MB 的 ASCII DXF 先流式剔除代理组
(_trim_proxies:按「组码 0 + 类型名」切组丢弃,命中即跳过该组全部组码,其余原样保留),再交给 ezdxf。
- 实测 85MB → 12.8MB、~150s → 13.7s,解析结果与不剔除完全一致(面积 / bbox / 候选环数不变)。
- 二进制 DXF(以
AutoCAD Binary DXF开头)不能按行拆组码,_is_ascii_dxf先判后跳过剔除。 - 剔除失败静默退回原文件,绝不因优化丢数据。
Runtime
- Python
3.11.9(python),ezdxf 1.4.4 - 除此之外零第三方依赖(几何/栅格/规则全部标准库实现);连中望走
comtypes 1.4.16(受管解释器自带,不需要 pywin32) - ZWCAD:
<本地路径> Files\ZWSOFT\ZWCAD 2026\ZWCAD.exe,/b必须置于参数末位 - COM ProgID:
ZWCAD.Application/ZWCAD.Application.2026(环境变量WB_ZWCAD可覆盖 exe 路径) - DWG→DXF(可选):装了 ODA File Converter 会自动优先使用,
路径可用环境变量WB_ODAFC覆盖;未装则退回中望 COM,两者都没有 → 转换失败并提示「请安装 ODA 或先另存 DXF」 - 全部输出 UTF-8;
.scr例外,必须纯 ASCII
三条硬约束(改动时不要破坏)
- 指标唯一口径:
sp_metrics.py一处算,HTML 报告 / DXF 指标表 / SVG 图签三处同用,禁止各算。 - 确定性:布局搜索禁用
random,枚举顺序固定,score相同取先者 —— 否则回归无法断言。 - 免责印记四处同步:HTML 报告每页页眉、SVG 右下角、DXF 图签栏、前端结果卡片顶部红条。
文案固定为「本方案依据为通用规范(GB 55031 / GB 50352 / GB 50016 / GB 55019),
日照间距系数与地方停车位配建标准需人工补充;不得直接作为报审依据。」
红线为假设矩形时,追加「红线为假设矩形,非真实地形,仅用于指标测算」。
参考文件
references/site-rules.json— 30 条机算规则references/site-defaults.json— 全部可调参数(改这里,不要改代码里的魔数)references/pending-specs.md— 待补规范 12 条(UI 面板数据源)references/dxf-layer-map.md— 图层 / 颜色 / 线型(GB/T 50103-2010)references/layout-algorithm.md— 算法说明与调参指南
测试
PY="C:/Users/<user>/.workbuddy/binaries/python/versions/3.11.9/python.exe"
"$PY" -m unittest discover -s tests -v # 16 组共 373 例(约 39 分钟)
- 纯逻辑组:
test_sp_geom / test_sp_layout / test_sp_metrics / test_sp_rules / test_sp_dxf - 景观组:
test_sp_landscape(56 例) ——TestNoSideEffect(开/关景观层指标逐项相等、异常降级不崩)、
TestDeterminism(两次运行逐字段一致、源码无random/import time)、
TestContent(树量密度 40~160 株/ha、覆盖率在可信带、树不压建筑/道路、主景园最大、园路水面广场存在、
metrics 自带enabled、低绿地率不误报遮荫)、
TestRobustness(无绿地/极小地块/无楼栋/空栅格)、TestMetrics(透传、指标行增补、禁用时仍 27 行)、
TestComposition(构图不改栋数与间距、评分 [0,1]、偏好轴线对位与靠边)、TestSvg(渲染树/水/园路/铺装/图例、
绿篱合并成单个 path、报告必须真出现景观专节)。
⚠ 并集面积断言要留量化余量(≤ Σπr² × 1.15 + 1)——2m 格网离散误差约 ±格数×格面积。 TestCoreSelection(3 例) 锁主景园三道判据,最关键的一条是
细长绿带(74m×2.5m,完整度 1.000)不能当主景——这是工业地块实测踩出来的。TestLargeGarden(12 例) 是大绿块场景的专属闸门(4.6 万㎡ / 6 栋 / 环形路网 / 绿地集中在中部):
主景园唯一、完整度 ≥0.62 且面积不悬殊、完整度字段逐块给出、水面存在且占比 <25%、
园路存在(环园路或降级游步道)、宅前场地覆盖 n−1 栋以上、入口广场存在、
三层植栽均有量、用地指标不受景观层影响、两次运行一致、
完整度足够时不告警 / 碎形主景园时必须告警。
⚠ 小样例全绿不代表大绿块也全绿 —— 改落位阈值后必须跑这一组。- 指令组补充:
test_sp_intent(42 例)含「环形路网」名词短语识别与跨子句矛盾预检
(「环形路网,多设支路」整句判为不可解析,不靠子句先后决定结果)。 - 输入条件组:
test_bld_dxf(外轮廓 + 代理对象剔除)、test_terrain_dxf(地形红线)、test_dwg_input(DWG 上传)、test_sp_dwg(DWG 转换) - 指令组:
test_sp_intent(38 例:各类指令解析、确定性、未识别回报、误伤回归——支路间距≠楼间距) - 交叉组:
test_consistency(多口径勾稽)、test_parking_above(地上车位强制) - 连接器组:
test_sp_zwcad(绘制计划/DXF 同源,--live走真 COM) test_sp_layout里与多栋落位相关的类:TestRoadSkeletonLevel(骨架三档)、TestOutlineRotation(原朝向优先/旋转/未排布差值)、
TestHeightPassthrough(height_m透传)、TestMultiBuildingPlacement(4 栋全落位 / 计数一致 / 含旋转仍确定)test_sp_layout里与场地要素相关的类:TestRoadAreaByKind(分类面积之和 = 总面积)、TestGreenPatches(地块/位置/碎斑过滤/集中方位/中心优先 + 确定性)、
TestBuildingEntries(每栋有出口 + 接驳道 ≥4m + 指令朝向)、TestDirectivesEndToEnd(指令走 CLI 全链路生效 + 未识别回报)、
TestSiteElementsInResult(结果含三张明细、分类面积勾稽、指标表新增行)- 多方案组:
test_sp_variants(29 例)——TestVariantTable(10 个变体、key/目录名唯一、字段合法、无 random)、
TestVariantEngine(10 个方案都出得来、两次跑落位一致、按分降序、骨架等级被锁定、
anchor 真的改变落点、评分惩罚未排布、stats.variant留痕)、
TestVariantsEndToEnd(CLI 真的写出 10 个子目录 + 对比页 + 恰好 1 个推荐)、
TestLandscapeBonus(景观加分 0~14、规整主景园得分必须高于同面积碎形主景园、园路长度分差、缺compactness向后兼容) test_dwg_input与test_sp_dwg里带live标记的用例走真实 DWG↔中望,默认跳过,
设WB_LIVE_CAD=1才跑(需本机装中望且未被占用)。- 耗时提醒:CLI 子进程用例占大头(38 次子进程 ≈ 173s,其中
import ezdxf就 5s)。
纯逻辑用例请直接调函数(如L.build(norm, defaults, env))而不是跑 CLI,可省掉进程启动开销。
<!-- PAYGATE:BEGIN -->
付费与交付(X402 微信 Agent Pay · 微信支付 AI 专属卡 · ¥0.01/次)
费用由用户直接支付给服务提供方。SkillHub 仅提供展示、投诉处理及平台服务,不作为收款方。请在支付前确认服务内容、计费单元、单价与退款规则。
- 单价:¥0.01 / 次(以支付网关价目表与 SkillHub 后台配置为准)
- 计费单元:每次完整服务需求,支付确认后开始服务
- 免费操作:支付前的需求沟通、订单状态查询
Agent 行为准则
- 首次调用返回 402 时,原样向用户出示支付信息并申请授权;不得自行判定支付成功,不得改用免费方式替代履约。
- 牢记
out_trade_no(订单号),支付后用同一订单号重试,不会重复扣费。
红线
- 未拿到
status=paid收据前,严禁执行任何实质性服务、严禁产出结果。 - 所有支付只通过
node scripts/client.js,严禁手动调用微信支付接口。 - 严禁向用户展示/朗读 payment_code、transaction_id、私钥等敏感字段。
- 设备不满足要求时只输出引导并停止,严禁绕过支付。
付费四步(X402 协议)
- 前置检查:当前 Agent 是否具备
weixinpay_pay能力,不具备则终止并提示。 - 请求资源:
node scripts/client.js require --skill "site-planning" --subject "<需求简述>",未付返回 402,含WeixinPay-Required与X-Out-Trade-No。 - 同时保存“支付码”和“订单号”;系统自动弹出微信支付 AI 专属卡,用户手机授权扣款(Agent 不得自行判断成功)。
- 原样重试 require(CLI 自动带
X-Out-Trade-No),服务端查单到账返回status=paid收据后,才开始履约。
订单状态免费查询(免费、幂等):node scripts/client.js status --out-trade-no "<订单号>"
<!-- PAYGATE:END -->