From eef9efcc1e776111996d20a459412fb7f59c39f8 Mon Sep 17 00:00:00 2001 From: laluo <2135665716@qq.com> Date: Thu, 6 Aug 2026 18:15:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E4=B8=80=E7=BA=A7?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E7=BC=96=E8=BE=91=20UI=20=E4=B8=8E=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 125 ++- scripts/probe_property_panel_usability.py | 294 +++++++ scripts/probe_transient_windows.py | 275 +++++++ scripts/verify_associated_features.py | 27 +- scripts/verify_boss_edit_suite.py | 46 ++ scripts/verify_boss_first_level_topology.py | 138 ++++ scripts/verify_boss_resize.py | 10 + scripts/verify_edge_coordinate_edit.py | 4 + scripts/verify_edge_first_level_topology.py | 1 + scripts/verify_edge_length_resize.py | 2 + scripts/verify_edge_round_chamfer.py | 25 + scripts/verify_face_edit_suite.py | 8 + scripts/verify_first_level_acceptance_docs.py | 82 ++ scripts/verify_first_level_edit_suites.py | 167 ++++ scripts/verify_hole_slot_edit_suite.py | 62 ++ scripts/verify_property_card_editor_ui.py | 256 ++++++ scripts/verify_property_editor_specs.py | 66 +- scripts/verify_round_chamfer_edit_suite.py | 42 + scripts/verify_shell_edit_suite.py | 42 + scripts/verify_shell_thickness_resize.py | 24 + step_editor/app.py | 206 ++++- step_editor/features.py | 13 + step_editor/model.py | 2 + step_editor/operations.py | 628 +++++++++++--- step_editor/polydata.py | 73 +- step_editor/ui_helpers.py | 5 + step_editor/window_actions.py | 2 +- step_editor/window_core.py | 688 +++++++++++++++- step_editor/window_state.py | 772 +++++++++++++++++- 29 files changed, 3907 insertions(+), 178 deletions(-) create mode 100644 scripts/probe_property_panel_usability.py create mode 100644 scripts/probe_transient_windows.py create mode 100644 scripts/verify_boss_edit_suite.py create mode 100644 scripts/verify_boss_first_level_topology.py create mode 100644 scripts/verify_first_level_acceptance_docs.py create mode 100644 scripts/verify_first_level_edit_suites.py create mode 100644 scripts/verify_hole_slot_edit_suite.py create mode 100644 scripts/verify_property_card_editor_ui.py create mode 100644 scripts/verify_round_chamfer_edit_suite.py create mode 100644 scripts/verify_shell_edit_suite.py diff --git a/README.md b/README.md index d950a13..e0d22ac 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。 - 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回。 - 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 -- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入 Edge、槽、孔、凸台、圆角等特征;不要每类只做一点。 +- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入 Edge,最后推进凸台、圆角、倒角、壳体等特征;不要每类只做一点。 - 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 ## 怎么运行 @@ -68,14 +68,37 @@ python main.py assets\models\cube_10mm.step python scripts\generate_cube_step.py ``` -10. 验证几何修改基线。当前推进和测试优先级是先 Face,再 Edge、槽、孔等其它特征: +10. 验证几何修改基线。当前推进和测试优先级是先 Face,再孔/槽,再 Edge 和其它特征。完整一级编辑回归可以用一个入口串起来: + +```powershell +python scripts\verify_first_level_edit_suites.py +``` + +只想先跑轻量检查时: + +```powershell +python scripts\verify_first_level_edit_suites.py --quick +``` + +`--quick` 会检查 smoke test、属性表规格、参数卡片 UI、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。 + +只验证某个阶段时,例如 Edge: + +```powershell +python scripts\verify_first_level_edit_suites.py --stage edge +``` + +也可以按阶段手动运行: ```powershell python scripts\verify_face_edit_suite.py python scripts\verify_isolated_face_edit.py +python scripts\verify_hole_slot_edit_suite.py python scripts\verify_edge_edit_suite.py python scripts\verify_edge_isolated_edit.py -python scripts\verify_shell_thickness_resize.py +python scripts\verify_boss_edit_suite.py +python scripts\verify_round_chamfer_edit_suite.py +python scripts\verify_shell_edit_suite.py python scripts\verify_analytic_surface_resize.py python scripts\verify_boss_resize.py python scripts\verify_hole_resize.py @@ -90,6 +113,14 @@ python scripts\verify_cylindrical_first_level_topology.py C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py ``` +如果用 `conda run` 跑验证脚本时出现 Conda 自身的 `UnicodeEncodeError`,先设置 UTF-8 输出再重跑: + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:PYTHONUTF8='1' +C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python scripts\verify_first_level_edit_suites.py --quick +``` + ## Windows 文件夹版打包 如果你要把软件发给没有 Python 环境的人,当前先打包成文件夹版。推荐先激活 `pyocc`,再运行打包脚本: @@ -192,7 +223,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 能导出选中的零件。 - 能撤销/重做编辑。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。 -- 当前开发顺序固定为先集中完成 Face 修改能力,再进入 Edge、槽、孔、凸台、圆角和其它解析曲面;每一类要先做到可操作、可解释、可测试,再进入下一类,避免每类只做一点导致测试分散。 +- 当前开发顺序固定为先集中完成 Face 修改能力,再进入孔/槽,再进入 Edge,最后推进凸台、圆角、倒角、壳体和其它解析曲面;每一类要先做到可操作、可解释、可测试,再进入下一类,避免每类只做一点导致测试分散。 ## 能改哪些,一分钟版 @@ -246,6 +277,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 特征 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 - 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 +- 大模型的 VTK 显示网格带有预算保护:即使编辑后请求较细的显示 deflection,也不会对上千个 Face 的 STEP 无限制生成百万级圆柱三角面;B-Rep 几何编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。只显示少量 Face 或隔离选中对象时仍可局部细化这些 Face,不会触发全模型超细重网格。 - 拉伸/切除后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回拉伸/切除前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 - 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 @@ -261,6 +293,8 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面 Face,并一起高亮相关局部区域。 - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。 - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face。 +- `特征探测级别` 切到 `探测相邻特征` 或 `探测二级特征` 后,当前选中对象表会显示 `关联探测` 说明行:它会写明本次探测级别、找到的关联特征数量,以及关联孔、槽、凸台等尺寸是否已经作为 `关联 Face` 行合并到同一张参数表里。这样即使没有找到额外可改对象,也能看出程序已经完成探测,而不是界面没有反应。 +- 当前选中对象表已经从横向五列表格改成竖向参数卡片:每个参数卡片显示参数名、当前值、目标值、建模意图和单项应用按钮;识别摘要、一级关系和其它只读诊断默认折叠在 `更多诊断信息` 后面,避免左侧窄面板里文字互相挤压。 - 属性面板会统一显示 `识别摘要`、`识别候选`、`识别置信度`、`识别风险`、`识别评分`、`识别结论`、`识别依据`、`识别限制` 和 `受限能力`。这些字段来自当前已知的 B-Rep 几何事实、一级拓扑关系、同域区域、材料采样和阻止原因,用来说明程序为什么把对象当作某类候选,而不是把猜测隐藏在代码里。`识别限制` 表示这个候选整体不能稳定编辑;`受限能力` 表示对象本身可识别、某些修改可用,但另一些子能力暂不开放。低置信解析曲面不会开放可编辑按钮,避免把“底层曲面类型像圆锥/球/环面”误当成稳定 CAD 设计参数。 - 模型层提供统一的一级拓扑事实包 `face_first_level_facts()`:它把当前主体 Face 区域、边界 Edge、边界 Vertex、共享边直接相邻 Face、角色分组和暂不传播的二/三级关系整理成同一份事实证据。当前 Face、孔、槽等编辑计划也会携带这份事实包;`只改当前 Face` 这类局部重建计划会在执行前先检查一级事实图是否 ready,若无法确认当前 Face、边界 Edge/Vertex 和共享边相邻 Face,会直接 blocked 并说明原因。Face 编辑成功后还会重建目标 Face 的一级事实图做结果校验,若目标值虽然达到但一级事实图不再成立,会自动回滚。后续孔、槽、凸台、圆角等候选识别应优先使用这份事实包,再叠加各自的语义判断和置信度。 - Edge 阶段新增统一的一级拓扑事实包 `edge_first_level_topology()` / `edge_first_level_facts()`:它把当前 Edge、端点 Vertex、共享端点的相邻 Edge、直接包含该 Edge 的相邻 Face、角色分组和暂不传播的二/三级关系整理成同一份事实证据。当前 Edge 长度、起点/中心/终点坐标、圆角、倒角、圆边轴心和椭圆半径等编辑计划会携带这份事实包,后续 Edge 一级策略应优先用它判断“只改当前边、移动端面、相邻圆柱或缩放所属对象”的执行影响。 @@ -458,7 +492,8 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 旋转操作会写入历史记录,并支持撤销/重做。 - 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。 - 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。 -- 首次加载会先在后台生成粗显示数据并快速显示,随后再后台生成精细显示数据并替换到视图里。 +- 首次加载默认使用中等清晰度显示网格,避免长期停留在粗网格预览;B-Rep 编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。 +- 旋转/平移/缩放相机时保持边线和静态画质一致;渲染优先使用较轻的 FXAA 抗锯齿,环境不支持时回退到 2x MSAA,减少复杂 STEP 查看时的帧率抖动。 - 鼠标悬停高亮做了节流和移动阈值,减少复杂模型上连续拾取造成的卡顿。 - 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。 - 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。 @@ -542,15 +577,23 @@ vertices: 3262 ## 下一步要实现什么 -建议下一步按这个顺序推进: +当前已经有按阶段跑通的一级编辑基线,但这还不等于 CAD 级完成。后续继续按这个顺序深化:每一类先用小模型和默认大模型保持回归通过,再拿模型工程师的真实 STEP 失败项补识别、补守门、补局部重建策略。 -1. 先集中完成 Face 一级拓扑关系编辑:面积、U向尺寸/V向尺寸、中心、偏移变换和壳体厚度,都要明确 `局部重建`、`拉伸/切除`、`移动特征`、`缩放特征` 等语义;当前阶段只传播当前 Face/同域碎片和共享边相邻 Face,不递归处理二级、三级关系。 -2. Face 一级关系闭环稳定后,先集中做 Edge 一级关系:直线 Edge 长度、起点、中心、终点和倒圆/倒角语义先做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback;当前 Edge 编辑计划已经开始携带统一的一级端点/相邻边/相邻 Face 事实包。 -3. Edge 一级关系稳定后,再集中做槽/孔类特征的一级关系:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度、中心距、孔径、孔轴心和盲孔/盲槽深度要统一验证。 -4. Edge 闭环稳定后,再集中做凸台、已有圆角、圆锥/球/环面、壳体局部区域等对象;每类都先补一级关系能力和验证,再进入下一类。 +1. 继续打磨 Face 一级拓扑关系编辑:面积、U向尺寸/V向尺寸、中心、偏移变换和壳体厚度,都要明确 `局部重建`、`拉伸/切除`、`移动特征`、`缩放特征` 等语义;当前阶段只传播当前 Face/同域碎片和共享边相邻 Face,不递归处理二级、三级关系。 +2. 继续打磨槽/孔类特征的一级关系:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度、中心距、孔径、孔轴心和盲孔/盲槽深度要统一验证。 +3. 继续打磨 Edge 一级关系:直线 Edge 长度、起点、中心、终点和倒圆/倒角语义先做稳定,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback;当前 Edge 编辑计划已经开始携带统一的一级端点/相邻边/相邻 Face 事实包。 +4. 继续打磨凸台、已有圆角、圆锥/球/环面、壳体局部区域等对象;每类都先补一级关系能力和验证,再进入下一类。 5. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。 6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。 +当前整体验证基线: + +- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage face`,覆盖 Face 专项套件和 Face isolated worker。 +- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage hole-slot`,覆盖孔/槽一级拓扑、局部重建和 isolated worker。 +- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage edge`,覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。 +- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic`,覆盖凸台、圆角/倒角、壳体厚度和解析曲面。 +- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --quick`,覆盖 smoke test、属性表规格、参数卡片 UI、一级事实图、关联探测、显示网格预算和一级验收文档一致性。 + Face 阶段的当前验收口径: - 当前阶段的 `一级关系` 定义为:选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为当前阶段的自动传播范围。 @@ -559,8 +602,12 @@ Face 阶段的当前验收口径: - `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积`、`U向尺寸`、`V向尺寸`、`中心`、`偏移变换` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。 - `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,属性表能显示这份一级关系摘要,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 - `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。特征参数表也会显示圆柱特征的 `一级关系` 摘要,让用户知道当前孔/槽修改会围绕哪些直接邻域执行;结果校验会拒绝虽然尺寸匹配但一级邻域明显弱于计划要求的圆柱结果。后续继续把这些一级关系用于更细的孔/槽实际重建策略。 +- `python scripts\verify_hole_slot_edit_suite.py` 必须通过,集中覆盖孔/槽一级拓扑、孔径/孔轴心/孔封堵、盲孔/盲槽深度、槽宽/槽深/弧长/弧角、长圆槽总长度/中心距、隔离执行、逻辑 Face ID 保持和属性表分组。 +- `python scripts\verify_boss_edit_suite.py` 必须通过,集中覆盖凸台一级拓扑、凸台直径/高度/轴心局部重建、圆 Edge 代理到凸台轴心移动,以及属性表分组。 +- `python scripts\verify_round_chamfer_edit_suite.py` 必须通过,集中覆盖新增圆角/倒角、已有圆角重建、结果反查、失败回滚和一级邻域守门。 +- `python scripts\verify_shell_edit_suite.py` 必须通过,集中覆盖壳体厚度的局部拉伸/切除、缩放特征语义、Face 一级关系计划字段和结果校验。 - 对不能稳定做 `局部重建` 的 Face,要禁用该建模意图,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。 -- Face 一级关系闭环后,当前先进入 Edge 一级关系阶段;Edge 的一级端点/相邻边/相邻 Face 事实包和验证稳定后,再回到槽/孔一级关系阶段。 +- Face 一级关系闭环后,当前优先把槽/孔一级关系阶段补成独立闭环;槽/孔的一级邻域、局部重建和隔离执行稳定后,再继续推进 Edge 与其它特征阶段。 ## 怎么使用 @@ -732,11 +779,11 @@ Face 阶段的当前验收口径: - 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。 - `槽/半孔深度`: - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改 `槽/半孔深度`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `槽/半孔深度`;目标值变化后,该行 `操作` 按钮会从 `未改动` 变成 `应用`,点击它或点击 `参数化建模`。 - 程序会按当前圆弧角度把目标槽深换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - `槽/半孔圆弧长度`: - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改 `槽/半孔圆弧长度`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `槽/半孔圆弧长度`;目标值变化后,点击该行的 `应用` 或点击 `参数化建模`。 - 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - `槽宽` / `槽深` / `弧长` 的 `缩放特征`: - 先选择一个槽/半孔候选 Face。 @@ -765,7 +812,7 @@ Face 阶段的当前验收口径: - `壳体厚度` 的 `拉伸/切除`: - 先选择一个已识别到相对平面的壳体平面 Face。 - 在当前选中对象表里修改 `壳体厚度`,并把 `建模意图` 设为 `拉伸/切除`。 - - 输入目标厚度后点击该行的 `可修改` 或点击 `参数化建模`。 + - 输入目标厚度后点击该行的 `应用` 或点击 `参数化建模`。 - 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。 - 如果相对平面重叠率低、厚度变化过大或拉伸/切除方向不稳定,会提高风险或阻止执行。 - 这一步仍是局部 B-Rep 平面拉伸/切除近似,不是完整 CAD 壳体特征参数编辑。 @@ -788,7 +835,7 @@ Face 阶段的当前验收口径: - 这会连带影响所属对象上的高度、厚度和其它尺寸,需要确认这正是你的建模意图。 - `凸台高度`: - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `高度`,并把 `建模意图` 设为 `拉伸/切除端盖`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `高度`,并把 `建模意图` 设为 `拉伸/切除端盖`,点击该行的 `应用` 或点击 `参数化建模`。 - 程序会尝试找到凸台外端盖 Face,并通过拉伸/切除端盖改变高度。 - `高度` 的 `缩放特征`: - 先选择一个完整圆柱凸台或普通完整圆柱 Face。 @@ -797,7 +844,7 @@ Face 阶段的当前验收口径: - 这不是端盖拉伸/切除;同一对象上的孔距、台阶位置和其它轴向尺寸也会跟随变化。 - `轴心`: - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `轴心`,并把 `建模意图` 设为 `移动凸台`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `轴心`,并把 `建模意图` 设为 `移动凸台`,点击该行的 `应用` 或点击 `参数化建模`。 - 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。 - `轴心` 的 `移动特征`: - 同样输入目标 X, Y, Z 坐标。 @@ -823,7 +870,7 @@ Face 阶段的当前验收口径: - `盲孔/盲槽深度` 的 `改底面深度`: - 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。 - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `建模意图` 设为 `改底面深度`。 - - 输入目标深度后点击该行的 `可修改` 或点击 `参数化建模`。 + - 输入目标深度后点击该行的 `应用` 或点击 `参数化建模`。 - 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。 - 目标深度小于当前估算深度时,程序会在旧底面方向补料,让孔变浅,预览显示为绿色。 - 目标深度必须大于 0;如果和当前估算深度几乎相同,会直接阻止。 @@ -848,7 +895,7 @@ Face 阶段的当前验收口径: - `圆角半径` / `圆角弧长` 的 `重建圆角`: - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `建模意图` 设为 `重建圆角`。 - - 输入目标值后点击该行的 `可修改` 或点击 `参数化建模`。 + - 输入目标值后点击该行的 `应用` 或点击 `参数化建模`。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 @@ -876,7 +923,7 @@ Face 阶段的当前验收口径: - 当前选中对象表中的 `长度基准` 行可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 - `长度` 行的 `建模意图` 下拉框可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱` 或 `缩放所属`。 - 把 `长度` 改成目标值后点击这一行的按钮。 - - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `起点`、`中心` 或 `终点`,格式是 `X, Y, Z`,然后点击该行的 `可修改` 按钮。 + - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `起点`、`中心` 或 `终点`,格式是 `X, Y, Z`,然后点击该行的 `应用` 按钮。 - 修改 `中心` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点。 - 对 `assets/models/cube_10mm.step` 这类全平面多面体,选择 `只改当前Edge` 时会只移动被选Edge的端点并重建周边面。这时正方体不会变成长方体;相邻面会跟着被移动的边自然变形,四点非平面面会优先重建成连续曲面,不再默认拆出面内对角线。 - 对同一个正方体,选择 `移动端面` 时会把改边长转成端面拉伸/切除,相关平行边会一起变化,结果更像从正方体变成长方体。 @@ -960,8 +1007,12 @@ pythonocc-step-editor/ GeometryParametric.spec # PyInstaller 文件夹版打包配置 package_windows.ps1 # 生成 dist/GeometryParametric 和 dist/GeometryParametric_windows_x64.zip generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具 + verify_first_level_acceptance_docs.py # 验证 README 的一级验收口径和阶段验证入口保持一致 + verify_first_level_edit_suites.py # 按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体阶段串起一级编辑回归 verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证简单圆锥解析重建和曲面整体缩放 verify_cone_semi_angle_isolation.py # 验证圆锥半角/参考半径隔离执行和嵌入式锥孔局部重切 + verify_boss_edit_suite.py # 一键运行凸台专项修改验证 + verify_boss_first_level_topology.py # 临时生成圆柱凸台 STEP 并验证凸台一级共边拓扑字段 verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改 verify_edge_edit_suite.py # 一键运行 Edge 专项修改验证 verify_edge_first_level_topology.py # 验证 Edge 一级端点/相邻边/相邻 Face 事实包和编辑计划携带情况 @@ -971,6 +1022,7 @@ pythonocc-step-editor/ verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放 verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改 + verify_round_chamfer_edit_suite.py # 一键运行圆角/倒角专项修改验证 verify_edge_isolated_edit.py # 通过 isolated worker 真实验证 Edge 一级编辑隔离执行 verify_face_coplanar_push_pull.py # 验证共面碎面会作为一个 Face 区域一起拉伸/切除 verify_face_complex_boundary_guard.py # 验证带内孔平面 Face 会禁用局部重建的局部变形,但保留向外拉伸和向内切削 @@ -990,8 +1042,11 @@ pythonocc-step-editor/ verify_face_ui_isolation_contract.py # 验证 Face UI 入口和 isolated worker 的隔离操作列表一致 verify_face_rectangular_axes.py # 验证矩形 Face 的U向尺寸 和V向尺寸 方向不会混淆 verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 + verify_hole_slot_edit_suite.py # 一键运行孔/槽专项修改验证 verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 + verify_property_card_editor_ui.py # 验证参数卡片 UI 能构建、检测目标值修改并展开诊断信息 + verify_shell_edit_suite.py # 一键运行壳体厚度专项修改验证 verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证壳体厚度局部/整体修改 verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 verify_cylindrical_first_level_topology.py # 临时生成孔/槽 STEP 并验证圆柱孔、盲孔、半圆槽、长圆槽的一级共边拓扑字段 @@ -1050,7 +1105,9 @@ pythonocc-step-editor/ 最小系统目标是打通:加载 STEP、显示模型、选择零件/Solid/Face/Edge/特征、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。 -当前开发节奏必须分阶段闭环:先把 Face 修改能力做完整并集中测试,再做 Edge;Edge 通过后再做槽/半孔、孔/圆柱、凸台、圆角和解析曲面。不要每个对象只做一点就切换方向。 +当前开发节奏必须分阶段闭环:先把 Face 修改能力做完整并集中测试,再做孔/槽;孔/槽通过后再做 Edge;Edge 通过后再做凸台、圆角、倒角、壳体和解析曲面。不要每个对象只做一点就切换方向。 + +截至 2026-08-06,一级编辑总验证入口 `scripts/verify_first_level_edit_suites.py` 已在 `pyocc` 环境下按阶段通过:Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面,以及 `--quick` 轻量回归。后续不要把这些阶段当作“完全没做”;应在现有基线上根据真实 STEP 失败项继续补识别、补风险守门、补局部重建和性能稳定性。 ### 运行方式 @@ -1061,11 +1118,16 @@ conda activate pyocc python main.py python main.py assets\models\cube_10mm.step python scripts\generate_cube_step.py +python scripts\verify_first_level_edit_suites.py --quick +python scripts\verify_first_level_edit_suites.py python scripts\verify_face_edit_suite.py python scripts\verify_isolated_face_edit.py +python scripts\verify_hole_slot_edit_suite.py python scripts\verify_edge_edit_suite.py python scripts\verify_edge_isolated_edit.py -python scripts\verify_shell_thickness_resize.py +python scripts\verify_boss_edit_suite.py +python scripts\verify_round_chamfer_edit_suite.py +python scripts\verify_shell_edit_suite.py python scripts\verify_analytic_surface_resize.py python scripts\verify_slot_resize.py python main.py --smoke-test @@ -1077,6 +1139,14 @@ python main.py --smoke-test C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py ``` +在 Windows PowerShell 里用 `conda run` 跑输出较多的验证脚本时,如果遇到 Conda 自身的 `UnicodeEncodeError`,通常不是几何测试失败,而是外层 Conda 打印子进程日志时被系统编码绊住。可以先设置 UTF-8 输出再运行: + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:PYTHONUTF8='1' +C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python scripts\verify_first_level_edit_suites.py --stage face +``` + 当前 Codex 沙盒环境可能没有 VTK/pythonocc,因此 GUI 和 smoke test 可能报 `ModuleNotFoundError: vtk`。能做的本地验证至少包括: ```powershell @@ -1090,9 +1160,14 @@ git diff --check - `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`。 - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`。 +- `scripts/verify_first_level_acceptance_docs.py`:验证 README 里声明的一级验收口径、阶段命令和脚本清单仍然能对上 `verify_first_level_edit_suites.py`,避免目标文档和实际验证入口脱节。 +- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数卡片 UI、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。 - `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证简单圆锥参考半径/半角解析重建,以及球面半径、环面主半径和环面小半径整体缩放。 - `scripts/verify_cone_semi_angle_isolation.py`:临时生成简单圆锥、旋转圆锥、嵌入式沉头锥孔和带圆柱通孔的沉头锥孔,验证圆锥半角/参考半径的大参数修改会走解析重建或锥孔局部重切;同时覆盖非端面参考半径的局部重切推导、圆锥特征的小端/大端/高度/半角信息、真实模型复杂浅锥大幅半角修改的提前阻断、`1499.5 -> 1700` 这类参考半径修改的快速阻止,以及隔离子进程保护主界面。 - `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。 +- `scripts/verify_boss_first_level_topology.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径、高度、轴心和圆 Edge 代理轴心计划都会暴露一级共边拓扑关系,并验证凸台直径执行后会反查目标 Face 和一级关系。 +- `scripts/verify_boss_edit_suite.py`:集中运行当前凸台阶段验证,串起凸台一级拓扑、凸台直径/高度/轴心局部重建、圆 Edge 代理轴心移动和属性表分组。 +- `scripts/verify_round_chamfer_edit_suite.py`:集中运行圆角/倒角阶段验证,串起直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角、已有圆角半径重建、结果反查和属性表分组。 - `scripts/verify_edge_edit_suite.py`:集中运行当前 Edge 阶段验证,覆盖 Edge 一级拓扑事实包、直线 Edge 长度的自动策略、只改当前 Edge、移动端面/保持垂直、缩放所属对象,以及固定起点、固定终点、固定中心三类锚点;同时串起 Edge 建模意图 UI 映射、直线 Edge 起点/中心/终点坐标修改、Edge 圆角/倒角/已有圆角修改和椭圆 Edge 主/小半径修改。 - `scripts/verify_edge_first_level_topology.py`:加载 `cube_10mm.step`,验证一条正方体 Edge 的一级关系包含当前 Edge、2 个端点 Vertex、4 条共享端点相邻 Edge 和 2 个直接相邻 Face,并确认边长、端点/中心坐标、圆角和倒角计划都会携带这份一级事实包。 - `scripts/verify_edge_coordinate_edit.py`:加载 `cube_10mm.step`,分别验证直线 Edge 起点、中心和终点坐标修改,确认局部重建后仍能找到目标端点位置的直线 Edge,并保持单 Solid。 @@ -1125,10 +1200,14 @@ git diff --check - `scripts/verify_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `U向尺寸` 和 `V向尺寸` 两个方向上可以分别做 `局部重建` 与 `缩放特征`,另一个方向不会被误改,属性表也会读回新值。 - `scripts/verify_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖拉伸/切除并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`缩放特征` 的轴向缩放会按轴向跨度校验结果。 - `scripts/verify_large_stepped_cap_push_pull.py`:加载 `assets/models/geom_extract.step`,自动找到 y=-39 的同轴阶梯环形端盖和 y=-57.5 的多内孔大平面端盖,分别验证 `-39 -> 50` 走 `local-shell-rebuild`、`57.5 -> 92` 走 `boundary-shell-rebuild`,并验证多内孔大端面浅范围向内收缩也走 `boundary-shell-rebuild`,深向内切除会快速阻止且说明材料厚度或二级关系原因;成功路径保持单 Solid,原逻辑 Face ID 会继续指向移动后的新端面,一级边界 Edge/Vertex、共享边相邻 Face 和内孔线圈数量不能丢,并同时覆盖隔离子进程路径,避免 UI 操作退回 180s 布尔/全模型结果扫描慢路径。 +- `scripts/verify_hole_slot_edit_suite.py`:集中运行当前孔/槽阶段验证,串起孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker 执行、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。后续孔/槽能力新增或重构时优先跑这个套件,而不是只跑单个孔径或槽宽脚本。 - `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路;盲孔/盲槽深度修改现在会在操作内部确认目标深度和一级邻域,失败会回滚。 - `scripts/verify_hole_slot_isolated_edit.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距可以在隔离执行通道里完成,输出 STEP 能重新加载并达到目标值;同时验证会破坏盲孔圆柱语义的整体缩放会被干净拒绝并回滚。窗口侧会额外验证孔径、孔轴心、槽宽、槽轴心、盲孔/盲槽深度、长圆槽总长度和长圆槽轴心隔离任务不会被误套用通用 Face 参数校验,并会把原逻辑 Face ID 继续绑定到修改后的孔壁、槽壁或长圆槽端部 Face。 - `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移变换、面积、U向尺寸/V向尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 - `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、U向尺寸/V向尺寸 或偏移变换编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `局部重建` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。 +- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数卡片显示层,确认可编辑卡片、目标值输入、建模意图下拉框、单项应用按钮和 `更多诊断信息` 展开都能正常工作。 +- `scripts/verify_associated_features.py`:验证 `探测相邻特征` 能在共享边拓扑范围内找到关联孔/凸台,把它们的可改尺寸以 `关联 Face` 行合并到当前特征参数表,并显示 `关联探测` 说明行;同时验证大模型全量显示网格不会退回百万级三角面,而单个圆柱 Face 仍可局部细化,防止用户误以为探测级别切换没有生效或加载显示卡住。 +- `scripts/verify_shell_edit_suite.py`:集中运行壳体厚度阶段验证,串起壳体厚度局部拉伸/切除、缩放特征、Face 一级事实图、结果校验和属性表分组。 - `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证壳体厚度局部拉伸/切除和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。 - `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 @@ -1155,7 +1234,9 @@ git diff --check ### 当前关键能力 -- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 +- STEP 显示:默认使用中等清晰度网格,避免粗网格长期显示导致画质发糊;复杂模型旋转时保持边线和静态画质一致,并可隐藏同域内部边。 +- 模型展示区域右上角会显示最近渲染窗口的 `FPS`,用于观察旋转、缩放和复杂模型查看时的实际渲染帧率;静止后会用一次性 idle 刷新归零,不会持续空转重绘。 +- 模型展示区域左下角会显示 CAD 风格的 view cube + 彩色 `XYZ` 方向标;主视图前景层会在 STEP 文件真实全局原点 `(0,0,0)` 显示一组 `XYZ` 坐标基准,带原点球、粗箭头、负方向短轴和 3D 标签,且不参与拾取或初始相机适配,避免打开模型时被坐标轴拉远缩小。 - 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。 - 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。 - 编辑:偏移变换、面积、U向尺寸/V向尺寸、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、壳体厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。 diff --git a/scripts/probe_property_panel_usability.py b/scripts/probe_property_panel_usability.py new file mode 100644 index 0000000..ba7ea79 --- /dev/null +++ b/scripts/probe_property_panel_usability.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from pathlib import Path + +from PySide6.QtCore import QPoint, Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QLabel, QLineEdit, QPushButton, QWidget + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.app import DEFAULT_MODEL_PATH, StepEditorWindow, _suppress_vtk_output_window +from step_editor.widgets import NoWheelComboBox + + +def _pump(app: QApplication, ms: int = 150) -> None: + deadline = time.monotonic() + ms / 1000.0 + while time.monotonic() < deadline: + app.processEvents() + time.sleep(0.005) + + +def _native_grab(widget: QWidget, target: Path) -> None: + app = QApplication.instance() + if app is None: + return + screen = app.primaryScreen() + if screen is None: + return + screen.grabWindow(int(widget.winId())).save(str(target)) + + +def _compact_text(text: object, limit: int = 120) -> str: + value = str(text or "").replace("\n", " ").strip() + if len(value) <= limit: + return value + return f"{value[: limit - 1]}..." + + +def _label_overflows(label: QLabel) -> bool: + if label.width() <= 0: + return False + return bool(label.sizeHint().width() > label.width() + 3 and not label.wordWrap()) + + +def _row_snapshot(window: StepEditorWindow) -> list[dict[str, object]]: + rows = getattr(window, "property_card_rows", {}) + result: list[dict[str, object]] = [] + if not isinstance(rows, dict): + return result + for row, widgets in rows.items(): + if not isinstance(widgets, dict): + continue + card = widgets.get("card") + if not isinstance(card, QWidget): + continue + labels = card.findChildren(QLabel) + result.append( + { + "row": int(row), + "height": card.height(), + "selected": bool(card.property("selected")), + "visible": card.isVisible(), + "title": _compact_text(widgets.get("status_label").text() if False else ""), + "labels": [ + { + "object": label.objectName(), + "text": _compact_text(label.text(), 90), + "width": label.width(), + "size_hint_width": label.sizeHint().width(), + "overflow": _label_overflows(label), + } + for label in labels + if label.isVisible() + ], + "has_editor": isinstance(widgets.get("target_editor"), QLineEdit), + "has_scope": isinstance(widgets.get("scope_combo"), NoWheelComboBox), + "has_button": isinstance(widgets.get("action_button"), QPushButton), + } + ) + return result + + +def _actionable_labels(window: StepEditorWindow) -> list[str]: + return [str(spec.get("label", "") or "") for _row, spec in window._actionable_property_rows()] + + +def _find_actionable_target(window: StepEditorWindow) -> dict[str, object] | None: + if window.model is None: + return None + best: dict[str, object] | None = None + best_score = -1 + max_faces = len(window.model.faces) + for mode, selector in (("Face", window.select_face), ("Feature", window.select_feature)): + window._set_selection_mode(mode) + for face_id in range(max_faces): + selector(face_id) + QApplication.processEvents() + labels = _actionable_labels(window) + score = len(labels) + if score > best_score: + best_score = score + best = {"mode": mode, "face_id": face_id, "labels": labels} + if score >= 3: + return best + return best + + +def _numeric_target_text(text: str) -> str: + match = re.search(r"[-+]?\d+(?:\.\d+)?", text) + if not match: + return "1" + value = float(match.group(0)) + if abs(value) < 1e-9: + value = 1.0 + else: + value *= 1.05 + return f"{value:.6g}" + + +def _pick_editable_numeric_row(window: StepEditorWindow) -> int | None: + for row, spec in window._actionable_property_rows(): + effective = window._effective_property_spec(spec, row=row) + if str(effective.get("value_type", "number")) == "number": + return int(row) + rows = window._actionable_property_rows() + return int(rows[0][0]) if rows else None + + +def _click_card(window: StepEditorWindow, row: int) -> bool: + card = window._property_card_widgets(row).get("card") + if not isinstance(card, QWidget): + return False + QTest.mouseClick(card, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(18, max(3, card.height() // 2))) + return True + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", default="assets/screenshots/property_panel_usability") + parser.add_argument("--model", default=str(DEFAULT_MODEL_PATH)) + parser.add_argument("--face-id", type=int, default=None) + parser.add_argument("--selection-mode", choices=("Face", "Feature"), default=None) + parser.add_argument("--detection-level", choices=("current-only", "associated-only", "secondary"), default=None) + parser.add_argument("--expand-diagnostics", action="store_true") + args = parser.parse_args() + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + _suppress_vtk_output_window() + app = QApplication(["property-panel-usability-probe"]) + window = StepEditorWindow(Path(args.model), background_load=False) + window.resize(1280, 820) + window.show() + _pump(app, 350) + window.load_step(Path(args.model), background=False) + _pump(app, 500) + _native_grab(window, output_dir / "00_loaded.png") + + if args.detection_level and hasattr(window, "feature_detection_combo"): + index = window.feature_detection_combo.findData(args.detection_level) + if index >= 0: + window.feature_detection_combo.setCurrentIndex(index) + _pump(app, 250) + + target = None + if args.face_id is not None: + mode = args.selection_mode or "Feature" + if mode == "Feature": + window._set_selection_mode("Feature") + window.select_feature(int(args.face_id)) + else: + window._set_selection_mode("Face") + window.select_face(int(args.face_id)) + _pump(app, 350) + target = { + "mode": mode, + "face_id": int(args.face_id), + "labels": _actionable_labels(window), + } + else: + target = _find_actionable_target(window) + if target is None or not target.get("labels"): + summary = { + "model": str(Path(args.model)), + "error": "no actionable face or feature found", + } + (output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"property panel usability probe wrote {output_dir}") + return 2 + + mode = str(target["mode"]) + face_id = int(target["face_id"]) + window._set_selection_mode(mode) + if mode == "Feature": + window.select_feature(face_id) + else: + window.select_face(face_id) + _pump(app, 350) + _native_grab(window, output_dir / "01_compact_actionable.png") + + if args.expand_diagnostics and hasattr(window, "property_expand_button") and window.property_expand_button.isVisible(): + QTest.mouseClick( + window.property_expand_button, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + QPoint(max(3, window.property_expand_button.width() // 2), max(3, window.property_expand_button.height() // 2)), + ) + _pump(app, 250) + _native_grab(window, output_dir / "01b_expanded_diagnostics_before_edit.png") + + target_row = _pick_editable_numeric_row(window) + if target_row is not None: + _click_card(window, target_row) + _pump(app, 250) + _native_grab(window, output_dir / "02_expanded_first_action.png") + expanded_rows = _row_snapshot(window) + + entered_target = "" + button_text_after_input = "" + selected_widgets = window._property_card_widgets(target_row) if target_row is not None else {} + editor = selected_widgets.get("target_editor") + if isinstance(editor, QLineEdit): + entered_target = _numeric_target_text(editor.text()) + editor.setFocus() + editor.selectAll() + QTest.keyClicks(editor, entered_target) + _pump(app, 250) + button = selected_widgets.get("action_button") + if isinstance(button, QPushButton): + button_text_after_input = button.text() + _native_grab(window, output_dir / "03_after_target_input.png") + + collapsed_after_blank_click = False + if target_row is not None: + _click_card(window, target_row) + _pump(app, 250) + collapsed_after_blank_click = getattr(window, "property_editor_selected_row", None) is None + _native_grab(window, output_dir / "04_collapsed_again.png") + + if hasattr(window, "property_expand_button") and window.property_expand_button.isVisible(): + QTest.mouseClick( + window.property_expand_button, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + QPoint(max(3, window.property_expand_button.width() // 2), max(3, window.property_expand_button.height() // 2)), + ) + _pump(app, 250) + _native_grab(window, output_dir / "05_more_diagnostics.png") + + compact_rows = _row_snapshot(window) + overflow_labels = [ + label + for row in compact_rows + expanded_rows + for label in row.get("labels", []) + if bool(label.get("overflow")) + ] + summary = { + "model": str(Path(args.model)), + "target": { + "mode": mode, + "face_id": face_id, + "actionable_labels": target.get("labels", []), + }, + "summary_label": getattr(window, "property_command_summary_label", None).text() + if hasattr(window, "property_command_summary_label") + else "", + "target_row": target_row, + "entered_target": entered_target, + "button_text_after_input": button_text_after_input, + "collapsed_after_blank_click": collapsed_after_blank_click, + "compact_rows": compact_rows, + "expanded_rows": expanded_rows, + "overflow_labels": overflow_labels, + "selected_row_after_diagnostics": getattr(window, "property_editor_selected_row", None), + } + (output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + window.close() + _pump(app, 100) + app.quit() + print(f"property panel usability probe wrote {output_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/probe_transient_windows.py b/scripts/probe_transient_windows.py new file mode 100644 index 0000000..b0c674b --- /dev/null +++ b/scripts/probe_transient_windows.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import argparse +import ctypes +import ctypes.wintypes +import json +import os +import sys +import time +from pathlib import Path + +from PySide6.QtCore import QEvent, QObject, QPoint, Qt, qInstallMessageHandler +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.app import DEFAULT_MODEL_PATH, StepEditorWindow, _suppress_vtk_output_window + + +def _rect_tuple(hwnd: int) -> tuple[int, int, int, int]: + rect = ctypes.wintypes.RECT() + if not ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): + return (0, 0, 0, 0) + return (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)) + + +def _window_text(hwnd: int) -> str: + length = ctypes.windll.user32.GetWindowTextLengthW(hwnd) + buffer = ctypes.create_unicode_buffer(max(length + 1, 1)) + ctypes.windll.user32.GetWindowTextW(hwnd, buffer, len(buffer)) + return buffer.value + + +def _class_name(hwnd: int) -> str: + buffer = ctypes.create_unicode_buffer(256) + ctypes.windll.user32.GetClassNameW(hwnd, buffer, len(buffer)) + return buffer.value + + +def _visible_native_windows_for_process(pid: int) -> list[dict[str, object]]: + windows: list[dict[str, object]] = [] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) + def callback(hwnd, _lparam): + window_pid = ctypes.wintypes.DWORD() + ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(window_pid)) + if int(window_pid.value) != pid: + return True + if not ctypes.windll.user32.IsWindowVisible(hwnd): + return True + left, top, right, bottom = _rect_tuple(int(hwnd)) + width = max(0, right - left) + height = max(0, bottom - top) + if width <= 0 or height <= 0: + return True + windows.append( + { + "hwnd": int(hwnd), + "class": _class_name(int(hwnd)), + "title": _window_text(int(hwnd)), + "rect": [left, top, right, bottom], + "size": [width, height], + } + ) + return True + + ctypes.windll.user32.EnumWindows(callback, 0) + return windows + + +def _widget_text(widget: QWidget) -> str: + if isinstance(widget, QMessageBox): + return str(widget.text() or "") + getter = getattr(widget, "text", None) + if callable(getter): + try: + value = getter() + if isinstance(value, str): + return value + except Exception: + return "" + return "" + + +class WindowProbe(QObject): + def __init__(self, output_dir: Path) -> None: + super().__init__() + self.output_dir = output_dir + self.events: list[dict[str, object]] = [] + self.native_samples: list[dict[str, object]] = [] + self._native_seen: set[tuple[object, ...]] = set() + + def eventFilter(self, watched, event): + if isinstance(watched, QWidget) and event.type() in { + QEvent.Type.Show, + QEvent.Type.Hide, + QEvent.Type.WindowActivate, + QEvent.Type.ToolTip, + }: + if watched.isWindow() or event.type() == QEvent.Type.ToolTip: + self.events.append( + { + "event": event.type().name, + "qt_class": type(watched).__name__, + "object_name": watched.objectName(), + "title": watched.windowTitle(), + "text": _widget_text(watched), + "is_window": watched.isWindow(), + "visible": watched.isVisible(), + "size": [watched.width(), watched.height()], + "flags": int(watched.windowFlags()), + } + ) + return False + + def sample_native(self, label: str) -> None: + for item in _visible_native_windows_for_process(os.getpid()): + key = (item["hwnd"], item["class"], item["title"], tuple(item["rect"])) + if key in self._native_seen: + continue + self._native_seen.add(key) + sample = dict(item) + sample["label"] = label + self.native_samples.append(sample) + + def grab_qt_windows(self, label: str) -> None: + app = QApplication.instance() + if app is None: + return + for index, widget in enumerate(app.topLevelWidgets()): + if not widget.isVisible(): + continue + name = widget.objectName() or type(widget).__name__ + safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name) + target = self.output_dir / f"{label}_qt_{index}_{safe_name}.png" + widget.grab().save(str(target)) + + def grab_native_windows(self, label: str) -> None: + app = QApplication.instance() + if app is None: + return + screen = app.primaryScreen() + if screen is None: + return + for index, item in enumerate(_visible_native_windows_for_process(os.getpid())): + hwnd = int(item["hwnd"]) + safe_class = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(item["class"])) + target = self.output_dir / f"{label}_native_{index}_{safe_class}_{hwnd}.png" + screen.grabWindow(hwnd).save(str(target)) + + def write_log(self) -> None: + payload = { + "qt_events": self.events, + "native_samples": self.native_samples, + } + (self.output_dir / "transient_windows_log.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _pump(app: QApplication, probe: WindowProbe, label: str, ms: int) -> None: + deadline = time.monotonic() + ms / 1000.0 + while time.monotonic() < deadline: + app.processEvents() + probe.sample_native(label) + time.sleep(0.005) + + +def _find_face_click_point(window: StepEditorWindow) -> QPoint: + width = max(1, window.vtk_widget.width()) + height = max(1, window.vtk_widget.height()) + fractions = [0.5, 0.42, 0.58, 0.35, 0.65, 0.28, 0.72] + for fy in fractions: + for fx in fractions: + x = int(width * fx) + y_qt = int(height * fy) + y_vtk = int(height - y_qt) + target = window._pick_selection_target("Face", x, y_vtk) + if target is not None: + return QPoint(x, y_qt) + return QPoint(width // 2, height // 2) + + +def _click_first_property_card(window: StepEditorWindow) -> bool: + rows = getattr(window, "property_card_rows", {}) + if not isinstance(rows, dict): + return False + for widgets in rows.values(): + card = widgets.get("card") if isinstance(widgets, dict) else None + if isinstance(card, QWidget) and card.isVisible(): + QTest.mouseClick(card, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(20, max(2, card.height() // 2))) + return True + return False + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", default="assets/screenshots/transient_probe") + parser.add_argument("--model", default=str(DEFAULT_MODEL_PATH)) + parser.add_argument("--offscreen", action="store_true") + args = parser.parse_args() + + if args.offscreen: + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + qt_messages: list[str] = [] + + def message_handler(_mode, _context, message): + text = str(message) + qt_messages.append(text) + print(text, file=sys.stderr) + + qInstallMessageHandler(message_handler) + _suppress_vtk_output_window() + app = QApplication(["transient-window-probe"]) + probe = WindowProbe(output_dir) + app.installEventFilter(probe) + + window = StepEditorWindow(Path(args.model), background_load=False) + window.show() + window.resize(1280, 820) + _pump(app, probe, "after_show", 300) + + window.load_step(Path(args.model), background=False) + _pump(app, probe, "after_load", 800) + window.render_window.Render() + _pump(app, probe, "after_render", 300) + probe.grab_qt_windows("after_render") + probe.grab_native_windows("after_render") + + window._set_selection_mode("Face") + point = _find_face_click_point(window) + QTest.mouseClick(window.vtk_widget, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, point) + _pump(app, probe, "after_face_click", 900) + probe.grab_qt_windows("after_face_click") + probe.grab_native_windows("after_face_click") + + clicked_card = _click_first_property_card(window) + _pump(app, probe, "after_property_click", 900) + probe.grab_qt_windows("after_property_click") + probe.grab_native_windows("after_property_click") + + (output_dir / "probe_summary.json").write_text( + json.dumps( + { + "model": str(Path(args.model)), + "face_click_point": [point.x(), point.y()], + "selected_kind": getattr(window, "selected_kind", None), + "selected_face_id": getattr(window, "selected_face_id", None), + "clicked_property_card": clicked_card, + "qt_message_count": len(qt_messages), + "qt_messages": qt_messages[-50:], + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + probe.write_log() + window.close() + _pump(app, probe, "after_close", 100) + app.quit() + print(f"transient window probe wrote {output_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_associated_features.py b/scripts/verify_associated_features.py index f243c0f..f7c244d 100644 --- a/scripts/verify_associated_features.py +++ b/scripts/verify_associated_features.py @@ -53,6 +53,15 @@ def main() -> int: context = state._feature_context_info(SOURCE_FACE_ID) specs, _used = state._editable_property_specs(context) rows = state._feature_context_property_specs(specs, context) + context_row = next((row for row in rows if row.get("key") == "feature_context_note"), None) + if context_row is None: + raise AssertionError("feature context should expose an associated-feature detection summary row") + context_text = str(context_row.get("current_text") or "") + context_scope = str(context_row.get("scope_text") or "") + if "相邻特征" not in context_text or "局部关联特征" not in context_text: + raise AssertionError(f"associated detection summary is not clear enough: {context_row}") + if "相邻特征" not in context_scope or "关联特征" not in context_scope: + raise AssertionError(f"associated detection summary should expose level and count: {context_row}") editable = { (str(row.get("label")), int(row.get("source_face_id", -1)), str(row.get("action"))) for row in rows @@ -74,8 +83,24 @@ def main() -> int: index for index in range(display.GetNumberOfCells()) if int(face_ids.GetTuple1(index)) == 394 ] - if len(boss_cells) < 400: + if display.GetNumberOfCells() > 250_000: + raise AssertionError(f"display tessellation exceeded the large-model budget: {display.GetNumberOfCells()}") + if len(boss_cells) < 60: raise AssertionError(f"cylindrical display tessellation is too coarse: {len(boss_cells)}") + local_boss = model.build_face_polydata(face_ids=[394], deflection=0.035) + if local_boss.GetNumberOfCells() < 400: + raise AssertionError( + "single-Face local display should still allow fine cylindrical tessellation: " + f"{local_boss.GetNumberOfCells()}" + ) + local_first_model = StepModel.load(MODEL_PATH) + local_first_model.build_face_polydata(face_ids=[394], deflection=0.035) + local_first_display = local_first_model.build_face_polydata(deflection=0.035) + if local_first_display.GetNumberOfCells() > 250_000: + raise AssertionError( + "full display should remain budgeted after a local fine display was built first: " + f"{local_first_display.GetNumberOfCells()}" + ) plane_cells = [ index for index in range(display.GetNumberOfCells()) if int(face_ids.GetTuple1(index)) == SOURCE_FACE_ID diff --git a/scripts/verify_boss_edit_suite.py b/scripts/verify_boss_edit_suite.py new file mode 100644 index 0000000..0d0abb0 --- /dev/null +++ b/scripts/verify_boss_edit_suite.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import subprocess +import sys +import os +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_DIR = PROJECT_ROOT / "scripts" + + +CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ( + "Boss cylindrical first-level topology and plan guards", + ("verify_boss_first_level_topology.py",), + ), + ( + "Boss diameter, height and axis local rebuilds", + ("verify_boss_resize.py",), + ), + ( + "Property editor keeps generic Face edits out of boss features", + ("verify_property_editor_specs.py",), + ), +) + + +def main() -> int: + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") + for index, (label, command) in enumerate(CASES, start=1): + print(f"\n[{index}/{len(CASES)}] {label}", flush=True) + subprocess.run( + (sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]), + cwd=PROJECT_ROOT, + env=env, + check=True, + ) + print("\nBoss edit suite passed.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_boss_first_level_topology.py b/scripts/verify_boss_first_level_topology.py new file mode 100644 index 0000000..ecd6ea2 --- /dev/null +++ b/scripts/verify_boss_first_level_topology.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import tempfile + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = Path(__file__).resolve().parent +for path in (PROJECT_ROOT, SCRIPTS_DIR): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from step_editor.model import StepModel + +from verify_boss_resize import ( # noqa: E402 + _axis_center, + _first_boss_face, + _first_circle_edge_adjacent_to_face, + _write_boss_model, +) + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def _assert_common_topology(topology: dict[str, object], label: str) -> None: + _assert(topology.get("topology_relation_depth") == 1, f"{label}: topology depth should be 1") + _assert( + topology.get("topology_relation_boundary") == "shared-edge", + f"{label}: topology boundary should be shared-edge", + ) + ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ()) + _assert("second-level" in ignored, f"{label}: second-level propagation should be explicitly ignored") + _assert("third-level" in ignored, f"{label}: third-level propagation should be explicitly ignored") + _assert( + int(topology.get("cylindrical_feature_side_face_count", 0) or 0) >= 1, + f"{label}: selected boss side Face is missing", + ) + _assert( + int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0) >= 1, + f"{label}: boss boundary Edges are missing", + ) + _assert( + int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) >= 1, + f"{label}: direct adjacent Faces are missing", + ) + _assert( + int(topology.get("cylindrical_feature_end_face_count", 0) or 0) >= 1, + f"{label}: boss end/base cap Faces are missing", + ) + + +def _assert_plan_topology(plan: dict[str, object], label: str) -> None: + _assert(plan.get("topology_relation_depth") == 1, f"{label}: plan topology depth should be 1") + _assert( + plan.get("topology_relation_status") == "ready", + f"{label}: plan topology should be ready, got {plan.get('topology_relation_status')!r}", + ) + _assert( + plan.get("first_level_topology_status") == "ready", + f"{label}: first-level guard should be ready, got {plan.get('first_level_topology_status')!r}", + ) + _assert( + int(plan.get("first_level_boundary_edge_count", 0) or 0) >= 1, + f"{label}: plan boundary Edges are missing", + ) + _assert( + int(plan.get("first_level_adjacent_face_count", 0) or 0) >= 1, + f"{label}: plan adjacent Faces are missing", + ) + _assert( + "second-level" in tuple(plan.get("topology_ignored_relation_depths", ()) or ()), + f"{label}: plan should document ignored deeper topology", + ) + _assert( + plan.get("first_level_topology_guard_note"), + f"{label}: first-level topology guard note is missing", + ) + + +def _assert_edge_delegated_topology(plan: dict[str, object]) -> None: + _assert(plan.get("circular_edge_cylinder_mode") == "boss", "circle Edge should delegate to boss axis move") + _assert(plan.get("move_axis_topology_relation_depth") == 1, "delegated boss topology depth should be 1") + _assert( + plan.get("move_axis_topology_relation_status") == "ready", + "delegated boss topology should be ready", + ) + _assert( + plan.get("move_axis_first_level_topology_status") == "ready", + "delegated boss first-level guard should be ready", + ) + _assert( + int(plan.get("move_axis_first_level_adjacent_face_count", 0) or 0) >= 1, + "delegated boss adjacent Faces are missing", + ) + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="geom_param_boss_topology_") as temp_dir: + model_path = Path(temp_dir) / "boss.step" + _write_boss_model(model_path) + model = StepModel.load(model_path) + face_id = _first_boss_face(model) + topology = model.cylindrical_feature_first_level_topology(face_id) + _assert_common_topology(topology, "cylindrical boss") + + center = _axis_center(model, face_id) + diameter_plan = model.cylindrical_boss_resize_plan(face_id, 8.0) + height_plan = model.cylindrical_boss_height_plan(face_id, 7.0) + axis_plan = model.cylindrical_boss_axis_move_plan(face_id, (center[0] + 2.0, center[1], center[2])) + _assert_plan_topology(diameter_plan, "boss diameter plan") + _assert_plan_topology(height_plan, "boss height plan") + _assert_plan_topology(axis_plan, "boss axis plan") + + edge_id = _first_circle_edge_adjacent_to_face(model, face_id) + edge_info = model.edge_info(edge_id) + edge_center = edge_info.get("center") + _assert(isinstance(edge_center, tuple), "boss circular Edge center is missing") + target_edge_center = (float(edge_center[0]) + 2.0, float(edge_center[1]), float(edge_center[2])) + edge_axis_plan = model.circular_edge_axis_move_plan(edge_id, target_edge_center) + _assert_edge_delegated_topology(edge_axis_plan) + + result = model.resize_cylindrical_boss(face_id, 8.0) + _assert("verified_face=" in result, "boss diameter edit should report verified target Face") + _assert( + "first_level_topology_matched=True" in result, + f"boss diameter edit should verify first-level topology: {result}", + ) + + print("boss first-level topology verification passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_boss_resize.py b/scripts/verify_boss_resize.py index 3815fff..34d19c4 100644 --- a/scripts/verify_boss_resize.py +++ b/scripts/verify_boss_resize.py @@ -146,6 +146,10 @@ def _run_diameter_case(target: float, tolerance: float) -> None: raise SystemExit( f"diameter changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}" ) + if "verified_face=" not in result: + raise SystemExit(f"diameter result did not report verified target Face: {result}") + if "first_level_topology_matched=True" not in result: + raise SystemExit(f"diameter result did not verify first-level topology: {result}") print("mode=diameter") print(f"source_face={face_id}") print(f"strategy={plan.get('resize_strategy')}") @@ -186,6 +190,8 @@ def _run_axis_center_case(offset: float, tolerance: float) -> None: raise SystemExit( f"axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}" ) + if "first_level_topology_matched=True" not in result: + raise SystemExit(f"axis_center result did not verify first-level topology: {result}") print("mode=axis_center") print(f"source_face={face_id}") print(f"strategy={plan.get('resize_strategy')}") @@ -232,6 +238,8 @@ def _run_circle_edge_axis_center_case(offset: float, tolerance: float) -> None: raise SystemExit( f"circle_edge_axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}" ) + if "first_level_topology_matched=True" not in result: + raise SystemExit(f"circle_edge_axis_center result did not verify first-level topology: {result}") print("mode=circle_edge_axis_center") print(f"source_edge={edge_id}") print(f"source_face={face_id}") @@ -264,6 +272,8 @@ def _run_height_case(target: float, tolerance: float) -> None: raise SystemExit(f"height changed solid count: before={before.solids}, after={after.solids}") if error > tolerance: raise SystemExit(f"height verification failed: target={target:g}, value={height:g}, error={error:g}") + if "first_level_topology_matched=True" not in result: + raise SystemExit(f"height result did not verify first-level topology: {result}") print("mode=height") print(f"source_face={face_id}") print(f"strategy={plan.get('resize_strategy')}") diff --git a/scripts/verify_edge_coordinate_edit.py b/scripts/verify_edge_coordinate_edit.py index 9d73732..4de21e1 100644 --- a/scripts/verify_edge_coordinate_edit.py +++ b/scripts/verify_edge_coordinate_edit.py @@ -121,6 +121,8 @@ def _run_endpoint_case(role: str, target_delta: float, tolerance: float) -> None if plan.get("resize_strategy") != "local-edge-endpoint-deform": raise SystemExit(f"{role} endpoint should use local-edge-endpoint-deform, got {plan.get('resize_strategy')}") result = model.move_edge_endpoint(edge_id, role, target_point) + if "First-level topology check" not in result: + raise SystemExit(f"{role} endpoint result should include first-level topology check: {result}") after = model.stats() matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end) if after.solids != before.solids: @@ -155,6 +157,8 @@ def _run_center_case(center_delta: tuple[float, float, float], tolerance: float) if plan.get("resize_strategy") != "local-edge-center-deform": raise SystemExit(f"center move should use local-edge-center-deform, got {plan.get('resize_strategy')}") result = model.move_edge_center(edge_id, target_center) + if "First-level topology check" not in result: + raise SystemExit(f"center move result should include first-level topology check: {result}") after = model.stats() matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end) if after.solids != before.solids: diff --git a/scripts/verify_edge_first_level_topology.py b/scripts/verify_edge_first_level_topology.py index c633fa3..c2f69bd 100644 --- a/scripts/verify_edge_first_level_topology.py +++ b/scripts/verify_edge_first_level_topology.py @@ -108,6 +108,7 @@ def main() -> int: "first_level_fact_adjacent_edge_count", "first_level_fact_included_edge_ids", "first_level_fact_included_edge_count", + "edge_first_level_topology", "first_level_vertex_count", "first_level_adjacent_edge_count", ): diff --git a/scripts/verify_edge_length_resize.py b/scripts/verify_edge_length_resize.py index b041ba1..9c2e3d0 100644 --- a/scripts/verify_edge_length_resize.py +++ b/scripts/verify_edge_length_resize.py @@ -136,6 +136,8 @@ def main() -> int: anchor_mode=args.anchor, strategy_mode=args.strategy, ) + if "First-level topology check" not in result: + raise SystemExit(f"Edge length result should include first-level topology check: {result}") after = model.stats() lengths = [_edge_length(model, item) for item in range(len(model.edges))] nearest = min(lengths, key=lambda value: abs(value - args.target_length)) diff --git a/scripts/verify_edge_round_chamfer.py b/scripts/verify_edge_round_chamfer.py index e3bb261..fa3f2c2 100644 --- a/scripts/verify_edge_round_chamfer.py +++ b/scripts/verify_edge_round_chamfer.py @@ -108,6 +108,22 @@ def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: floa raise SystemExit(f"no existing fillet candidate near radius {radius:g}; loose matches: {detail or ''}") +def _assert_existing_fillet_plan_topology(plan: dict[str, object]) -> None: + if plan.get("topology_relation_depth") != 1: + raise SystemExit(f"existing fillet plan should expose first-level depth: {plan}") + if plan.get("topology_relation_status") != "ready": + raise SystemExit(f"existing fillet plan topology should be ready: {plan}") + if plan.get("first_level_topology_status") != "ready": + raise SystemExit(f"existing fillet first-level guard should be ready: {plan}") + if int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0) < 1: + raise SystemExit(f"existing fillet plan should expose boundary Edges: {plan}") + if int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0) < 2: + raise SystemExit(f"existing fillet plan should expose direct support Faces: {plan}") + ignored = tuple(plan.get("topology_ignored_relation_depths", ()) or ()) + if "second-level" not in ignored or "third-level" not in ignored: + raise SystemExit(f"existing fillet plan should document ignored deeper topology: {plan}") + + def _run_fillet_case(radius: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_edge_fillet_") as temp_dir: model_path = Path(temp_dir) / "box.step" @@ -121,6 +137,8 @@ def _run_fillet_case(radius: float, tolerance: float) -> None: result = model.fillet_edge(edge_id, radius) after = model.stats() matches = _cylindrical_faces_near_radius(model, radius, tolerance) + if "Edge blend result check" not in result: + raise SystemExit(f"fillet result did not report execution-layer result check: {result}") if after.solids != before.solids: raise SystemExit(f"fillet changed solid count: before={before.solids}, after={after.solids}") if not matches: @@ -144,6 +162,8 @@ def _verify_chamfer_topology( result: str, extra_lines: list[str], ) -> None: + if "Edge blend result check" not in result: + raise SystemExit(f"{mode} result did not report execution-layer result check: {result}") if after.solids != before.solids: raise SystemExit(f"{mode} changed solid count: before={before.solids}, after={after.solids}") if after.faces <= before.faces: @@ -250,6 +270,7 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera plan = model.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise SystemExit(f"existing fillet plan was blocked: {plan['message']}") + _assert_existing_fillet_plan_topology(plan) support_face_ids = tuple(plan.get("feature_existing_fillet_support_face_ids", ())) if len(support_face_ids) < 2: raise SystemExit(f"existing fillet should expose at least two support Faces, got {support_face_ids}") @@ -263,6 +284,10 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera raise SystemExit(f"existing fillet verification failed: no cylindrical face near radius {target_radius:g}") if old_matches and abs(source_radius - target_radius) > tolerance: raise SystemExit(f"existing fillet still has old radius matches: {old_matches}") + if "Existing fillet result check" not in result: + raise SystemExit(f"existing fillet result did not report result check: {result}") + if "first_level_topology_matched=True" not in result: + raise SystemExit(f"existing fillet result did not verify first-level topology: {result}") print("mode=existing_fillet") print(f"face_id={face_id}") print(f"strategy={plan.get('resize_strategy')}") diff --git a/scripts/verify_face_edit_suite.py b/scripts/verify_face_edit_suite.py index 5501da3..c3482cf 100644 --- a/scripts/verify_face_edit_suite.py +++ b/scripts/verify_face_edit_suite.py @@ -18,6 +18,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( "Face first-level shared-edge topology", ("verify_face_first_level_topology.py",), ), + ( + "cylindrical Face first-level topology for holes and slots", + ("verify_cylindrical_first_level_topology.py",), + ), ( "face width, owning feature", ("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "width", "--target-size", "15"), @@ -90,6 +94,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( "freeform Face stays read-only with a clear blocker", ("verify_face_freeform_guard.py",), ), + ( + "analytic curved Face radius and angle edits", + ("verify_analytic_surface_resize.py",), + ), ( "cylindrical side Face height edits are checked", ("verify_cylindrical_height_resize.py",), diff --git a/scripts/verify_first_level_acceptance_docs.py b/scripts/verify_first_level_acceptance_docs.py new file mode 100644 index 0000000..fd952d1 --- /dev/null +++ b/scripts/verify_first_level_acceptance_docs.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from pathlib import Path +import sys + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_DIR = PROJECT_ROOT / "scripts" +README_PATH = PROJECT_ROOT / "README.md" + +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from verify_first_level_edit_suites import QUICK_COMMANDS, STAGES # noqa: E402 + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def _command_path(script_name: str) -> Path: + script_path = SCRIPT_DIR / script_name + if script_path.exists(): + return script_path + return PROJECT_ROOT / script_name + + +def _commands() -> tuple[tuple[str, str, tuple[str, ...]], ...]: + collected: list[tuple[str, str, tuple[str, ...]]] = [] + for stage_name, _stage_label, cases in STAGES: + for _label, command in cases: + collected.append((stage_name, command[0], command[1:])) + for _label, command in QUICK_COMMANDS: + collected.append(("quick", command[0], command[1:])) + return tuple(collected) + + +def _verify_script_inventory() -> None: + for stage_name, script_name, _args in _commands(): + path = _command_path(script_name) + _assert(path.exists(), f"{stage_name} command target is missing: {script_name}") + + +def _verify_readme_mentions(readme: str) -> None: + required_fragments = ( + "当前整体验证基线", + "不等于 CAD 级完成", + "Face 阶段的当前验收口径", + "verify_first_level_edit_suites.py --quick", + "verify_first_level_edit_suites.py --stage face", + "verify_first_level_edit_suites.py --stage hole-slot", + "verify_first_level_edit_suites.py --stage edge", + "verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic", + "一级编辑总验证入口", + "真实 STEP 失败项", + ) + for fragment in required_fragments: + _assert(fragment in readme, f"README missing first-level acceptance fragment: {fragment}") + + for stage_name, _stage_label, _cases in STAGES: + _assert( + f"--stage {stage_name}" in readme or stage_name in {"round-chamfer", "shell", "analytic"}, + f"README should mention how to run stage {stage_name}", + ) + + for stage_name, script_name, _args in _commands(): + if script_name == "main.py": + continue + _assert(script_name in readme, f"README should mention {stage_name} command script {script_name}") + + +def main() -> int: + readme = README_PATH.read_text(encoding="utf-8") + _verify_script_inventory() + _verify_readme_mentions(readme) + print("first-level acceptance docs ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_first_level_edit_suites.py b/scripts/verify_first_level_edit_suites.py new file mode 100644 index 0000000..15690c7 --- /dev/null +++ b/scripts/verify_first_level_edit_suites.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_DIR = PROJECT_ROOT / "scripts" + + +STAGES: tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...] = ( + ( + "face", + "Face first-level edit baseline", + ( + ("Face edit suite", ("verify_face_edit_suite.py",)), + ("Face isolated worker edits", ("verify_isolated_face_edit.py",)), + ), + ), + ( + "hole-slot", + "Hole and slot first-level edit baseline", + ( + ("Hole/slot edit suite", ("verify_hole_slot_edit_suite.py",)), + ), + ), + ( + "edge", + "Edge first-level edit baseline", + ( + ("Edge edit suite", ("verify_edge_edit_suite.py",)), + ("Edge isolated worker edits", ("verify_edge_isolated_edit.py",)), + ), + ), + ( + "boss", + "Boss first-level edit baseline", + ( + ("Boss edit suite", ("verify_boss_edit_suite.py",)), + ), + ), + ( + "round-chamfer", + "Round and chamfer first-level edit baseline", + ( + ("Round/chamfer edit suite", ("verify_round_chamfer_edit_suite.py",)), + ), + ), + ( + "shell", + "Shell thickness first-level edit baseline", + ( + ("Shell edit suite", ("verify_shell_edit_suite.py",)), + ), + ), + ( + "analytic", + "Analytic surface edit baseline", + ( + ("Analytic surface edits", ("verify_analytic_surface_resize.py",)), + ("Cone semi-angle isolated edits", ("verify_cone_semi_angle_isolation.py",)), + ), + ), +) + +QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("Smoke test", ("main.py", "--smoke-test")), + ("Property editor specs", ("verify_property_editor_specs.py",)), + ("Property card editor UI", ("verify_property_card_editor_ui.py",)), + ("First-level fact graph", ("verify_first_level_fact_graph.py",)), + ("Associated feature probe and display budget", ("verify_associated_features.py",)), + ("First-level acceptance docs", ("verify_first_level_acceptance_docs.py",)), +) + + +def _stage_names() -> tuple[str, ...]: + return tuple(stage[0] for stage in STAGES) + + +def _selected_stages(names: tuple[str, ...]) -> tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...]: + if not names: + return STAGES + selected = {name.strip() for name in names if name.strip()} + return tuple(stage for stage in STAGES if stage[0] in selected) + + +def _run(label: str, command: tuple[str, ...], *, index: int, total: int) -> None: + print(f"\n[{index}/{total}] {label}", flush=True) + target = SCRIPT_DIR / command[0] + if not target.exists(): + target = PROJECT_ROOT / command[0] + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") + subprocess.run( + (sys.executable, str(target), *command[1:]), + cwd=PROJECT_ROOT, + env=env, + check=True, + ) + + +def _display_command(command: tuple[str, ...]) -> str: + target = SCRIPT_DIR / command[0] + prefix = "scripts\\" + if not target.exists(): + prefix = "" + args = " ".join(command[1:]) + return f"python {prefix}{command[0]} {args}".rstrip() + + +def _commands_for(stages: tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...]) -> list[tuple[str, tuple[str, ...]]]: + commands: list[tuple[str, tuple[str, ...]]] = [] + for _stage_name, stage_label, cases in stages: + for label, command in cases: + commands.append((f"{stage_label}: {label}", command)) + return commands + + +def main(argv: tuple[str, ...] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Run staged first-level geometry edit verification suites.", + ) + parser.add_argument( + "--stage", + action="append", + choices=_stage_names(), + help="Only run one stage. Repeat this option to run multiple stages.", + ) + parser.add_argument( + "--quick", + action="store_true", + help="Run lightweight first-level regression checks instead of the full geometry suites.", + ) + parser.add_argument( + "--list", + action="store_true", + help="Print the staged verification plan without running it.", + ) + args = parser.parse_args(argv) + + if args.quick: + commands = list(QUICK_COMMANDS) + heading = "quick first-level regression checks" + else: + stages = _selected_stages(tuple(args.stage or ())) + commands = _commands_for(stages) + heading = "first-level edit suites" + + if args.list: + print(f"{heading}:") + for index, (label, command) in enumerate(commands, start=1): + print(f"{index}. {label}: {_display_command(command)}") + return 0 + + total = len(commands) + for index, (label, command) in enumerate(commands, start=1): + _run(label, command, index=index, total=total) + print(f"\n{heading} passed.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_hole_slot_edit_suite.py b/scripts/verify_hole_slot_edit_suite.py new file mode 100644 index 0000000..4b8a276 --- /dev/null +++ b/scripts/verify_hole_slot_edit_suite.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import subprocess +import sys +import os +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_DIR = PROJECT_ROOT / "scripts" + + +CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ( + "Hole/slot cylindrical first-level topology and plan guards", + ("verify_cylindrical_first_level_topology.py",), + ), + ( + "Hole diameter, axis, suppress and blind-depth local rebuilds", + ("verify_hole_resize.py",), + ), + ( + "Slot width, depth, arc, axis and obround local rebuilds", + ("verify_slot_resize.py",), + ), + ( + "Hole/slot isolated worker execution and logical-id retention", + ("verify_hole_slot_isolated_edit.py",), + ), + ( + "Hole/slot recognition summary and editable action grouping", + ("verify_feature_recognition_summary.py",), + ), + ( + "Unified first-level fact graph includes holes and slots", + ("verify_first_level_fact_graph.py",), + ), + ( + "Property editor keeps generic Face edits out of hole/slot features", + ("verify_property_editor_specs.py",), + ), +) + + +def main() -> int: + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") + for index, (label, command) in enumerate(CASES, start=1): + print(f"\n[{index}/{len(CASES)}] {label}", flush=True) + subprocess.run( + (sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]), + cwd=PROJECT_ROOT, + env=env, + check=True, + ) + print("\nHole/slot edit suite passed.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py new file mode 100644 index 0000000..6f4c4b8 --- /dev/null +++ b/scripts/verify_property_card_editor_ui.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import os +from pathlib import Path +import sys + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QEvent, QObject +from PySide6.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QTableWidget, + QVBoxLayout, + QWidget, +) + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.widgets import NoWheelComboBox +from step_editor.window_state import WindowStateMixin + + +class _StatusBar: + def showMessage(self, _text: str) -> None: + pass + + +class _PropertyCardProbe(QWidget, WindowStateMixin): + def __init__(self) -> None: + super().__init__() + self.model = object() + self.operation_in_progress = False + self.scan_in_progress = False + self.load_in_progress = False + self.property_editor_updating = False + self.property_table_expanded = False + self.property_table_collapsed_rows = 4 + self.property_editor_selected_row = None + self.property_command_active_key = "" + self.property_command_buttons = {} + self.property_editor_specs = [] + self.selected_kind = "face" + self.selected_part_id = None + self.selected_solid_id = None + self.selected_face_id = 0 + self.selected_edge_id = None + self.current_info_values = {"area": 100.0} + self.property_table = QTableWidget(0, 5) + self.property_card_scroll = QScrollArea() + self.property_card_container = QWidget() + self.property_card_layout = QVBoxLayout(self.property_card_container) + self.property_card_scroll.setWidget(self.property_card_container) + self.property_command_summary_label = QLabel() + self.property_command_bar = QFrame() + self.property_command_layout = QHBoxLayout(self.property_command_bar) + self.apply_property_button = QPushButton() + + def _selected_action_info(self) -> dict[str, object]: + return { + "area": 100.0, + "surface": "plane", + "push_pull_status": "ready", + "first_level_boundary_edge_count": 4, + "first_level_boundary_vertex_count": 4, + "first_level_adjacent_face_count": 4, + } + + def _set_control_state(self, widget, enabled: bool, _enabled_tip: str, _disabled_tip: str) -> None: + widget.setEnabled(enabled) + + def statusBar(self) -> _StatusBar: + return _StatusBar() + + +class _TopLevelPropertyLabelProbe(QObject): + def __init__(self) -> None: + super().__init__() + self.shown_labels: list[str] = [] + + def eventFilter(self, watched, event): + if ( + event.type() == QEvent.Type.Show + and isinstance(watched, QLabel) + and watched.isWindow() + and watched.objectName().startswith("propertyCard") + ): + self.shown_labels.append(f"{type(watched).__name__}:{watched.objectName()}:{watched.text()}") + return False + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def main() -> int: + app = QApplication.instance() or QApplication([]) + top_level_label_probe = _TopLevelPropertyLabelProbe() + app.installEventFilter(top_level_label_probe) + probe = _PropertyCardProbe() + probe.show() + QApplication.processEvents() + probe._refresh_property_editor() + QApplication.processEvents() + _assert( + not top_level_label_probe.shown_labels, + f"property card labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}", + ) + + actionable_labels = [str(spec.get("label", "")) for _row, spec in probe._actionable_property_rows()] + _assert("面积" in actionable_labels, "modifiable feature list did not expose the editable area row") + _assert(not probe.property_command_buttons, "legacy command buttons should not be shown in the modifiable-feature panel") + _assert(not probe.property_command_bar.isVisible(), "legacy command bar should be hidden") + _assert("可修改项" in probe.property_command_summary_label.text(), "modifiable-feature summary is missing") + + rows = getattr(probe, "property_card_rows", {}) + _assert(rows, "property card rows were not built") + _assert( + not any(isinstance(widgets.get("target_editor"), QLineEdit) for widgets in rows.values()), + "modifiable feature rows should be compact until the user expands one", + ) + _assert( + all(str(widgets.get("status_label").text()) == "可修改" for widgets in rows.values()), + "compact modifiable rows should end with the editable status label", + ) + _assert( + all(not str(widgets.get("current_value").toolTip()) for widgets in rows.values() if widgets.get("current_value") is not None), + "compact modifiable rows should not show click-triggered tooltips", + ) + + target_row = probe._actionable_property_rows()[0][0] + probe._select_property_card_row(target_row) + rows = getattr(probe, "property_card_rows", {}) + editable_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("action_button"), QPushButton)] + target_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("target_editor"), QLineEdit)] + scope_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("scope_combo"), NoWheelComboBox)] + + _assert(editable_rows, "editable property card button is missing") + _assert(target_rows, "property card target editor is missing") + _assert(scope_rows, "property card modeling-intent combo is missing") + + probe._toggle_property_card_row(target_row) + QApplication.processEvents() + rows = getattr(probe, "property_card_rows", {}) + _assert(probe.property_editor_selected_row is None, "clicking an expanded row should collapse it") + _assert( + not any(isinstance(widgets.get("target_editor"), QLineEdit) for widgets in rows.values()), + "collapsed modifiable rows should return to compact display", + ) + _assert( + not any(editor.isVisible() for editor in probe.property_card_container.findChildren(QLineEdit)), + "collapsed modifiable rows should not leave stale target editors visible", + ) + _assert( + not any( + label.isVisible() and "建模意图:" in label.text() + for label in probe.property_card_container.findChildren(QLabel) + ), + "collapsed modifiable rows should not leave stale expanded hints visible", + ) + + probe._select_property_card_row(target_row) + rows = getattr(probe, "property_card_rows", {}) + target_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("target_editor"), QLineEdit)] + _assert(target_rows, "target editor is missing after re-expanding the row") + + rows[target_rows[0]]["target_editor"].setText("144") + probe._update_property_apply_state() + changed = probe._changed_property_rows() + button = rows[target_rows[0]]["action_button"] + _assert(changed, "card target edit was not detected") + _assert(bool(button.property("changed")), "card button did not enter changed state") + _assert(button.isEnabled(), "card button should be enabled for a valid changed target") + + probe.toggle_property_table_expanded() + _assert(probe.property_table_expanded, "property card expand toggle failed") + _assert(len(probe.property_card_rows) > len(rows), "expanded property card list did not reveal more rows") + expanded_editor = probe.property_card_rows[target_rows[0]].get("target_editor") + _assert(isinstance(expanded_editor, QLineEdit), "expanded target editor is missing") + _assert(expanded_editor.text().strip() == "144", "target value was not preserved after card rebuild") + + long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。" + probe.property_editor_specs = [ + { + "key": "feature_context_note", + "label": "关联探测", + "current_text": long_context, + "current_raw": long_context, + "target_text": "", + "editable": False, + "enabled": False, + "status_text": "说明", + "span_value_columns": True, + "pin_top": True, + }, + { + "key": "associated_face_center", + "label": "相邻平面 Face 1580 · 中心", + "current_text": "(-118.585, -23.3, -269.561)", + "current_raw": "(-118.585, -23.3, -269.561)", + "target_text": "(-118.585, -23.3, -269.561)", + "editable": True, + "enabled": True, + "action": "move_face_center", + "scope_text": "局部重建", + "status_text": "可修改", + }, + ] + probe.property_table_expanded = True + probe.property_editor_selected_row = None + probe._rebuild_property_cards() + QApplication.processEvents() + context_value = next( + ( + label + for label in probe.property_card_rows[0]["card"].findChildren(QLabel) + if label.objectName() == "propertyCardValue" + ), + None, + ) + _assert(isinstance(context_value, QLabel), "long associated detection note is missing") + _assert(context_value.wordWrap(), "long associated detection note should wrap in diagnostics view") + _assert("关联尺寸可在同一参数表中直接修改" in context_value.text(), "associated detection note lost its trailing text") + associated_widgets = probe.property_card_rows[1] + associated_card = associated_widgets["card"] + associated_title = next( + ( + label + for label in associated_card.findChildren(QLabel) + if label.objectName() == "propertyCardTitle" + ), + None, + ) + _assert(isinstance(associated_title, QLabel), "long associated row title is missing") + _assert(associated_title.wordWrap(), "long associated row title should use a two-line compact layout") + _assert(associated_card.height() > 24, "long associated row should be taller than a one-line compact row") + associated_value = associated_widgets.get("current_value") + _assert(isinstance(associated_value, QLabel), "long associated row value is missing") + _assert("(-118.585" in associated_value.text(), "long associated row value was hidden by the title") + + print("property card editor UI ok") + if QApplication.instance() is app: + app.quit() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_property_editor_specs.py b/scripts/verify_property_editor_specs.py index 5b46db1..fa092fd 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -8,7 +8,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from step_editor.window_state import WindowStateMixin +from step_editor.window_state import WindowStateMixin, _property_table_column_widths from step_editor.ui_helpers import INFO_LABELS, _format_value @@ -102,6 +102,12 @@ def _assert_current_text_contains( raise SystemExit(f"{label} {key} text missing {missing}: {text!r}") +def _assert_spans_value_columns(specs: list[dict[str, object]], key: str) -> None: + spec = _spec(specs, key) + if not bool(spec.get("span_value_columns")): + raise SystemExit(f"{key} should span the value/intent/target/action columns: {spec}") + + def _assert_hard_range(specs: list[dict[str, object]], key: str, low: float, high: float) -> None: spec = _spec(specs, key) actual_low = spec.get("min_value") @@ -312,6 +318,24 @@ def _assert_target_change_detection() -> None: raise SystemExit("changed vector Face target was not detected") +def _assert_property_table_column_widths() -> None: + for width in (320, 340, 360, 400, 520): + columns = _property_table_column_widths(width) + if len(columns) != 5: + raise SystemExit(f"property table should have five column widths, got {columns}") + if sum(columns) != width: + raise SystemExit(f"property table widths should fill viewport {width}, got {columns} sum={sum(columns)}") + label_width, current_width, scope_width, target_width, action_width = columns + if current_width < label_width + 40: + raise SystemExit(f"current value column should be wider than parameter-name column at {width}: {columns}") + if action_width < 70: + raise SystemExit(f"operation column should keep row buttons visible at {width}: {columns}") + if target_width < 56: + raise SystemExit(f"target value column should stay usable at {width}: {columns}") + if scope_width > label_width + 8: + raise SystemExit(f"modeling-intent column should not take space from current values at {width}: {columns}") + + def _assert_holed_plane_local_scopes_disabled() -> None: specs = _specs( { @@ -362,7 +386,15 @@ def _assert_holed_plane_local_scopes_disabled() -> None: def main() -> int: if INFO_LABELS.get("face_id") != "当前拓扑 Face ID": raise SystemExit("raw face_id label should make clear that it is the current topological Face ID") - for key in ("selection_title", "selection_display_id", "selection_topological_face_id"): + for key in ( + "selection_title", + "selection_display_id", + "selection_topological_face_id", + "feature_detection_level", + "associated_feature_count", + "associated_feature_face_ids", + "feature_context_note", + ): if key not in INFO_LABELS: raise SystemExit(f"{key} should have a user-facing label") @@ -393,6 +425,7 @@ def main() -> int: plane_specs = _specs(plane_info) plane_keys = {str(spec.get("key", "")) for spec in plane_specs} _assert_label(plane_specs, "cad_modeling_form", "建模形式") + _assert_spans_value_columns(plane_specs, "cad_modeling_form") _assert_current_text_contains( plane_specs, "cad_modeling_form", @@ -400,6 +433,7 @@ def main() -> int: "plane Face", ) _assert_label(plane_specs, "cad_recommended_operation", "推荐操作") + _assert_spans_value_columns(plane_specs, "cad_recommended_operation") _assert_current_text_contains( plane_specs, "cad_recommended_operation", @@ -443,11 +477,13 @@ def main() -> int: plane_feature_rows = plane_feature_probe._feature_property_specs(plane_feature_specs, plane_info) plane_feature_keys = {str(spec.get("key", "")) for spec in plane_feature_rows} topology_spec = _spec(plane_feature_rows, "face_first_level_topology") + _assert_spans_value_columns(plane_feature_rows, "face_first_level_topology") topology_text = str(topology_spec.get("current_text") or "") for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"): if fragment not in topology_text: raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}") _assert_label(plane_feature_rows, "face_edit_semantics", "建模意图") + _assert_spans_value_columns(plane_feature_rows, "face_edit_semantics") _assert_label(plane_feature_rows, "cad_modeling_form", "建模形式") _assert_label(plane_feature_rows, "cad_recommended_operation", "推荐操作") if str(plane_feature_rows[0].get("key", "")) != "cad_modeling_form": @@ -889,6 +925,15 @@ def main() -> int: "start_point": (0.0, 0.0, 0.0), "end_point": (10.0, 0.0, 0.0), "length_center": (5.0, 0.0, 0.0), + "topology_relation_depth": 1, + "selected_edge_count": 1, + "first_level_vertex_count": 2, + "first_level_adjacent_edge_count": 4, + "first_level_adjacent_face_count": 2, + "first_level_edge_count": 5, + "first_level_topology_note": "Edge first-level topology is ready.", + "first_level_fact_summary": "一级事实=当前 Edge、端点、共享端点相邻 Edge、直接相邻 Face。", + "topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。", } ) _assert_current_text_contains( @@ -903,7 +948,21 @@ def main() -> int: ("优先改长度", "移动端面", "只改当前Edge"), "line Edge", ) - _assert_contains({str(spec.get("key", "")) for spec in edge_specs}, {"length"}, "line Edge") + _assert_label(edge_specs, "edge_first_level_topology", "一级关系") + _assert_current_text_contains( + edge_specs, + "edge_first_level_topology", + ("端点 Vertex 2 个", "相邻 Edge 4 条", "直接相邻 Face 2 个"), + "line Edge", + ) + edge_topology_tip = str(_spec(edge_specs, "edge_first_level_topology").get("disabled_tip") or "") + if "二级、三级" not in edge_topology_tip: + raise SystemExit(f"line Edge topology tip should explain ignored deeper topology: {edge_topology_tip}") + _assert_contains( + {str(spec.get("key", "")) for spec in edge_specs}, + {"length", "edge_first_level_topology"}, + "line Edge", + ) low_recognition_specs = _specs( { @@ -928,6 +987,7 @@ def main() -> int: raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}") _assert_target_change_detection() + _assert_property_table_column_widths() _assert_holed_plane_local_scopes_disabled() _assert_no_legacy_face_source_terms() print("property editor specs ok") diff --git a/scripts/verify_round_chamfer_edit_suite.py b/scripts/verify_round_chamfer_edit_suite.py new file mode 100644 index 0000000..7d93cf5 --- /dev/null +++ b/scripts/verify_round_chamfer_edit_suite.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import subprocess +import sys +import os +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_DIR = PROJECT_ROOT / "scripts" + + +CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ( + "Edge fillet/chamfer and existing fillet rebuild checks", + ("verify_edge_round_chamfer.py",), + ), + ( + "Property editor exposes round/chamfer actions without generic Face leakage", + ("verify_property_editor_specs.py",), + ), +) + + +def main() -> int: + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") + for index, (label, command) in enumerate(CASES, start=1): + print(f"\n[{index}/{len(CASES)}] {label}", flush=True) + subprocess.run( + (sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]), + cwd=PROJECT_ROOT, + env=env, + check=True, + ) + print("\nRound/chamfer edit suite passed.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_shell_edit_suite.py b/scripts/verify_shell_edit_suite.py new file mode 100644 index 0000000..a3b8ab0 --- /dev/null +++ b/scripts/verify_shell_edit_suite.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import subprocess +import sys +import os +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_DIR = PROJECT_ROOT / "scripts" + + +CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ( + "Shell thickness first-level local and owning edits", + ("verify_shell_thickness_resize.py",), + ), + ( + "Property editor exposes shell thickness semantics", + ("verify_property_editor_specs.py",), + ), +) + + +def main() -> int: + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") + for index, (label, command) in enumerate(CASES, start=1): + print(f"\n[{index}/{len(CASES)}] {label}", flush=True) + subprocess.run( + (sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]), + cwd=PROJECT_ROOT, + env=env, + check=True, + ) + print("\nShell edit suite passed.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_shell_thickness_resize.py b/scripts/verify_shell_thickness_resize.py index 0d3f203..52f57f6 100644 --- a/scripts/verify_shell_thickness_resize.py +++ b/scripts/verify_shell_thickness_resize.py @@ -95,6 +95,24 @@ def _axis_affine_point( ) +def _assert_shell_plan_topology(plan: dict[str, object], label: str) -> None: + if plan.get("topology_relation_depth") != 1: + raise SystemExit(f"{label} should expose first-level topology depth: {plan}") + if plan.get("topology_relation_status") != "ready": + raise SystemExit(f"{label} first-level topology should be ready: {plan}") + if int(plan.get("first_level_boundary_edge_count", 0) or 0) < 1: + raise SystemExit(f"{label} should expose boundary Edges: {plan}") + if int(plan.get("first_level_boundary_vertex_count", 0) or 0) < 1: + raise SystemExit(f"{label} should expose boundary Vertices: {plan}") + if int(plan.get("first_level_adjacent_face_count", 0) or 0) < 1: + raise SystemExit(f"{label} should expose direct adjacent Faces: {plan}") + ignored = tuple(plan.get("topology_ignored_relation_depths", ()) or ()) + if "second-level" not in ignored or "third-level" not in ignored: + raise SystemExit(f"{label} should document ignored deeper topology: {plan}") + if plan.get("first_level_fact_status") != "ready": + raise SystemExit(f"{label} should expose a ready first-level fact graph: {plan}") + + def _assert_logical_face_retained( model: StepModel, logical_id: int, @@ -181,6 +199,7 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler plan = model.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": raise SystemExit(f"local shell plan was blocked: {plan['message']}") + _assert_shell_plan_topology(plan, "local shell thickness plan") outward = _vector(plan.get("outward_direction"), "local shell outward_direction") distance = float(plan.get("push_pull_distance", 0.0)) expected_logical_center = ( @@ -193,6 +212,7 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness) if plan["status"] == "blocked": raise SystemExit(f"owning shell plan was blocked: {plan['message']}") + _assert_shell_plan_topology(plan, "owning shell thickness plan") expected_logical_center = _axis_affine_point( before_face_center, _vector(plan.get("affine_axis_point"), "owning shell affine_axis_point"), @@ -219,6 +239,10 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler tolerance, f"{mode} shell thickness", ) + if "Face result check" not in result: + raise SystemExit(f"{mode} shell thickness result did not report Face result check: {result}") + if "First-level check" not in result: + raise SystemExit(f"{mode} shell thickness result did not report first-level check: {result}") print(f"mode={mode}") print(f"face_id={face_id}") diff --git a/step_editor/app.py b/step_editor/app.py index e58879d..39593de 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -8,6 +8,7 @@ import sys from pathlib import Path import vtk +import vtkmodules.vtkInteractionWidgets # noqa: F401 import vtkmodules.vtkInteractionStyle # noqa: F401 import vtkmodules.vtkRenderingFreeType # noqa: F401 import vtkmodules.vtkRenderingOpenGL2 # noqa: F401 @@ -57,6 +58,7 @@ from .window_state import WindowStateMixin _CRASH_LOG_HANDLE = None +_VTK_OUTPUT_WINDOW = None PROJECT_ROOT = Path(__file__).resolve().parent.parent APP_USER_MODEL_ID = "GeometryParametric.StepEditor" ISOLATED_EDIT_WORKER_ARG = "--isolated-edit-worker" @@ -119,6 +121,18 @@ def _set_windows_taskbar_identity() -> None: pass +def _suppress_vtk_output_window() -> None: + """Keep VTK warnings from opening transient native output windows.""" + global _VTK_OUTPUT_WINDOW + try: + vtk.vtkObject.GlobalWarningDisplayOff() + output_window = vtk.vtkStringOutputWindow() + vtk.vtkOutputWindow.SetInstance(output_window) + _VTK_OUTPUT_WINDOW = output_window + except Exception: + pass + + def _isolated_edit_worker_request(argv: list[str]) -> Path | None: if ISOLATED_EDIT_WORKER_ARG not in argv: return None @@ -132,6 +146,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf ui_task_requested = Signal(object) def __init__(self, step_path: str | Path, *, background_load: bool = False): + _suppress_vtk_output_window() super().__init__() self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection) self.setWindowTitle("几何参数化") @@ -149,6 +164,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.model_actor = None self.edge_actor = None + self.scene_axes_renderer = None + self.orientation_marker_widget = None + self.orientation_marker_prop = None + self.step_coordinate_axes_actor = None + self.hide_edges_during_camera_interaction = False + self.edge_visibility_before_camera_interaction: int | None = None + self.prefer_fxaa_antialiasing = True + self.fallback_multi_samples = 2 + self.interactive_multi_samples = 0 + self.still_multi_samples = 0 self.highlight_actor = None self.edge_highlight_actor = None self.highlight_signature: tuple[object, ...] | None = None @@ -160,6 +185,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.pending_hover_position: tuple[int, int] | None = None self.last_hover_pick_position: tuple[int, int] | None = None self.camera_interaction_active = False + self.camera_interaction_visual_changed = False self.last_camera_interaction_ended_at: datetime | None = None self.hover_after_camera_cooldown_ms = 420 self.pointer_button_down = False @@ -233,9 +259,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.auto_load_on_show = bool(background_load) self.vtk_interactor_started = False self.first_show_handled = False - self.preview_load_deflection = 1.0 - self.initial_load_deflection = 0.035 - self.edit_result_deflection = 0.035 + self.preview_load_deflection = 0.35 + self.initial_load_deflection = 0.35 + self.edit_result_deflection = 0.35 self.last_id_kind = "Feature" self.feature_detection_level = "current-only" self.property_editor_updating = False @@ -244,7 +270,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self._selected_action_info_cache_value: dict[str, object] | None = None self.property_editor_specs: list[dict[str, object]] = [] self.property_table_expanded = False - self.property_table_collapsed_rows = 6 + self.property_table_collapsed_rows = 8 + self.property_editor_selected_row: int | None = None + self.property_command_active_key = "" + self.property_command_buttons: dict[str, QPushButton] = {} self._build_ui() self._build_vtk() @@ -487,8 +516,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf border-radius: 5px; color: #ffffff; font-weight: 700; - min-height: 18px; - padding: 1px 3px; + min-height: 26px; + padding: 4px 8px; } QPushButton#propertyRowEditButton:hover { background: #f97316; @@ -646,6 +675,109 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf padding-left: 1px; padding-right: 1px; } + QLabel#propertyCommandSummary { + color: #334155; + font-size: 11px; + padding: 0 2px 1px 2px; + } + QLabel#propertyCommandHelp { + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 6px; + color: #7c2d12; + font-size: 11px; + padding: 5px 7px; + } + QFrame#propertyCommandBar { + background: #eef2f7; + border: 1px solid #cbd5e1; + border-radius: 6px; + } + QPushButton#propertyCommandButton { + background: transparent; + border: 0; + border-radius: 4px; + color: #334155; + font-weight: 700; + min-height: 34px; + padding: 3px 6px; + } + QPushButton#propertyCommandButton:hover { + background: #e2e8f0; + } + QPushButton#propertyCommandButton[active="true"] { + background: #ffffff; + color: #0f172a; + border: 1px solid #f59e0b; + } + QScrollArea#propertyCardScroll { + background: transparent; + border: 0; + } + QWidget#propertyCardContainer { + background: transparent; + } + QFrame#propertyCard { + background: #ffffff; + border: 1px solid #d8e0eb; + border-radius: 6px; + } + QFrame#propertyCard[compact="true"] { + background: #ffffff; + } + QFrame#propertyCard[editable="true"] { + background: #fffaf2; + border-color: #f4c58a; + } + QFrame#propertyCard[selected="true"] { + background: #fffaf2; + border-color: #f59e0b; + } + QFrame#propertyCard[changed="true"] { + background: #f0fdf4; + border-color: #16a34a; + } + QFrame#propertyCard[invalid="true"] { + background: #fff1f2; + border-color: #ef4444; + } + QFrame#propertyCard[pinTop="true"] { + background: #eef6ff; + border-color: #b7d3f5; + } + QLabel#propertyCardTitle { + color: #172033; + font-weight: 700; + } + QLabel#propertyCardMetaLabel { + color: #64748b; + font-size: 11px; + font-weight: 700; + } + QLabel#propertyCardValue { + color: #172033; + } + QLabel#propertyCardCompactValue { + color: #172033; + font-weight: 600; + } + QLabel#propertyCardHint { + color: #64748b; + font-size: 11px; + } + QLabel#propertyCardCompactMeta { + color: #475569; + font-size: 11px; + } + QLineEdit#propertyCardTargetEditor { + background: #ffffff; + border: 1px solid #c8d2df; + border-radius: 4px; + padding: 3px 5px; + } + QLineEdit#propertyCardTargetEditor:focus { + border-color: #2563eb; + } QTabWidget::pane { border: 1px solid #d8e0eb; border-radius: 6px; @@ -920,20 +1052,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.object_edit_box.setObjectName("editSection") help_tip( self.object_edit_box, - "选择特征后,这里只列出可可靠修改的语义尺寸;面积、中心和拓扑数据位于下方诊断信息。", + "选择对象后,这里以参数卡片显示当前值、建模意图、目标值和应用按钮;诊断信息默认折叠在后面。", ) object_edit_layout = QVBoxLayout(self.object_edit_box) object_edit_layout.setContentsMargins(0, 8, 0, 4) object_edit_layout.setSpacing(4) - self.property_table = QTableWidget(0, 5) + self.property_table = QTableWidget(0, 5, self.object_edit_box) self.property_table.setObjectName("propertyTable") - self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值", "操作"]) + self.property_table.setHorizontalHeaderLabels(["参数", "当前值", "建模意图", "目标值", "操作"]) self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.property_table.setAlternatingRowColors(True) self.property_table.setMinimumHeight(180) self.property_table.setWordWrap(False) - self.property_table.setTextElideMode(Qt.TextElideMode.ElideMiddle) + self.property_table.setTextElideMode(Qt.TextElideMode.ElideRight) self.property_table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.property_table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.property_table.verticalHeader().setVisible(False) @@ -946,21 +1078,51 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) - property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) - self.property_table.setColumnWidth(0, 148) - self.property_table.setColumnWidth(1, 68) - self.property_table.setColumnWidth(2, 96) - self.property_table.setColumnWidth(3, 72) - self.property_table.setColumnWidth(4, 54) + property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) + self._resize_property_table_columns() + self.property_table.installEventFilter(self) help_tip( self.property_table, "特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。", ) self.property_table.itemChanged.connect(self._on_property_table_item_changed) - object_edit_layout.addWidget(self.property_table) - self.property_expand_button = QPushButton("展开全部参数") + self.property_table.setVisible(False) + self.property_table.setMaximumHeight(0) + self.property_command_summary_label = QLabel("未选择可编辑对象") + self.property_command_summary_label.setObjectName("propertyCommandSummary") + self.property_command_summary_label.setWordWrap(True) + object_edit_layout.addWidget(self.property_command_summary_label) + self.property_command_bar = QFrame() + self.property_command_bar.setObjectName("propertyCommandBar") + self.property_command_layout = QHBoxLayout(self.property_command_bar) + self.property_command_layout.setContentsMargins(3, 3, 3, 3) + self.property_command_layout.setSpacing(2) + object_edit_layout.addWidget(self.property_command_bar) + self.property_command_help_label = QLabel("") + self.property_command_help_label.setObjectName("propertyCommandHelp") + self.property_command_help_label.setWordWrap(True) + self.property_command_help_label.setVisible(False) + object_edit_layout.addWidget(self.property_command_help_label) + self.property_card_scroll = QScrollArea() + self.property_card_scroll.setObjectName("propertyCardScroll") + self.property_card_scroll.setWidgetResizable(True) + self.property_card_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.property_card_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.property_card_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.property_card_container = QWidget() + self.property_card_container.setObjectName("propertyCardContainer") + self.property_card_layout = QVBoxLayout(self.property_card_container) + self.property_card_layout.setContentsMargins(0, 0, 0, 0) + self.property_card_layout.setSpacing(6) + self.property_card_scroll.setWidget(self.property_card_container) + help_tip( + self.property_card_scroll, + "每张卡片对应一个参数;修改目标值后,点击卡片右上角的按钮只应用这一项。", + ) + object_edit_layout.addWidget(self.property_card_scroll) + self.property_expand_button = QPushButton("更多诊断信息") self.property_expand_button.setObjectName("propertyExpandBar") - help_tip(self.property_expand_button, "参数较多时默认只显示前 6 行;点击这里展开或收起完整参数列表。") + help_tip(self.property_expand_button, "默认优先显示可编辑参数和关键说明;点击这里展开或收起完整诊断信息。") self.property_expand_button.clicked.connect(self.toggle_property_table_expanded) self.property_expand_button.setVisible(False) self.property_expand_button.setMaximumHeight(22) @@ -969,6 +1131,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf property_action_row = QHBoxLayout() property_action_row.setContentsMargins(0, 0, 0, 0) self.apply_property_button = QPushButton("参数化建模") + self.apply_property_button.setVisible(False) help_tip(self.apply_property_button, "按当前属性表里修改过的目标值执行参数化建模;一次只执行一个几何修改,成功后可撤销。") self.apply_property_button.clicked.connect(self.apply_current_property_edit) self.quick_export_all_button = QPushButton("导出当前完整STEP") @@ -1465,6 +1628,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.vtk_widget.setMouseTracking(True) self.vtk_widget.installEventFilter(self) root_layout.addWidget(self.vtk_widget, stretch=1) + app = QApplication.instance() + if app is not None and not getattr(self, "_global_tooltip_filter_installed", False): + app.installEventFilter(self) + self._global_tooltip_filter_installed = True self.statusBar().showMessage("请选择 STEP 文件,或点击“读取模型”加载到可视化区域。") @@ -1495,6 +1662,7 @@ def main() -> int: _enable_crash_log() path, smoke_test = _parse_args(sys.argv) _set_windows_taskbar_identity() + _suppress_vtk_output_window() app = QApplication(sys.argv) app.setApplicationName("几何参数化") app.setApplicationDisplayName("几何参数化") diff --git a/step_editor/features.py b/step_editor/features.py index 290e669..9b75596 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -2193,6 +2193,15 @@ class FeatureMixin: scoped_info["height_estimate"] = axis_range["span"] scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) readiness = _cylinder_boss_resize_readiness(scoped_info, new_diameter) + readiness = self._apply_cylindrical_first_level_guard_to_readiness( + readiness, + topology_fields, + status_key="boss_resize_status", + risk_key="boss_resize_risk", + note_key="boss_resize_note", + warnings_key="boss_resize_warnings", + blockers_key="boss_resize_blockers", + ) resize_mode = _resize_mode(current_diameter, new_diameter) delta_diameter = new_diameter - current_diameter diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) @@ -2213,6 +2222,7 @@ class FeatureMixin: "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], + **topology_fields, "current_diameter": current_diameter, "target_diameter": new_diameter, "delta_diameter": delta_diameter, @@ -2252,12 +2262,14 @@ class FeatureMixin: feature = self.feature_info(face_id) except Exception: feature = {} + topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id) blockers: list[str] = [] warnings: list[str] = [ "Cylindrical boss axis move removes the old boss envelope, then fuses a same-diameter boss on the target axis." ] risk = "medium" + risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk) try: target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) @@ -2360,6 +2372,7 @@ class FeatureMixin: "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), + **topology_fields, "surface": info.get("surface"), "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, diff --git a/step_editor/model.py b/step_editor/model.py index 6f5c79b..26ac267 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -127,6 +127,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._edge_polydata_cache: dict[tuple[object, ...], object] = {} self._polydata_cache_limit = 96 self._mesh_deflection: float | None = None + self._face_mesh_deflections: dict[int, float] = {} self.refresh_topology() @classmethod @@ -214,6 +215,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._face_polydata_cache.clear() self._edge_polydata_cache.clear() self._mesh_deflection = None + self._face_mesh_deflections.clear() solid_id = 0 for part in self.display_parts(): diff --git a/step_editor/operations.py b/step_editor/operations.py index e4a5bab..4328f01 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -1678,6 +1678,23 @@ class OperationMixin: notes.append(f"相邻圆柱Face {face_id} 的{mode_label}不可用:{axis_plan.get('message', '')}") continue + axis_topology_fields = { + f"move_axis_{key}": axis_plan.get(key) + for key in ( + "topology_relation_depth", + "topology_relation_status", + "topology_relation_boundary", + "topology_ignored_relation_depths", + "first_level_topology_status", + "first_level_topology_guard_note", + "first_level_boundary_edge_count", + "first_level_adjacent_face_count", + "cylindrical_feature_side_face_count", + "cylindrical_feature_boundary_edge_count", + "cylindrical_feature_adjacent_face_count", + ) + if key in axis_plan + } candidate = { "circular_edge_cylinder_face_id": face_id, "circular_edge_cylinder_mode": mode, @@ -1703,6 +1720,7 @@ class OperationMixin: "move_axis_blockers": axis_plan.get("blockers", ""), "move_axis_feature_guess": axis_plan.get("feature_guess", feature_guess), "move_axis_confidence": axis_plan.get("confidence", face_info.get("confidence", "")), + **axis_topology_fields, "resize_strategy": "move-adjacent-cylinder-from-circular-edge-center", "edit_strategy_label": f"通过{mode_label}移动圆Edge", "edit_semantics": ( @@ -2347,11 +2365,53 @@ class OperationMixin: feature_guess = str(info.get("feature_guess", "")) current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"])) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) + topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id) warnings: list[str] = [] blockers: list[str] = [] risk = "medium" status = "caution" + topology_status = str(topology_fields.get("topology_relation_status") or "") + side_count = int(topology_fields.get("cylindrical_feature_side_face_count", 0) or 0) + boundary_edge_count = int(topology_fields.get("cylindrical_feature_boundary_edge_count", 0) or 0) + adjacent_face_ids = tuple(_int_values(topology_fields.get("cylindrical_feature_adjacent_face_ids"))) + adjacent_count = int(topology_fields.get("cylindrical_feature_adjacent_face_count", 0) or 0) + missing_support_faces = tuple(sorted(set(support_face_ids) - set(adjacent_face_ids))) + topology_blockers: list[str] = [] + topology_warnings: list[str] = [] + topology_risk = "low" + if topology_status != "ready": + topology_blockers.append("当前圆角的一级拓扑关系无法确认,不能稳定执行移除旧圆角并重建。") + if side_count <= 0: + topology_blockers.append("没有识别到当前圆角面区域。") + if boundary_edge_count <= 0: + topology_blockers.append("没有识别到当前圆角面的边界 Edge,不能确认重建影响范围。") + if adjacent_count < 2: + topology_blockers.append("没有识别到当前圆角直接连接的两个支撑 Face。") + if missing_support_faces: + topology_blockers.append(f"已识别支撑 Face {missing_support_faces} 不在当前圆角一级相邻 Face 中。") + if adjacent_count > 8 or boundary_edge_count > 16: + topology_risk = _max_risk(topology_risk, "high") + topology_warnings.append("当前圆角一级邻域较复杂,重建后需要重点检查相邻面。") + elif adjacent_count > 4 or boundary_edge_count > 8: + topology_risk = _max_risk(topology_risk, "medium") + topology_warnings.append("当前圆角一级邻域包含较多相邻面或边界边。") + topology_fields.update( + { + "first_level_topology_status": "blocked" if topology_blockers else "ready", + "first_level_topology_risk": "blocked" if topology_blockers else topology_risk, + "first_level_topology_blockers": ";".join(topology_blockers), + "first_level_topology_warnings": ";".join(topology_warnings), + "first_level_topology_guard_note": ( + f"圆角一级关系检查:圆角 Face {side_count} 个,边界 Edge {boundary_edge_count} 条," + f"直接相邻 Face {adjacent_count} 个,支撑 Face {len(support_face_ids)} 个。" + ), + } + ) + blockers.extend(topology_blockers) + warnings.extend(topology_warnings) + risk = _max_risk(risk, str(topology_fields.get("first_level_topology_risk") or "low")) + if feature_guess != "round/fillet candidate": blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。") if target_radius <= 0: @@ -2412,6 +2472,7 @@ class OperationMixin: "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), + **topology_fields, "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, "confidence": info.get("confidence"), @@ -4105,6 +4166,7 @@ class OperationMixin: blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}。") if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate": blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。") + risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk) cylinder_identity = {**info, **feature} angular_span = _effective_cylinder_angular_span(cylinder_identity) or 0.0 if not _is_effectively_full_cylinder(cylinder_identity): @@ -4239,6 +4301,7 @@ class OperationMixin: "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), + **topology_fields, "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), @@ -4320,7 +4383,8 @@ class OperationMixin: f"target_height={float(plan['target_height']):g}, " f"delta={float(plan['delta_height']):g}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " - f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}. {push_result}" + f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}, " + f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}. {push_result}" ) def shell_thickness_preview_polydata( @@ -7664,6 +7728,7 @@ class OperationMixin: check = self._edge_length_result_check(plan) if check is None: return "Result check unavailable." + topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=False) return ( "Result check: " f"match={check['match_method']}, " @@ -7672,7 +7737,7 @@ class OperationMixin: f"target_error={float(check['target_error']):g}, " f"relative_error={float(check['relative_error']):g}, " f"endpoint_error={float(check['endpoint_error']):g}, " - f"scope={check['scope']}." + f"scope={check['scope']}. {topology_check}" ) def _edge_length_result_summary_or_raise(self, plan: dict[str, object]) -> str: @@ -7699,6 +7764,7 @@ class OperationMixin: f"允许误差 {endpoint_tolerance:g}。" ) + topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=True) return ( "Result check: " f"match={check['match_method']}, " @@ -7707,7 +7773,173 @@ class OperationMixin: f"target_error={float(check['target_error']):g}, " f"relative_error={float(check['relative_error']):g}, " f"endpoint_error={float(check['endpoint_error']):g}, " - f"scope={check['scope']}." + f"scope={check['scope']}. {topology_check}" + ) + + def _edge_first_level_result_summary( + self, + plan: dict[str, object], + result_edge_id: int, + *, + enforce: bool, + ) -> str: + strategy = str(plan.get("resize_strategy") or "") + enforce_strategy = strategy in { + "local-edge-only-deform", + "local-edge-endpoint-deform", + "local-edge-center-deform", + "move-edge-end-plane-by-push-pull", + } + expected_vertices = _int_or_none(plan.get("first_level_fact_boundary_vertex_count")) + if expected_vertices is None: + expected_vertices = _int_or_none(plan.get("first_level_vertex_count")) + expected_adjacent_edges = _int_or_none(plan.get("first_level_fact_adjacent_edge_count")) + if expected_adjacent_edges is None: + expected_adjacent_edges = _int_or_none(plan.get("first_level_adjacent_edge_count")) + expected_adjacent_faces = _int_or_none(plan.get("first_level_fact_adjacent_face_count")) + if expected_adjacent_faces is None: + expected_adjacent_faces = _int_or_none(plan.get("first_level_adjacent_face_count")) + expected_included_edges = _int_or_none(plan.get("first_level_fact_included_edge_count")) + if expected_included_edges is None: + expected_included_edges = _int_or_none(plan.get("first_level_edge_count")) + + if not any( + value is not None and value > 0 + for value in (expected_vertices, expected_adjacent_edges, expected_adjacent_faces, expected_included_edges) + ): + return "First-level topology check: unavailable." + + try: + topology = self.edge_first_level_topology(int(result_edge_id)) + except Exception as exc: + if enforce and enforce_strategy: + raise RuntimeError( + "Edge编辑后无法重新识别目标Edge的一级关系,模型已恢复到修改前状态。" + f"目标Edge {result_edge_id},原因:{exc}" + ) from exc + return f"First-level topology check: unavailable ({exc})." + + actual_vertices = int(topology.get("first_level_vertex_count", 0) or 0) + actual_adjacent_edges = int(topology.get("first_level_adjacent_edge_count", 0) or 0) + actual_adjacent_faces = int(topology.get("first_level_adjacent_face_count", 0) or 0) + actual_included_edges = int(topology.get("first_level_edge_count", 0) or 0) + + failures: list[str] = [] + if expected_vertices is not None and expected_vertices > 0 and actual_vertices != expected_vertices: + failures.append(f"端点 Vertex {actual_vertices}/{expected_vertices}") + if ( + expected_adjacent_edges is not None + and expected_adjacent_edges > 0 + and actual_adjacent_edges < expected_adjacent_edges + ): + failures.append(f"共享端点相邻 Edge {actual_adjacent_edges}/{expected_adjacent_edges}") + if ( + expected_adjacent_faces is not None + and expected_adjacent_faces > 0 + and actual_adjacent_faces < expected_adjacent_faces + ): + failures.append(f"直接相邻 Face {actual_adjacent_faces}/{expected_adjacent_faces}") + if ( + expected_included_edges is not None + and expected_included_edges > 0 + and actual_included_edges < expected_included_edges + ): + failures.append(f"一级范围 Edge {actual_included_edges}/{expected_included_edges}") + + if failures and enforce and enforce_strategy: + raise RuntimeError( + "Edge编辑后一级关系不完整,模型已恢复到修改前状态。" + f"目标Edge {result_edge_id},未通过:{'; '.join(failures)}。" + ) + + status = "ok" if not failures else f"warning({'; '.join(failures)})" + return ( + "First-level topology check: " + f"{status}, edge={result_edge_id}, endpoint_vertices={actual_vertices}, " + f"adjacent_edges={actual_adjacent_edges}, adjacent_faces={actual_adjacent_faces}, " + f"included_edges={actual_included_edges}." + ) + + def _edge_blend_result_summary_or_raise( + self, + plan: dict[str, object], + before_stats, + *, + mode: str, + ) -> str: + part_id = _int_or_none(plan.get("part_id")) + if part_id is None: + raise RuntimeError("Edge blend result check failed: the source part could not be resolved.") + part = self.part_by_id(part_id) + if part is None: + raise RuntimeError(f"Edge blend result check failed: unknown part id {part_id}.") + + _ensure_valid_shape(part.shape) + after_stats = self.part_topology_stats(part_id) + before_solids = int(getattr(before_stats, "solids", 0)) + after_solids = int(getattr(after_stats, "solids", 0)) + if before_solids > 0 and after_solids != before_solids: + raise RuntimeError( + "Edge blend result changed the solid count; the model has been restored to the previous state. " + f"Solids {before_solids}->{after_solids}." + ) + + face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0)) + edge_delta = int(getattr(after_stats, "edges", 0)) - int(getattr(before_stats, "edges", 0)) + if mode == "fillet": + target_radius = _float_or_none(plan.get("target_radius")) + if target_radius is None or target_radius <= 0.0: + raise RuntimeError("Edge fillet result check failed: target radius is invalid.") + tolerance = max(_shape_diagonal(part.shape) * 1e-5, target_radius * 1e-3, 1e-5) + matched_faces: list[int] = [] + for face_id in range(len(self.faces)): + if int(self.face_part_ids[face_id]) != part_id: + continue + try: + info = self.face_info(face_id) + except Exception: + continue + if info.get("surface") != "cylinder": + continue + radius = _float_or_none(info.get("radius")) + if radius is None: + continue + if abs(radius - target_radius) <= tolerance: + matched_faces.append(face_id) + if not matched_faces: + raise RuntimeError( + "Edge fillet result did not contain a cylindrical face near the target radius; " + "the model has been restored to the previous state. " + f"Target radius {target_radius:g}, tolerance {tolerance:g}." + ) + if face_delta < 0: + raise RuntimeError( + "Edge fillet result lost faces unexpectedly; the model has been restored to the previous state. " + f"Face delta {face_delta}." + ) + return ( + "Edge blend result check: " + f"mode=fillet, solids={before_solids}->{after_solids}, " + f"face_delta={face_delta}, edge_delta={edge_delta}, " + f"target_radius={target_radius:g}, matched_faces={tuple(matched_faces[:8])}." + ) + + if face_delta <= 0: + raise RuntimeError( + "Edge chamfer result did not add the expected chamfer face; " + "the model has been restored to the previous state. " + f"Face delta {face_delta}." + ) + if edge_delta <= 0: + raise RuntimeError( + "Edge chamfer result did not add the expected boundary edges; " + "the model has been restored to the previous state. " + f"Edge delta {edge_delta}." + ) + return ( + "Edge blend result check: " + f"mode={mode}, solids={before_solids}->{after_solids}, " + f"face_delta={face_delta}, edge_delta={edge_delta}." ) def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None: @@ -10166,6 +10398,119 @@ class OperationMixin: return False return bool(saw_outer_radius and saw_inner_radius) + def _existing_fillet_result_summary_or_raise( + self, + plan: dict[str, object], + before_stats, + ) -> str: + part_id = _int_or_none(plan.get("part_id")) + if part_id is None: + raise RuntimeError("Existing fillet result check failed: the source part could not be resolved.") + part = self.part_by_id(part_id) + if part is None: + raise RuntimeError(f"Existing fillet result check failed: unknown part id {part_id}.") + + _ensure_valid_shape(part.shape) + after_stats = self.part_topology_stats(part_id) + before_solids = int(getattr(before_stats, "solids", 0)) + after_solids = int(getattr(after_stats, "solids", 0)) + if before_solids > 0 and after_solids != before_solids: + raise RuntimeError( + "Existing fillet result changed the solid count; the model has been restored to the previous state. " + f"Solids {before_solids}->{after_solids}." + ) + + target_radius = _float_or_none(plan.get("target_radius")) + if target_radius is None or target_radius <= 0.0: + raise RuntimeError("Existing fillet result check failed: target radius is invalid.") + diagonal = max(_shape_diagonal(part.shape), target_radius, 1.0) + radius_tolerance = max(target_radius * 0.01, diagonal * 1e-5, 1e-5) + required_adjacent = max(2, min(int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0), 4)) + required_boundary = max(1, min(int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0), 4)) + matched: list[dict[str, object]] = [] + closest: dict[str, object] | None = None + + for candidate_face_id in range(len(self.faces)): + if int(self.face_part_ids[candidate_face_id]) != part_id: + continue + try: + info = self.face_info(candidate_face_id) + except Exception: + continue + if info.get("surface") != "cylinder": + continue + radius = _float_or_none(info.get("radius")) + if radius is None: + continue + radius_error = abs(radius - target_radius) + candidate = { + "face_id": candidate_face_id, + "radius": radius, + "radius_error": radius_error, + "feature_guess": info.get("feature_guess", ""), + "angular_span": info.get("angular_span", ""), + } + if closest is None or radius_error < float(closest["radius_error"]): + closest = candidate + if radius_error > radius_tolerance: + continue + try: + topology = self.cylindrical_feature_first_level_topology(candidate_face_id) + side_count = int(topology.get("cylindrical_feature_side_face_count", 0) or 0) + boundary_count = int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0) + adjacent_count = int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) + except Exception as exc: + raise RuntimeError( + "Existing fillet result matched the target radius but first-level topology failed; " + f"Face {candidate_face_id}, error={exc}." + ) from exc + if side_count <= 0 or boundary_count < required_boundary or adjacent_count < required_adjacent: + raise RuntimeError( + "Existing fillet result matched the target radius but lost first-level topology; " + f"Face {candidate_face_id}, side={side_count}, boundary_edges={boundary_count}, " + f"adjacent_faces={adjacent_count}, required_boundary={required_boundary}, " + f"required_adjacent={required_adjacent}." + ) + matched.append( + { + **candidate, + "side_face_count": side_count, + "boundary_edge_count": boundary_count, + "adjacent_face_count": adjacent_count, + } + ) + + if not matched: + if closest is None: + detail = "no cylindrical Face remained in the source part" + else: + detail = ( + f"closest Face {closest['face_id']} radius {float(closest['radius']):.6g}, " + f"target {target_radius:.6g}, error {float(closest['radius_error']):.6g}" + ) + raise RuntimeError( + "Existing fillet result did not contain a target-radius cylindrical round Face; " + f"the model has been restored to the previous state. {detail}." + ) + + face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0)) + edge_delta = int(getattr(after_stats, "edges", 0)) - int(getattr(before_stats, "edges", 0)) + matched_face_ids = tuple(int(item["face_id"]) for item in matched[:8]) + topology_summaries = tuple( + { + "face_id": int(item["face_id"]), + "boundary_edge_count": int(item["boundary_edge_count"]), + "adjacent_face_count": int(item["adjacent_face_count"]), + } + for item in matched[:8] + ) + return ( + "Existing fillet result check: " + f"solids={before_solids}->{after_solids}, face_delta={face_delta}, edge_delta={edge_delta}, " + f"target_radius={target_radius:g}, matched_faces={matched_face_ids}, " + f"first_level_topology_matched=True, topology={topology_summaries}." + ) + def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": @@ -10176,49 +10521,58 @@ class OperationMixin: if part is None: raise ValueError(f"Unknown part id {part_id}") - source_face = topods.Face(self.faces[face_id]) - defeatured = _defeature_faces(part.shape, [source_face]) - axis_point = gp_Pnt(*plan["axis_point"]) - axis_dir = gp_Dir(*plan["axis"]) - root_edges = _axis_aligned_edge_candidates( - defeatured, - axis_point, - axis_dir, - expected_length=float(plan.get("height_estimate") or 0.0), - reference_radius=float(plan["current_radius"]), - ) - if not root_edges: - raise RuntimeError( - "已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;" - "该圆角可能不是简单直线边圆角。" + old_part_shape = part.shape + before_stats = self.part_topology_stats(part_id) + try: + source_face = topods.Face(self.faces[face_id]) + defeatured = _defeature_faces(part.shape, [source_face]) + axis_point = gp_Pnt(*plan["axis_point"]) + axis_dir = gp_Dir(*plan["axis"]) + root_edges = _axis_aligned_edge_candidates( + defeatured, + axis_point, + axis_dir, + expected_length=float(plan.get("height_estimate") or 0.0), + reference_radius=float(plan["current_radius"]), ) + if not root_edges: + raise RuntimeError( + "Existing fillet face was removed, but no axis-aligned sharp Edge was found for refillet; " + "the selected round is probably not a simple straight-edge fillet." + ) - result = None - failures: list[str] = [] - for index, root_edge in enumerate(root_edges[:16], start=1): - try: - maker = BRepFilletAPI_MakeFillet(defeatured) - maker.Add(float(target_radius), topods.Edge(root_edge)) - result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}") - break - except Exception as exc: - failures.append(str(exc)) - if result is None: - detail = failures[-1] if failures else "没有可用的候选边。" - raise RuntimeError( - "已移除已有圆角面,但所有候选锐边都无法重新倒圆;" - f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}" - ) + result = None + failures: list[str] = [] + for index, root_edge in enumerate(root_edges[:16], start=1): + try: + maker = BRepFilletAPI_MakeFillet(defeatured) + maker.Add(float(target_radius), topods.Edge(root_edge)) + result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}") + break + except Exception as exc: + failures.append(str(exc)) + if result is None: + detail = failures[-1] if failures else "no candidate Edge was available" + raise RuntimeError( + "Existing fillet face was removed, but every candidate sharp Edge failed to refillet; " + f"the selected round may be a complex blend. Last error: {detail}" + ) + + part.shape = result + self.refresh_topology() + result_check = self._existing_fillet_result_summary_or_raise(plan, before_stats) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise - part.shape = result - self.refresh_topology() return ( "Existing fillet radius resize completed: " f"face {face_id}, current_radius={float(plan['current_radius']):g}, " f"target_radius={target_radius:g}, " f"delta_radius={float(plan['delta_radius']):g}, " f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def fillet_edge(self, edge_id: int, radius: float) -> str: @@ -10231,16 +10585,24 @@ class OperationMixin: if part is None: raise ValueError(f"Unknown part id {part_id}") - maker = BRepFilletAPI_MakeFillet(part.shape) - maker.Add(radius, topods.Edge(self.edges[edge_id])) - result = _finalize_builder_result(maker, "edge fillet") - part.shape = result - self.refresh_topology() + old_part_shape = part.shape + before_stats = self.part_topology_stats(part_id) + try: + maker = BRepFilletAPI_MakeFillet(part.shape) + maker.Add(radius, topods.Edge(self.edges[edge_id])) + result = _finalize_builder_result(maker, "edge fillet") + part.shape = result + self.refresh_topology() + result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="fillet") + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( f"Edge fillet completed: edge {edge_id}, radius={radius:g}, " f"edge_length={float(plan['edge_length']):g}, " f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def chamfer_edge(self, edge_id: int, distance: float) -> str: @@ -10253,16 +10615,24 @@ class OperationMixin: if part is None: raise ValueError(f"Unknown part id {part_id}") - maker = BRepFilletAPI_MakeChamfer(part.shape) - maker.Add(distance, topods.Edge(self.edges[edge_id])) - result = _finalize_builder_result(maker, "edge chamfer") - part.shape = result - self.refresh_topology() + old_part_shape = part.shape + before_stats = self.part_topology_stats(part_id) + try: + maker = BRepFilletAPI_MakeChamfer(part.shape) + maker.Add(distance, topods.Edge(self.edges[edge_id])) + result = _finalize_builder_result(maker, "edge chamfer") + part.shape = result + self.refresh_topology() + result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="chamfer") + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, " f"edge_length={float(plan['edge_length']):g}, " f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def chamfer_edge_asymmetric( @@ -10282,19 +10652,29 @@ class OperationMixin: raise ValueError(f"Unknown part id {part_id}") resolved_reference_face_id = int(plan["reference_face_id"]) - maker = BRepFilletAPI_MakeChamfer(part.shape) + old_part_shape = part.shape + before_stats = self.part_topology_stats(part_id) try: - maker.Add( - float(distance1), - float(distance2), - topods.Edge(self.edges[edge_id]), - topods.Face(self.faces[resolved_reference_face_id]), - ) - except TypeError as exc: - raise RuntimeError("The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face).") from exc - result = _finalize_builder_result(maker, "asymmetric edge chamfer") - part.shape = result - self.refresh_topology() + maker = BRepFilletAPI_MakeChamfer(part.shape) + try: + maker.Add( + float(distance1), + float(distance2), + topods.Edge(self.edges[edge_id]), + topods.Face(self.faces[resolved_reference_face_id]), + ) + except TypeError as exc: + raise RuntimeError( + "The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face)." + ) from exc + result = _finalize_builder_result(maker, "asymmetric edge chamfer") + part.shape = result + self.refresh_topology() + result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="asymmetric_chamfer") + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( f"Asymmetric Edge chamfer completed: edge {edge_id}, " f"distance1={float(distance1):g}, distance2={float(distance2):g}, " @@ -10302,7 +10682,7 @@ class OperationMixin: f"edge_length={float(plan['edge_length']):g}, " f"distance1_to_length_ratio={float(plan['distance1_to_length_ratio']):g}, " f"distance2_to_length_ratio={float(plan['distance2_to_length_ratio']):g}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def chamfer_edge_distance_angle( @@ -10322,28 +10702,40 @@ class OperationMixin: raise ValueError(f"Unknown part id {part_id}") resolved_reference_face_id = int(plan["reference_face_id"]) - maker = BRepFilletAPI_MakeChamfer(part.shape) + old_part_shape = part.shape + before_stats = self.part_topology_stats(part_id) try: - maker.AddDA( - float(distance), - float(plan["target_angle_radians"]), - topods.Edge(self.edges[edge_id]), - topods.Face(self.faces[resolved_reference_face_id]), - ) - except AttributeError as exc: - raise RuntimeError("The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc - except TypeError as exc: - raise RuntimeError("The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc - result = _finalize_builder_result(maker, "distance-angle edge chamfer") - part.shape = result - self.refresh_topology() + maker = BRepFilletAPI_MakeChamfer(part.shape) + try: + maker.AddDA( + float(distance), + float(plan["target_angle_radians"]), + topods.Edge(self.edges[edge_id]), + topods.Face(self.faces[resolved_reference_face_id]), + ) + except AttributeError as exc: + raise RuntimeError( + "The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face)." + ) from exc + except TypeError as exc: + raise RuntimeError( + "The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face)." + ) from exc + result = _finalize_builder_result(maker, "distance-angle edge chamfer") + part.shape = result + self.refresh_topology() + result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="distance_angle_chamfer") + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( f"Distance-angle Edge chamfer completed: edge {edge_id}, " f"distance={float(distance):g}, angle_degrees={float(angle_degrees):g}, " f"reference_face={resolved_reference_face_id}, " f"edge_length={float(plan['edge_length']):g}, " f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: @@ -11892,41 +12284,62 @@ class OperationMixin: if part is None: raise ValueError(f"Unknown part id {part_id}") - start = gp_Pnt(*plan["boss_tool_start_point"]) - direction = gp_Dir(*plan["boss_tool_axis_direction"]) - axis = gp_Ax2(start, direction) - height = float(plan["boss_tool_height"]) - if plan["resize_mode"] == "enlarge": - tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() - op = BRepAlgoAPI_Fuse(part.shape, tool) - result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False) - action = "enlarged by bounded fuse" - else: - removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() - replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() - remove_op = BRepAlgoAPI_Cut(part.shape, removal) - removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False) - if _topology_shape_count(removed, TopAbs_FACE) == 0: - exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"]) - exact_axis = gp_Ax2(exact_start, direction) - exact_replacement = BRepPrimAPI_MakeCylinder( - exact_axis, - float(plan["boss_tool_inner_radius"]), - float(plan["boss_tool_exact_height"]), - ).Shape() - result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement") + old_part_shape = part.shape + verification: dict[str, object] = {} + try: + start = gp_Pnt(*plan["boss_tool_start_point"]) + direction = gp_Dir(*plan["boss_tool_axis_direction"]) + axis = gp_Ax2(start, direction) + height = float(plan["boss_tool_height"]) + if plan["resize_mode"] == "enlarge": + tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() + op = BRepAlgoAPI_Fuse(part.shape, tool) + result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False) + action = "enlarged by bounded fuse" else: - fuse_op = BRepAlgoAPI_Fuse(removed, replacement) - result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False) - action = "shrunk by removing old envelope and fusing target cylinder" + removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() + replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() + remove_op = BRepAlgoAPI_Cut(part.shape, removal) + removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False) + if _topology_shape_count(removed, TopAbs_FACE) == 0: + exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"]) + exact_axis = gp_Ax2(exact_start, direction) + exact_replacement = BRepPrimAPI_MakeCylinder( + exact_axis, + float(plan["boss_tool_inner_radius"]), + float(plan["boss_tool_exact_height"]), + ).Shape() + result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement") + else: + fuse_op = BRepAlgoAPI_Fuse(removed, replacement) + result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False) + action = "shrunk by removing old envelope and fusing target cylinder" + + part.shape = result + self.refresh_topology() + + verification_plan = dict(plan) + verification_plan["cutter_axis_point"] = plan["boss_tool_axis_point"] + verification_plan["cutter_axis_direction"] = plan["boss_tool_axis_direction"] + verification = self._verify_cylindrical_resize_result(verification_plan, part_id) + if not verification["matched"]: + detail = str(verification.get("detail", "cylindrical boss resize result verification failed")) + raise RuntimeError( + "Cylindrical boss resize returned a result, but no target-diameter boss with " + f"valid first-level topology was found; rolled back to the previous model.{detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise - part.shape = result - self.refresh_topology() return ( f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, " f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, " f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, " - f"height={float(plan['boss_tool_height']):g}." + f"height={float(plan['boss_tool_height']):g}, " + f"verified_face={verification.get('face_id', '')}, " + f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}." ) def move_cylindrical_boss_axis( @@ -12011,7 +12424,8 @@ class OperationMixin: f"diameter={float(plan['target_diameter']):g}, " f"height={float(plan['boss_tool_height']):g}, " f"risk={plan['risk']}, action={action}, " - f"verified_face={verification.get('face_id', '')}." + f"verified_face={verification.get('face_id', '')}, " + f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}." ) def suppress_cylindrical_hole(self, face_id: int) -> str: diff --git a/step_editor/polydata.py b/step_editor/polydata.py index 567b31a..7ece4ed 100644 --- a/step_editor/polydata.py +++ b/step_editor/polydata.py @@ -80,14 +80,27 @@ class PolydataMixin: face_key = _polydata_id_key(face_ids) part_key = _polydata_id_key(part_ids) - cache_key = ("faces", face_key, part_key, float(deflection)) + requested_deflection = max(float(deflection), 1e-9) + local_mesh_face_ids = self._local_fine_mesh_face_ids(face_key, part_key, requested_deflection) + mesh_deflection = self._effective_face_mesh_deflection( + requested_deflection, + local_mesh_face_ids=local_mesh_face_ids, + ) + cache_key = ( + "faces", + face_key, + part_key, + float(requested_deflection), + float(mesh_deflection), + local_mesh_face_ids, + ) cached = self._polydata_cache_get("_face_polydata_cache", cache_key) if cached is not None: return cached selected_faces = set(face_key) if face_key is not None else None selected_parts = set(part_key) if part_key is not None else None - self._ensure_mesh(deflection) + self._ensure_mesh(mesh_deflection, face_ids=local_mesh_face_ids) points = vtk.vtkPoints() polys = vtk.vtkCellArray() @@ -137,8 +150,47 @@ class PolydataMixin: poly.GetCellData().AddArray(solid_arr) return self._polydata_cache_remember("_face_polydata_cache", cache_key, poly) - def _ensure_mesh(self, deflection: float) -> None: + def _local_fine_mesh_face_ids( + self, + face_key: tuple[int, ...] | None, + part_key: tuple[int, ...] | None, + requested: float, + ) -> tuple[int, ...] | None: + if requested > 0.04 or part_key is not None or face_key is None: + return None + valid_face_ids = tuple(face_id for face_id in face_key if 0 <= face_id < len(self.faces)) + if 0 < len(valid_face_ids) <= 64: + return valid_face_ids + return None + + def _effective_face_mesh_deflection( + self, + requested: float, + *, + local_mesh_face_ids: tuple[int, ...] | None = None, + ) -> float: + requested = max(float(requested), 1e-9) + if local_mesh_face_ids: + return requested + face_count = len(getattr(self, "faces", ()) or ()) + if requested <= 0.04 and face_count > 600: + # Ultra-fine analytic remeshing can create million-cell VTK meshes + # on large STEP assemblies. Keep the B-Rep edit precision separate + # from the display mesh budget so selection and rotation stay usable. + return 0.1 if face_count > 1500 else 0.06 + return requested + + def _ensure_mesh(self, deflection: float, face_ids: tuple[int, ...] | None = None) -> None: requested = max(float(deflection), 1e-9) + if face_ids: + local_deflections = getattr(self, "_face_mesh_deflections", {}) + for face_id in face_ids: + if local_deflections.get(face_id) is not None and requested >= float(local_deflections[face_id]) * 0.999: + continue + self._mesh_single_face(face_id, requested) + local_deflections[face_id] = requested + self._face_mesh_deflections = local_deflections + return if self._mesh_deflection is None or requested < self._mesh_deflection * 0.999: if requested <= 0.04: BRepMesh_IncrementalMesh( @@ -162,6 +214,21 @@ class PolydataMixin: BRepMesh_IncrementalMesh(self.shape, requested, False, math.radians(angular_degrees), True) self._mesh_deflection = requested + def _mesh_single_face(self, face_id: int, requested: float) -> None: + if face_id < 0 or face_id >= len(self.faces): + return + try: + face = self.faces[face_id] + surface_type = BRepAdaptor_Surface(face).GetType() + if surface_type in {GeomAbs_Cylinder, GeomAbs_Cone}: + BRepMesh_IncrementalMesh(face, requested, False, math.radians(1.2), True) + elif surface_type in {GeomAbs_Sphere, GeomAbs_Torus}: + BRepMesh_IncrementalMesh(face, max(requested, 0.1), False, math.radians(4.0), True) + else: + BRepMesh_IncrementalMesh(face, requested, False, math.radians(12.0), True) + except Exception: + return + def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8): shape = _compound_from_shapes(value for value in snapshot.values() if isinstance(value, TopoDS_Shape)) BRepMesh_IncrementalMesh(shape, deflection) diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 9506cf9..3ab79c8 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -430,6 +430,10 @@ INFO_LABELS = { "cad_recommended_operation": "推荐操作", "feature_type": "特征类型", "feature_source_face_id": "特征来源 Face", + "feature_detection_level": "特征探测级别", + "associated_feature_count": "关联特征数", + "associated_feature_face_ids": "关联特征 Face", + "feature_context_note": "关联探测", "recognition_summary": "识别摘要", "recognition_candidate": "识别候选", "recognition_confidence": "识别置信度", @@ -507,6 +511,7 @@ INFO_LABELS = { "length_change_ratio": "Edge长度变化比例", "edge_length_anchor_mode": "Edge长度基准模式", "edge_length_anchor_label": "Edge长度基准", + "edge_first_level_topology": "一级关系", "local_edge_deform_target_kind": "局部边形变目标", "local_edge_deform_anchor": "局部边形变基准", "local_edge_deform_face_count": "局部边形变重建面数", diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 412f1b0..0674e49 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -7062,7 +7062,7 @@ class WindowActionMixin: target_logical_id = self._edit_target_logical_id(target_kind, target_id) result_deflection = float(getattr(self, "edit_result_deflection", 1.6)) if operation_name == "拉伸/切除平面": - result_deflection = max(result_deflection, 2.4) + result_deflection = max(result_deflection, 0.35) context = { "operation_name": operation_name, "target": target, diff --git a/step_editor/window_core.py b/step_editor/window_core.py index d8dd949..b5dd533 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -4,15 +4,18 @@ from datetime import datetime import json import math from pathlib import Path +import time import vtk from PySide6.QtCore import QEvent, Qt, QThread, QTimer, Slot +from PySide6.QtWidgets import QToolTip from PySide6.QtWidgets import ( QApplication, QFileDialog, QMessageBox, QTableWidgetItem, QTreeWidgetItem, + QWidget, ) from .model import StepModel @@ -22,6 +25,9 @@ from .workers import EditWorker, LoadWorker, ScanWorker VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0) +AXIS_X_COLOR = (0.82, 0.12, 0.10) +AXIS_Y_COLOR = (0.12, 0.58, 0.20) +AXIS_Z_COLOR = (0.10, 0.28, 0.88) def _prepare_static_mapper(mapper) -> None: @@ -91,15 +97,20 @@ class WindowCoreMixin: self.ui_task_requested.emit(callback) def eventFilter(self, watched, event): + if event.type() == QEvent.Type.ToolTip and self._should_suppress_transient_tooltip(watched): + QToolTip.hideText() + return True if watched is getattr(self, "vtk_widget", None): event_type = event.type() if event_type == QEvent.Type.MouseButtonRelease: + QToolTip.hideText() if event.button() == Qt.MouseButton.LeftButton: x, y = self._vtk_position_from_qt_event(event) self.skip_next_vtk_left_release = True self._handle_left_button_release(x, y) self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton) elif event_type == QEvent.Type.MouseButtonPress: + QToolTip.hideText() if event.button() == Qt.MouseButton.LeftButton: x, y = self._vtk_position_from_qt_event(event) self.skip_next_vtk_left_press = True @@ -114,8 +125,58 @@ class WindowCoreMixin: self._queue_hover_from_qt_event(event) elif event_type == QEvent.Type.Leave: self._cancel_hover_tracking(render=True) + elif event_type == QEvent.Type.Resize and hasattr(self, "_sync_vtk_render_window_size"): + QTimer.singleShot(0, self._sync_vtk_render_window_size) + elif watched is getattr(self, "property_table", None): + if event.type() == QEvent.Type.Resize and hasattr(self, "_resize_property_table_columns"): + QTimer.singleShot(0, self._resize_property_table_columns) return super().eventFilter(watched, event) + def _should_suppress_transient_tooltip(self, watched) -> bool: + if not isinstance(watched, QWidget): + return False + suppressed_roots = ( + getattr(self, "vtk_widget", None), + getattr(self, "object_edit_box", None), + getattr(self, "property_card_scroll", None), + getattr(self, "property_card_container", None), + ) + for root in suppressed_roots: + if root is None: + continue + widget = watched + while widget is not None: + if widget is root: + return True + widget = widget.parentWidget() + return False + + def _sync_vtk_render_window_size(self) -> None: + widget = getattr(self, "vtk_widget", None) + render_window = getattr(self, "render_window", None) + if widget is None or render_window is None: + return + try: + scale = widget._getPixelRatio() if hasattr(widget, "_getPixelRatio") else widget.devicePixelRatioF() + except Exception: + scale = 1.0 + width = max(int(round(float(widget.width()) * float(scale))), 1) + height = max(int(round(float(widget.height()) * float(scale))), 1) + try: + current_width, current_height = render_window.GetSize() + except Exception: + current_width, current_height = width, height + if int(current_width) == width and int(current_height) == height: + return + try: + render_window.SetSize(width, height) + except Exception: + pass + try: + self.interactor.SetSize(width, height) + except Exception: + pass + def _vtk_position_from_qt_event(self, event) -> tuple[int, int]: position = event.position() if hasattr(event, "position") else event.pos() scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0 @@ -123,15 +184,53 @@ class WindowCoreMixin: y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale)) return x, y + def _configure_render_antialiasing(self) -> None: + fxaa_enabled = False + if bool(getattr(self, "prefer_fxaa_antialiasing", False)) and hasattr(self.renderer, "UseFXAAOn"): + try: + self.renderer.UseFXAAOn() + fxaa_enabled = True + except Exception: + fxaa_enabled = False + if not fxaa_enabled and hasattr(self.renderer, "UseFXAAOff"): + try: + self.renderer.UseFXAAOff() + except Exception: + pass + + fallback_samples = max(int(getattr(self, "fallback_multi_samples", 2) or 0), 0) + if fxaa_enabled: + self.interactive_multi_samples = 0 + self.still_multi_samples = 0 + else: + self.interactive_multi_samples = fallback_samples + self.still_multi_samples = fallback_samples + self.fxaa_antialiasing_enabled = fxaa_enabled + self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", fallback_samples))) + def _build_vtk(self) -> None: self.renderer = vtk.vtkRenderer() self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) self.render_window = self.vtk_widget.GetRenderWindow() - if hasattr(self.render_window, "SetMultiSamples"): - self.render_window.SetMultiSamples(0) + self._configure_render_antialiasing() + if hasattr(self.render_window, "SetNumberOfLayers"): + self.render_window.SetNumberOfLayers(3) + if hasattr(self.renderer, "SetLayer"): + self.renderer.SetLayer(0) self.render_window.AddRenderer(self.renderer) - if hasattr(self.renderer, "UseFXAAOff"): - self.renderer.UseFXAAOff() + self._build_scene_axes_renderer() + self._build_fps_overlay() + self._fps_frame_count = 0 + self._fps_window_started_at = time.perf_counter() + self._fps_last_text_update_at = self._fps_window_started_at + self._fps_last_value = 0.0 + self._fps_idle_timeout_ms = 900 + self._fps_idle_zero_displayed = False + self._fps_idle_rendering_zero = False + self.fps_idle_timer = QTimer(self) + self.fps_idle_timer.setSingleShot(True) + self.fps_idle_timer.timeout.connect(self._show_idle_fps_zero) + self.render_window.AddObserver("EndEvent", self._on_render_window_end_event) self.interactor = self.render_window.GetInteractor() self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera()) @@ -166,6 +265,523 @@ class WindowCoreMixin: self.renderer.AddLight(fill_light) self.interactor.Initialize() + self._build_orientation_marker() + + def _build_scene_axes_renderer(self) -> None: + try: + axes_renderer = vtk.vtkRenderer() + axes_renderer.SetLayer(1) + axes_renderer.InteractiveOff() + axes_renderer.EraseOff() + axes_renderer.PreserveColorBufferOn() + axes_renderer.PreserveDepthBufferOff() + axes_renderer.SetActiveCamera(self.renderer.GetActiveCamera()) + axes_light = vtk.vtkLight() + axes_light.SetLightTypeToHeadlight() + axes_light.SetIntensity(0.8) + axes_renderer.AddLight(axes_light) + self.render_window.AddRenderer(axes_renderer) + except Exception: + self.scene_axes_renderer = None + return + self.scene_axes_renderer = axes_renderer + + def _build_fps_overlay(self) -> None: + self.fps_actor = vtk.vtkTextActor() + self.fps_actor.SetInput("FPS --") + text_property = self.fps_actor.GetTextProperty() + text_property.SetFontFamilyToArial() + text_property.SetFontSize(17) + text_property.BoldOn() + text_property.SetColor(0.05, 0.08, 0.12) + text_property.SetOpacity(0.92) + text_property.SetJustificationToRight() + text_property.SetVerticalJustificationToTop() + if hasattr(text_property, "SetBackgroundColor"): + text_property.SetBackgroundColor(1.0, 1.0, 1.0) + if hasattr(text_property, "SetBackgroundOpacity"): + text_property.SetBackgroundOpacity(0.58) + position = self.fps_actor.GetPositionCoordinate() + position.SetCoordinateSystemToNormalizedViewport() + position.SetValue(0.985, 0.982) + self.renderer.AddActor2D(self.fps_actor) + + def _ensure_fps_overlay(self) -> None: + actor = getattr(self, "fps_actor", None) + if actor is None: + self._build_fps_overlay() + return + try: + self.renderer.AddActor2D(actor) + except Exception: + pass + + def _on_render_window_end_event(self, _obj, _event) -> None: + now = time.perf_counter() + if bool(getattr(self, "_fps_idle_rendering_zero", False)): + self._fps_idle_rendering_zero = False + self._fps_frame_count = 0 + self._fps_window_started_at = now + self._fps_last_text_update_at = now + return + self._fps_idle_zero_displayed = False + self._restart_fps_idle_timer() + self._fps_frame_count = int(getattr(self, "_fps_frame_count", 0) or 0) + 1 + window_started_at = float(getattr(self, "_fps_window_started_at", now) or now) + elapsed = now - window_started_at + if elapsed < 0.35: + return + fps = self._fps_frame_count / max(elapsed, 1e-9) + previous = float(getattr(self, "_fps_last_value", 0.0) or 0.0) + if previous > 0.0: + fps = previous * 0.35 + fps * 0.65 + self._fps_last_value = fps + self._fps_frame_count = 0 + self._fps_window_started_at = now + actor = getattr(self, "fps_actor", None) + if actor is not None: + actor.SetInput(f"FPS {fps:4.1f}") + + def _restart_fps_idle_timer(self) -> None: + timer = getattr(self, "fps_idle_timer", None) + if timer is None: + return + try: + timer.start(int(getattr(self, "_fps_idle_timeout_ms", 900) or 900)) + except Exception: + pass + + def _show_idle_fps_zero(self) -> None: + if bool(getattr(self, "_fps_idle_zero_displayed", False)): + return + actor = getattr(self, "fps_actor", None) + render_window = getattr(self, "render_window", None) + if actor is None or render_window is None: + return + self._fps_idle_zero_displayed = True + self._fps_last_value = 0.0 + self._fps_frame_count = 0 + self._fps_window_started_at = time.perf_counter() + try: + actor.SetInput("FPS 0.0") + except Exception: + return + self._fps_idle_rendering_zero = True + try: + render_window.Render() + except Exception: + pass + finally: + self._fps_idle_rendering_zero = False + + def _axis_caption_property(self, caption_actor): + try: + return caption_actor.GetCaptionTextProperty() + except Exception: + pass + try: + text_actor = caption_actor.GetTextActor() + return text_actor.GetTextProperty() + except Exception: + return None + + def _style_axis_caption(self, caption_actor, color: tuple[float, float, float], scale: float) -> None: + text_property = self._axis_caption_property(caption_actor) + if text_property is not None: + try: + text_property.SetFontFamilyToArial() + text_property.BoldOn() + text_property.SetColor(*color) + text_property.SetOpacity(0.96) + except Exception: + pass + for method_name in ("SetWidth", "SetHeight"): + try: + getattr(caption_actor, method_name)(scale) + except Exception: + pass + + def _configure_axes_actor(self, axes_actor, length: float, caption_scale: float) -> None: + length = max(float(length), 1e-6) + axes_actor.SetTotalLength(length, length, length) + axes_actor.SetXAxisLabelText("X") + axes_actor.SetYAxisLabelText("Y") + axes_actor.SetZAxisLabelText("Z") + axes_actor.AxisLabelsOn() + for method_name, args in ( + ("SetShaftTypeToCylinder", ()), + ("SetCylinderRadius", (0.012,)), + ("SetConeRadius", (0.045,)), + ("SetSphereRadius", (0.035,)), + ("SetNormalizedShaftLength", (0.78, 0.78, 0.78)), + ("SetNormalizedTipLength", (0.22, 0.22, 0.22)), + ): + try: + getattr(axes_actor, method_name)(*args) + except Exception: + pass + for shaft_name, tip_name, caption_name, color in ( + ("GetXAxisShaftProperty", "GetXAxisTipProperty", "GetXAxisCaptionActor2D", AXIS_X_COLOR), + ("GetYAxisShaftProperty", "GetYAxisTipProperty", "GetYAxisCaptionActor2D", AXIS_Y_COLOR), + ("GetZAxisShaftProperty", "GetZAxisTipProperty", "GetZAxisCaptionActor2D", AXIS_Z_COLOR), + ): + for prop_name in (shaft_name, tip_name): + try: + prop = getattr(axes_actor, prop_name)() + prop.SetColor(*color) + prop.SetAmbient(0.25) + prop.SetDiffuse(0.78) + prop.SetSpecular(0.2) + except Exception: + pass + try: + self._style_axis_caption(getattr(axes_actor, caption_name)(), color, caption_scale) + except Exception: + pass + try: + axes_actor.PickableOff() + except Exception: + pass + + @staticmethod + def _blend_axis_color( + color: tuple[float, float, float], + target: tuple[float, float, float], + amount: float, + ) -> tuple[float, float, float]: + amount = min(max(float(amount), 0.0), 1.0) + return tuple(color[index] * (1.0 - amount) + target[index] * amount for index in range(3)) + + def _apply_axis_material( + self, + actor, + color: tuple[float, float, float], + *, + opacity: float = 1.0, + ambient: float = 0.48, + diffuse: float = 0.7, + specular: float = 0.22, + specular_power: float = 28.0, + ) -> None: + try: + prop = actor.GetProperty() + prop.SetColor(*color) + prop.SetOpacity(float(opacity)) + prop.SetAmbient(float(ambient)) + prop.SetDiffuse(float(diffuse)) + prop.SetSpecular(float(specular)) + prop.SetSpecularPower(float(specular_power)) + except Exception: + pass + try: + actor.PickableOff() + except Exception: + pass + + def _make_axis_tube_actor( + self, + start: tuple[float, float, float], + end: tuple[float, float, float], + radius: float, + color: tuple[float, float, float], + *, + opacity: float = 1.0, + ): + line = vtk.vtkLineSource() + line.SetPoint1(*start) + line.SetPoint2(*end) + tube = vtk.vtkTubeFilter() + tube.SetInputConnection(line.GetOutputPort()) + tube.SetRadius(max(float(radius), 1e-9)) + tube.SetNumberOfSides(28) + tube.CappingOn() + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(tube.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + self._apply_axis_material(actor, color, opacity=opacity) + return actor + + def _make_axis_cone_actor( + self, + center: tuple[float, float, float], + direction: tuple[float, float, float], + height: float, + radius: float, + color: tuple[float, float, float], + ): + cone = vtk.vtkConeSource() + cone.SetCenter(*center) + cone.SetDirection(*direction) + cone.SetHeight(max(float(height), 1e-9)) + cone.SetRadius(max(float(radius), 1e-9)) + cone.SetResolution(36) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(cone.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + self._apply_axis_material(actor, color, ambient=0.3, diffuse=0.72, specular=0.36, specular_power=42.0) + return actor + + def _make_axis_sphere_actor( + self, + center: tuple[float, float, float], + radius: float, + color: tuple[float, float, float], + ): + sphere = vtk.vtkSphereSource() + sphere.SetCenter(*center) + sphere.SetRadius(max(float(radius), 1e-9)) + sphere.SetThetaResolution(36) + sphere.SetPhiResolution(24) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(sphere.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + self._apply_axis_material(actor, color, ambient=0.42, diffuse=0.6, specular=0.4, specular_power=46.0) + return actor + + def _make_axis_label_actor( + self, + text: str, + position: tuple[float, float, float], + scale: float, + color: tuple[float, float, float], + ): + vector_text = vtk.vtkVectorText() + vector_text.SetText(text) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(vector_text.GetOutputPort()) + actor = vtk.vtkFollower() + actor.SetMapper(mapper) + actor.SetScale(scale, scale, scale) + actor.SetPosition(*position) + try: + actor.SetCamera(self.renderer.GetActiveCamera()) + except Exception: + pass + self._apply_axis_material(actor, color, ambient=0.78, diffuse=0.38, specular=0.0) + return actor + + def _set_annotated_cube_property( + self, + cube_actor, + method_name: str, + color: tuple[float, float, float], + opacity: float, + ) -> None: + try: + prop = getattr(cube_actor, method_name)() + prop.SetColor(*color) + prop.SetOpacity(float(opacity)) + prop.SetAmbient(0.35) + prop.SetDiffuse(0.65) + prop.SetSpecular(0.18) + except Exception: + pass + + def _build_orientation_marker_prop(self): + axes_actor = vtk.vtkAxesActor() + self._configure_axes_actor(axes_actor, 1.72, caption_scale=0.14) + + cube_actor = vtk.vtkAnnotatedCubeActor() + cube_actor.SetXPlusFaceText("+X") + cube_actor.SetXMinusFaceText("-X") + cube_actor.SetYPlusFaceText("+Y") + cube_actor.SetYMinusFaceText("-Y") + cube_actor.SetZPlusFaceText("+Z") + cube_actor.SetZMinusFaceText("-Z") + cube_actor.SetFaceTextScale(0.54) + cube_actor.SetTextEdgesVisibility(1) + cube_actor.SetCubeVisibility(1) + self._set_annotated_cube_property(cube_actor, "GetCubeProperty", (0.88, 0.92, 0.96), 0.72) + self._set_annotated_cube_property(cube_actor, "GetXPlusFaceProperty", AXIS_X_COLOR, 0.92) + self._set_annotated_cube_property( + cube_actor, + "GetXMinusFaceProperty", + self._blend_axis_color(AXIS_X_COLOR, (1.0, 1.0, 1.0), 0.52), + 0.78, + ) + self._set_annotated_cube_property(cube_actor, "GetYPlusFaceProperty", AXIS_Y_COLOR, 0.92) + self._set_annotated_cube_property( + cube_actor, + "GetYMinusFaceProperty", + self._blend_axis_color(AXIS_Y_COLOR, (1.0, 1.0, 1.0), 0.52), + 0.78, + ) + self._set_annotated_cube_property(cube_actor, "GetZPlusFaceProperty", AXIS_Z_COLOR, 0.92) + self._set_annotated_cube_property( + cube_actor, + "GetZMinusFaceProperty", + self._blend_axis_color(AXIS_Z_COLOR, (1.0, 1.0, 1.0), 0.52), + 0.78, + ) + try: + edge_property = cube_actor.GetTextEdgesProperty() + edge_property.SetColor(0.09, 0.12, 0.17) + edge_property.SetOpacity(0.75) + edge_property.SetLineWidth(1.5) + except Exception: + pass + for actor in (axes_actor, cube_actor): + try: + actor.PickableOff() + except Exception: + pass + + prop = vtk.vtkPropAssembly() + prop.AddPart(axes_actor) + prop.AddPart(cube_actor) + return prop, axes_actor, cube_actor + + def _build_orientation_marker(self) -> None: + try: + prop, axes_actor, cube_actor = self._build_orientation_marker_prop() + marker_widget = vtk.vtkOrientationMarkerWidget() + marker_widget.SetOrientationMarker(prop) + marker_widget.SetInteractor(self.interactor) + marker_widget.SetViewport(0.015, 0.015, 0.235, 0.235) + if hasattr(marker_widget, "SetOutlineColor"): + marker_widget.SetOutlineColor(0.72, 0.78, 0.86) + marker_widget.SetEnabled(1) + marker_widget.InteractiveOff() + except Exception: + self.orientation_marker_widget = None + self.orientation_marker_prop = None + self.orientation_marker_axes_actor = None + self.orientation_marker_cube_actor = None + return + self.orientation_marker_widget = marker_widget + self.orientation_marker_prop = prop + self.orientation_marker_axes_actor = axes_actor + self.orientation_marker_cube_actor = cube_actor + + def _valid_bounds_tuple(self, bounds) -> tuple[float, float, float, float, float, float] | None: + try: + values = tuple(float(value) for value in bounds) + except (TypeError, ValueError): + return None + if len(values) != 6: + return None + if not all(math.isfinite(value) for value in values): + return None + x0, x1, y0, y1, z0, z1 = values + if x1 < x0 or y1 < y0 or z1 < z0: + return None + return values + + def _current_model_bounds(self) -> tuple[float, float, float, float, float, float] | None: + polydata = getattr(self, "model_polydata", None) + if polydata is None: + return None + try: + return self._valid_bounds_tuple(polydata.GetBounds()) + except Exception: + return None + + def _step_coordinate_axis_length(self) -> float: + bounds = self._current_model_bounds() + if bounds is None: + return 1.0 + x0, x1, y0, y1, z0, z1 = bounds + dx = max(x1 - x0, 0.0) + dy = max(y1 - y0, 0.0) + dz = max(z1 - z0, 0.0) + model_size = max(math.sqrt(dx * dx + dy * dy + dz * dz), dx, dy, dz, 1e-6) + return max(model_size * 0.18, 1e-4) + + def _scene_coordinate_axes_anchor(self, length: float) -> tuple[float, float, float]: + # STEP display geometry is already in the file/global coordinate frame. + # Keep the scene axes at the real file origin; visibility is handled by + # the foreground renderer, not by moving the origin marker. + return (0.0, 0.0, 0.0) + + def _build_scene_coordinate_axes(self, length: float, anchor: tuple[float, float, float]): + length = max(float(length), 1e-6) + assembly = vtk.vtkAssembly() + assembly.SetPosition(*anchor) + shaft_radius = length * 0.012 + tip_length = length * 0.18 + tip_radius = length * 0.045 + negative_length = length * 0.28 + label_scale = length * 0.075 + origin_radius = length * 0.032 + + assembly.AddPart(self._make_axis_sphere_actor((0.0, 0.0, 0.0), origin_radius, (0.96, 0.98, 1.0))) + for label, direction, color in ( + ("X", (1.0, 0.0, 0.0), AXIS_X_COLOR), + ("Y", (0.0, 1.0, 0.0), AXIS_Y_COLOR), + ("Z", (0.0, 0.0, 1.0), AXIS_Z_COLOR), + ): + shaft_end = tuple(direction[index] * (length - tip_length * 0.78) for index in range(3)) + cone_center = tuple(direction[index] * (length - tip_length * 0.5) for index in range(3)) + label_position = tuple(direction[index] * (length * 1.08) for index in range(3)) + negative_start = tuple(-direction[index] * negative_length for index in range(3)) + pale_color = self._blend_axis_color(color, (1.0, 1.0, 1.0), 0.48) + + assembly.AddPart( + self._make_axis_tube_actor( + negative_start, + (0.0, 0.0, 0.0), + shaft_radius * 0.58, + pale_color, + opacity=0.62, + ) + ) + assembly.AddPart(self._make_axis_tube_actor((0.0, 0.0, 0.0), shaft_end, shaft_radius, color)) + assembly.AddPart(self._make_axis_cone_actor(cone_center, direction, tip_length, tip_radius, color)) + assembly.AddPart(self._make_axis_label_actor(label, label_position, label_scale, color)) + try: + assembly.PickableOff() + except Exception: + pass + try: + assembly.UseBoundsOff() + except Exception: + pass + return assembly + + def _ensure_step_coordinate_axes(self) -> None: + previous_actor = getattr(self, "step_coordinate_axes_actor", None) + if previous_actor is not None: + for renderer in (getattr(self, "scene_axes_renderer", None), self.renderer): + if renderer is None: + continue + try: + renderer.RemoveViewProp(previous_actor) + except Exception: + pass + axes_renderer = getattr(self, "scene_axes_renderer", None) or self.renderer + try: + axes_renderer.SetActiveCamera(self.renderer.GetActiveCamera()) + except Exception: + pass + length = self._step_coordinate_axis_length() + anchor = self._scene_coordinate_axes_anchor(length) + try: + axes_actor = self._build_scene_coordinate_axes(length, anchor) + self.step_coordinate_axes_actor = axes_actor + except Exception: + axes_actor = vtk.vtkAxesActor() + axes_actor.SetPosition(*anchor) + self._configure_axes_actor( + axes_actor, + length, + caption_scale=0.055, + ) + self.step_coordinate_axes_actor = axes_actor + for method_name in ("PickableOff", "UseBoundsOff"): + try: + getattr(axes_actor, method_name)() + except Exception: + pass + try: + axes_renderer.AddViewProp(axes_actor) + except Exception: + try: + axes_renderer.AddActor(axes_actor) + except Exception: + pass def on_camera_interaction_start(self, _obj, _event) -> None: if not self._is_ui_thread(): @@ -184,15 +800,73 @@ class WindowCoreMixin: return self.camera_interaction_active = True self._cancel_hover_tracking(render=False) + self._set_camera_interaction_visual_mode(True) def _end_camera_interaction(self) -> None: if not getattr(self, "camera_interaction_active", False): return self.camera_interaction_active = False + self._set_camera_interaction_visual_mode(False) self.last_camera_interaction_ended_at = datetime.now() self.pending_hover_position = None self.last_hover_pick_position = None + def _set_render_window_multisamples(self, samples: int) -> bool: + render_window = getattr(self, "render_window", None) + if render_window is None or not hasattr(render_window, "SetMultiSamples"): + return False + target = max(int(samples), 0) + try: + if hasattr(render_window, "GetMultiSamples") and int(render_window.GetMultiSamples()) == target: + return False + except Exception: + pass + try: + render_window.SetMultiSamples(target) + return True + except Exception: + return False + + def _set_camera_interaction_visual_mode(self, active: bool) -> None: + edge_actor = getattr(self, "edge_actor", None) + changed = False + if active: + if bool(getattr(self, "hide_edges_during_camera_interaction", True)) and edge_actor is not None: + try: + self.edge_visibility_before_camera_interaction = int(edge_actor.GetVisibility()) + if self.edge_visibility_before_camera_interaction: + edge_actor.VisibilityOff() + changed = True + except Exception: + self.edge_visibility_before_camera_interaction = None + changed = self._set_render_window_multisamples( + int(getattr(self, "interactive_multi_samples", 0)) + ) or changed + self.camera_interaction_visual_changed = changed + return + + if edge_actor is not None and self.edge_visibility_before_camera_interaction is not None: + try: + previous_visibility = int(self.edge_visibility_before_camera_interaction) + if int(edge_actor.GetVisibility()) != previous_visibility: + edge_actor.SetVisibility(previous_visibility) + changed = True + except Exception: + pass + self.edge_visibility_before_camera_interaction = None + changed = self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", 4))) or changed + changed = bool(getattr(self, "camera_interaction_visual_changed", False)) or changed + self.camera_interaction_visual_changed = False + if changed: + try: + self.renderer.ResetCameraClippingRange() + except Exception: + pass + try: + self.render_window.Render() + except Exception: + pass + def _hover_suppressed_after_camera(self) -> bool: ended_at = getattr(self, "last_camera_interaction_ended_at", None) if ended_at is None: @@ -268,7 +942,7 @@ class WindowCoreMixin: if not background: self._load_step_sync( new_path, - deflection=0.8, + deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35), show_internal_edges=self._show_same_domain_internal_edges(), status_prefix="Loading", ) @@ -1027,6 +1701,7 @@ class WindowCoreMixin: self._clear_overlay_polydata_cache() self.renderer.RemoveAllViewProps() self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) + self._ensure_fps_overlay() self.scene_isolated = False self.highlight_signature = None @@ -1067,6 +1742,7 @@ class WindowCoreMixin: self.edge_actor.SetMapper(edge_mapper) self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1) self.edge_actor.GetProperty().SetLineWidth(1.0) + self.edge_actor.GetProperty().LightingOff() self.renderer.AddActor(self.edge_actor) self.highlight_actor = None @@ -1082,6 +1758,8 @@ class WindowCoreMixin: self.diff_actors = [] if reset_camera: self.renderer.ResetCamera() + self._ensure_step_coordinate_axes() + self.renderer.ResetCameraClippingRange() self.render_window.Render() def _on_mode_changed(self, mode: str) -> None: diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 39c26dd..495a25c 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -8,11 +8,18 @@ from PySide6.QtCore import Qt, QThread, QTimer, Slot from PySide6.QtWidgets import ( QApplication, QFileDialog, + QFrame, + QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QPushButton, + QSizePolicy, QTableWidgetItem, QTreeWidgetItem, + QToolTip, + QVBoxLayout, + QWidget, ) from PySide6.QtGui import QColor @@ -25,13 +32,43 @@ from .workers import EditWorker, LoadWorker, ScanWorker PROPERTY_VALUE_TOLERANCE = 1e-9 +PROPERTY_LABEL_COLUMN = 0 +PROPERTY_CURRENT_COLUMN = 1 PROPERTY_SCOPE_COLUMN = 2 PROPERTY_TARGET_COLUMN = 3 PROPERTY_ACTION_COLUMN = 4 +PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (54, 118, 48, 66, 74) +PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (68, 168, 58, 92, 82) +PROPERTY_COMMAND_ORDER = ("offset", "move", "scale", "rotate", "feature", "diagnostics") +PROPERTY_COMMAND_LABELS = { + "offset": "偏移", + "move": "移动", + "scale": "缩放", + "rotate": "旋转", + "feature": "特征", + "diagnostics": "诊断", +} +PROPERTY_COMMAND_SUBTITLES = { + "offset": "沿法线调面", + "move": "移动中心/坐标", + "scale": "改尺寸/半径", + "rotate": "绕轴旋转", + "feature": "孔槽/圆角", + "diagnostics": "识别与关系", +} +PROPERTY_COMMAND_HELP = { + "offset": "沿 Face 法线调整位置,适合平面推拉、偏移或切除深度类修改。", + "move": "移动当前 Face、Edge、特征或 Solid 的中心/轴心,不直接修改尺寸。", + "scale": "修改面积、宽度、高度、半径、直径、长度等尺寸参数。", + "rotate": "设置旋转轴或旋转角度,适合 Part/Solid 的整体姿态调整。", + "feature": "执行孔、槽、圆角、倒角、封堵等离散特征命令。", + "diagnostics": "查看识别依据、一级拓扑关系和当前不支持修改的原因。", +} FEATURE_EDIT_SEMANTICS_KEYS = { "cad_modeling_form", "cad_recommended_operation", + "edge_first_level_topology", "face_first_level_topology", "cylindrical_feature_first_level_topology", "slot_edit_semantics", @@ -44,6 +81,57 @@ FEATURE_EDIT_SEMANTICS_KEYS = { } +def _property_table_column_widths(available_width: int) -> tuple[int, int, int, int, int]: + """Prefer current value and action visibility over a wide parameter-name column.""" + column_count = len(PROPERTY_TABLE_MIN_COLUMN_WIDTHS) + available = max(int(available_width or 0), column_count * 44) + minimum = PROPERTY_TABLE_MIN_COLUMN_WIDTHS + preferred = PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS + min_total = sum(minimum) + preferred_total = sum(preferred) + + if available >= preferred_total: + widths = [int(value) for value in preferred] + extra = available - preferred_total + weights = (0.05, 0.62, 0.04, 0.19, 0.10) + for index, weight in enumerate(weights): + addition = int(extra * weight) + widths[index] += addition + widths[1] += available - sum(widths) + return tuple(widths) # type: ignore[return-value] + + if available >= min_total: + scale = (available - min_total) / max(preferred_total - min_total, 1) + widths = [ + int(round(min_width + (pref_width - min_width) * scale)) + for min_width, pref_width in zip(minimum, preferred) + ] + widths[1] += available - sum(widths) + return tuple(widths) # type: ignore[return-value] + + floors = (48, 96, 44, 56, 70) + widths = [int(value) for value in minimum] + deficit = min_total - available + for index in (0, 2, 3, 1, 4): + if deficit <= 0: + break + reducible = max(0, widths[index] - floors[index]) + reduction = min(reducible, deficit) + widths[index] -= reduction + deficit -= reduction + if deficit > 0: + widths[1] = max(44, widths[1] - deficit) + widths[1] += available - sum(widths) + return tuple(max(44, width) for width in widths) # type: ignore[return-value] + + +def _compact_property_card_text(text: str, limit: int = 220) -> str: + compact = " ".join(str(text or "").replace("\r", "\n").split()) + if len(compact) <= limit: + return compact + return f"{compact[: max(0, limit - 1)].rstrip()}…" + + def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]: """Return the independent, user-facing dimensions for a feature candidate.""" surface = str(action_info.get("surface", "") or "") @@ -895,6 +983,13 @@ class WindowStateMixin: "查看当前选中对象的属性;可修改的行可以输入目标值。", "请先选择一个对象。", ) + if hasattr(self, "property_card_scroll"): + self._set_control_state( + self.property_card_scroll, + has_selection and has_current_info, + "查看当前选中对象的参数卡片;可修改的卡片可以输入目标值。", + "请先选择一个对象。", + ) self._update_edit_action_states(has_model) def _update_edit_action_states(self, has_model: bool) -> None: @@ -1321,14 +1416,475 @@ class WindowStateMixin: return self.property_editor_specs = [] self.property_table_expanded = False + self.property_editor_selected_row = None + self.property_command_active_key = "" was_blocked = self.property_table.blockSignals(True) try: + self.property_table.clearSpans() self.property_table.setRowCount(0) finally: self.property_table.blockSignals(was_blocked) + self._clear_property_cards() + self._clear_property_command_bar() self._resize_property_table_height() self._update_property_apply_state(False) + def _clear_property_cards(self) -> None: + self.property_card_rows = {} + layout = getattr(self, "property_card_layout", None) + if layout is None: + return + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.hide() + widget.setParent(None) + widget.deleteLater() + + def _clear_property_command_bar(self) -> None: + self.property_command_buttons = {} + if hasattr(self, "property_command_summary_label"): + self.property_command_summary_label.setText("未选择可编辑对象") + self.property_command_summary_label.setToolTip("") + if hasattr(self, "property_command_help_label"): + self.property_command_help_label.setText("") + self.property_command_help_label.setToolTip("") + self.property_command_help_label.setVisible(False) + layout = getattr(self, "property_command_layout", None) + if layout is not None: + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.hide() + widget.setParent(None) + widget.deleteLater() + if hasattr(self, "property_command_bar"): + self.property_command_bar.setVisible(False) + if hasattr(self, "property_command_summary_label"): + self.property_command_summary_label.setVisible(False) + + def _is_actionable_property_row(self, row: int, spec: dict[str, object]) -> bool: + effective_spec = self._effective_property_spec(spec, row=row) + return bool(effective_spec.get("editable") and effective_spec.get("enabled") and effective_spec.get("action")) + + def _actionable_property_rows(self) -> list[tuple[int, dict[str, object]]]: + specs = list(getattr(self, "property_editor_specs", []) or []) + return [(row, spec) for row, spec in enumerate(specs) if self._is_actionable_property_row(row, spec)] + + def _property_command_summary_text(self) -> tuple[str, str]: + specs = list(getattr(self, "property_editor_specs", []) or []) + topology_text = "" + for spec in specs: + key = str(spec.get("key", "") or "") + current_text = str(spec.get("current_text", "") or "").strip() + if key in { + "face_first_level_topology", + "edge_first_level_topology", + "cylindrical_feature_first_level_topology", + } and current_text: + topology_text = _compact_property_card_text(current_text, 86) + actionable = self._actionable_property_rows() + labels = [str(spec.get("label", "") or "").strip() for _row, spec in actionable] + labels = [label for label in labels if label] + if labels: + summary = f"可修改项 {len(labels)} 个:{'、'.join(labels[:4])}" + if topology_text: + summary = f"{summary} | {topology_text}" + else: + summary = topology_text or "当前对象没有稳定可修改项;请查看诊断信息。" + tooltip_parts = [str(spec.get("current_text", "") or "") for spec in specs if bool(spec.get("pin_top"))] + return summary, "\n\n".join(part for part in tooltip_parts if part) + + def _rebuild_property_command_bar(self) -> None: + layout = getattr(self, "property_command_layout", None) + if layout is None: + return + self.property_command_buttons = {} + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + self.property_command_active_key = "" + summary, tooltip = self._property_command_summary_text() + if hasattr(self, "property_command_summary_label"): + self.property_command_summary_label.setText(summary) + self.property_command_summary_label.setToolTip(tooltip or summary) + self.property_command_summary_label.setVisible(True) + if hasattr(self, "property_command_help_label"): + self.property_command_help_label.setText("") + self.property_command_help_label.setToolTip("") + self.property_command_help_label.setVisible(False) + if hasattr(self, "property_command_bar"): + self.property_command_bar.setVisible(False) + + def _select_property_command(self, command_key: str) -> None: + if not command_key: + return + + def _visible_property_card_rows(self) -> list[tuple[int, dict[str, object]]]: + specs = list(getattr(self, "property_editor_specs", []) or []) + if not specs: + return [] + if bool(getattr(self, "property_table_expanded", False)): + return [(row, spec) for row, spec in enumerate(specs)] + + visible = self._actionable_property_rows() + if not visible: + visible = [ + (row, spec) + for row, spec in enumerate(specs) + if bool(spec.get("editable") or spec.get("action") or spec.get("pin_top")) + ] + if not visible: + visible = [(row, spec) for row, spec in enumerate(specs)] + selected_row = getattr(self, "property_editor_selected_row", None) + if selected_row is not None: + selected_items = [(row, spec) for row, spec in enumerate(specs) if row == selected_row] + if selected_items: + visible = selected_items + [(row, spec) for row, spec in visible if row != selected_row] + collapsed_rows = max(int(getattr(self, "property_table_collapsed_rows", 6) or 6), 6) + return visible[:collapsed_rows] + + def _rebuild_property_cards(self) -> None: + layout = getattr(self, "property_card_layout", None) + if layout is None: + return + self._clear_property_cards() + visible_rows = self._visible_property_card_rows() + visible_row_ids = {row for row, _spec in visible_rows} + selected_row = getattr(self, "property_editor_selected_row", None) + if selected_row not in visible_row_ids: + self.property_editor_selected_row = None + for row, spec in visible_rows: + card = self._build_property_card(row, spec) + layout.addWidget(card) + + def _select_property_card_row(self, row: int) -> None: + specs = getattr(self, "property_editor_specs", []) + if row < 0 or row >= len(specs): + return + if getattr(self, "property_editor_selected_row", None) == row: + return + self.property_editor_selected_row = row + self._rebuild_property_cards() + self._resize_property_table_height() + self._update_property_apply_state() + self._scroll_property_card_row_to_top(row) + + def _toggle_property_card_row(self, row: int) -> None: + QToolTip.hideText() + specs = getattr(self, "property_editor_specs", []) + if row < 0 or row >= len(specs): + return + if getattr(self, "property_editor_selected_row", None) == row: + self.property_editor_selected_row = None + self._rebuild_property_cards() + self._resize_property_table_height() + self._update_property_apply_state() + self._scroll_property_cards_to_top() + return + self._select_property_card_row(row) + + def _scroll_property_cards_to_top(self) -> None: + scroll = getattr(self, "property_card_scroll", None) + if scroll is None: + return + bar = scroll.verticalScrollBar() + if bar is None: + return + bar.setValue(0) + QTimer.singleShot(0, lambda target_bar=bar: target_bar.setValue(0)) + + def _scroll_property_card_row_to_top(self, row: int) -> None: + scroll = getattr(self, "property_card_scroll", None) + card = self._property_card_widgets(row).get("card") + if scroll is None or not isinstance(card, QWidget): + return + bar = scroll.verticalScrollBar() + if bar is None: + return + + def apply_scroll() -> None: + bar.setValue(max(0, int(card.y()) - 4)) + + apply_scroll() + QTimer.singleShot(0, apply_scroll) + + def _property_card_widgets(self, row: int) -> dict[str, object]: + rows = getattr(self, "property_card_rows", {}) + if isinstance(rows, dict): + widgets = rows.get(row, {}) + if isinstance(widgets, dict): + return widgets + return {} + + def _build_property_card(self, row: int, spec: dict[str, object]) -> QFrame: + effective_spec = self._effective_property_spec(spec, row=row) + editable = bool(effective_spec.get("editable") and effective_spec.get("enabled")) + value_type = str(effective_spec.get("value_type", "number")) + input_editable = editable and value_type != "command" + span_value_columns = bool(effective_spec.get("span_value_columns")) and not editable + selected = row == getattr(self, "property_editor_selected_row", None) + + card = QFrame() + card.setObjectName("propertyCard") + card.setProperty("editable", bool(editable)) + card.setProperty("pinTop", bool(effective_spec.get("pin_top"))) + card.setProperty("selected", bool(selected)) + card.setProperty("compact", bool(not selected)) + card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + card.setCursor(Qt.CursorShape.PointingHandCursor) + card.mousePressEvent = lambda _event, target_row=row: self._toggle_property_card_row(target_row) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(7 if not selected else 8, 2 if not selected else 7, 7 if not selected else 8, 2 if not selected else 7) + card_layout.setSpacing(0 if not selected else 5) + diagnostics_expanded = bool(getattr(self, "property_table_expanded", False)) + + header = QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.setSpacing(8 if not selected else 4) + title = QLabel(str(effective_spec.get("label", ""))) + title.setObjectName("propertyCardTitle") + title.setMinimumWidth(0) + title.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + if selected or span_value_columns: + title.setToolTip(str(effective_spec.get("disabled_tip") or effective_spec.get("enabled_tip") or "")) + header.addWidget(title, 1) + compact_detail_below_header = False + if not selected and not span_value_columns: + current_text = str(effective_spec.get("current_text", "")) + detail_parts = [current_text] if current_text else [] + scope_text = str(effective_spec.get("scope_text") or effective_spec.get("scope_label") or "").strip() + target_text = self._property_target_text(row) or str(effective_spec.get("target_text", "")) + if editable and value_type != "command": + validation_error = ( + "" if not target_text.strip() else self._property_target_validation_error(effective_spec, target_text) + ) + row_changed = self._property_target_changed(effective_spec, target_text) + if validation_error: + scope_text = "目标无效" + elif row_changed: + scope_text = f"目标 {target_text}" + elif editable and value_type == "command": + scope_text = str(effective_spec.get("button_text") or scope_text or "可执行").strip() + if scope_text: + detail_parts.append(scope_text) + compact_detail_text = _compact_property_card_text(" · ".join(detail_parts), 150) + compact_multiline = len(str(effective_spec.get("label", ""))) >= 14 or len(compact_detail_text) >= 30 + title.setWordWrap(compact_multiline) + if compact_multiline: + title.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + card_layout.setSpacing(1) + compact_detail = QLabel(_compact_property_card_text(" · ".join(detail_parts), 150 if compact_multiline else 96)) + compact_detail.setObjectName("propertyCardCompactValue") + compact_detail.setAlignment( + (Qt.AlignmentFlag.AlignLeft if compact_multiline else Qt.AlignmentFlag.AlignRight) + | Qt.AlignmentFlag.AlignVCenter + ) + compact_detail.setMinimumWidth(0) + compact_detail.setSizePolicy( + QSizePolicy.Policy.Expanding if compact_multiline else QSizePolicy.Policy.Fixed, + QSizePolicy.Policy.Fixed, + ) + if compact_multiline: + compact_detail_below_header = True + else: + header.addWidget(compact_detail, 0) + row_widgets_placeholder = compact_detail + status_text = "可修改" if editable else str(effective_spec.get("status_text", "")) + else: + status_text = str(effective_spec.get("status_text", "")) + status_label = QLabel(str(effective_spec.get("status_text", "")), card) + status_label.setObjectName("propertyCardMetaLabel") + status_label.setText(status_text) + status_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + header.addWidget(status_label, 0) + card_layout.addLayout(header) + status_label.setVisible(bool(status_text)) + if compact_detail_below_header: + card_layout.addWidget(row_widgets_placeholder) + + row_widgets: dict[str, object] = { + "card": card, + "status_label": status_label, + } + if not selected and not span_value_columns: + row_widgets["current_value"] = row_widgets_placeholder + self.property_card_rows[row] = row_widgets + card.style().unpolish(card) + card.style().polish(card) + return card + + if span_value_columns: + current_text = str(effective_spec.get("current_text", "")) + show_full_span_text = bool(selected or diagnostics_expanded) + span_limit = 520 if show_full_span_text else 96 + value = QLabel(_compact_property_card_text(current_text, span_limit)) + value.setObjectName("propertyCardValue") + value.setWordWrap(show_full_span_text) + value.setToolTip(str(effective_spec.get("disabled_tip") or current_text)) + card_layout.addWidget(value) + hint_text = _compact_property_card_text(str(effective_spec.get("disabled_tip") or ""), 150) + if selected and hint_text and hint_text != value.text(): + hint = QLabel(hint_text) + hint.setObjectName("propertyCardHint") + hint.setWordWrap(True) + card_layout.addWidget(hint) + row_widgets["hint_label"] = hint + self.property_card_rows[row] = row_widgets + card.style().unpolish(card) + card.style().polish(card) + return card + + current_row = QHBoxLayout() + current_row.setContentsMargins(0, 0, 0, 0) + current_label = QLabel("当前值") + current_label.setObjectName("propertyCardMetaLabel") + current_row.addWidget(current_label, 0) + current_text = str(effective_spec.get("current_text", "")) + current_value = QLabel(_compact_property_card_text(current_text, 220 if selected else 118)) + current_value.setObjectName("propertyCardValue") + current_value.setWordWrap(bool(selected)) + current_value.setToolTip(current_text) + current_row.addWidget(current_value, 1) + card_layout.addLayout(current_row) + + if not selected: + compact_parts: list[str] = [] + scope_text = str(effective_spec.get("scope_text") or effective_spec.get("scope_label") or "").strip() + if scope_text: + compact_parts.append(scope_text) + target_text = self._property_target_text(row) or str(effective_spec.get("target_text", "")) + if editable and value_type != "command": + validation_error = ( + "" if not target_text.strip() else self._property_target_validation_error(effective_spec, target_text) + ) + row_changed = self._property_target_changed(effective_spec, target_text) + if validation_error: + compact_parts.append("目标无效") + elif row_changed: + compact_parts.append(f"目标 {target_text}") + elif editable and value_type == "command": + button_text = str(effective_spec.get("button_text") or "可执行").strip() + if button_text: + compact_parts.append(button_text) + status_text = str(effective_spec.get("status_text") or "").strip() + if status_text and not compact_parts: + compact_parts.append(status_text) + if compact_parts: + compact_meta = QLabel(_compact_property_card_text(" · ".join(compact_parts), 118)) + compact_meta.setObjectName("propertyCardCompactMeta") + compact_meta.setWordWrap(True) + compact_meta.setToolTip(" · ".join(compact_parts)) + card_layout.addWidget(compact_meta) + row_widgets["compact_meta"] = compact_meta + self.property_card_rows[row] = row_widgets + card.style().unpolish(card) + card.style().polish(card) + return card + + if input_editable: + target_row = QHBoxLayout() + target_row.setContentsMargins(0, 0, 0, 0) + target_label = QLabel("目标值") + target_label.setObjectName("propertyCardMetaLabel") + target_row.addWidget(target_label, 0) + editor = QLineEdit(self._property_target_text(row) or str(effective_spec.get("target_text", ""))) + editor.setObjectName("propertyCardTargetEditor") + editor.setToolTip(self._property_target_tooltip(effective_spec, editable=True)) + editor.setPlaceholderText("输入目标值") + editor.setCursor(Qt.CursorShape.IBeamCursor) + editor.textChanged.connect(lambda _text="", _row=row: self._on_property_card_target_changed(_row)) + editor.returnPressed.connect(lambda target_row=row: self.apply_property_row_edit(target_row)) + target_row.addWidget(editor, 1) + card_layout.addLayout(target_row) + row_widgets["target_editor"] = editor + + modes = spec.get("scope_modes") + if isinstance(modes, dict) and modes: + scope_row = QHBoxLayout() + scope_row.setContentsMargins(0, 0, 0, 0) + scope_label = QLabel("建模意图") + scope_label.setObjectName("propertyCardMetaLabel") + scope_row.addWidget(scope_label, 0) + combo = self._make_property_scope_combo(row, spec) + scope_row.addWidget(combo, 1) + card_layout.addLayout(scope_row) + row_widgets["scope_combo"] = combo + + hint_text = self._property_card_hint_text(effective_spec, editable=editable) + hint = QLabel(hint_text, card) + hint.setObjectName("propertyCardHint") + hint.setWordWrap(True) + hint.setToolTip(str(effective_spec.get("enabled_tip") or effective_spec.get("disabled_tip") or hint_text)) + card_layout.addWidget(hint) + hint.setVisible(bool(hint_text)) + row_widgets["hint_label"] = hint + + if editable and effective_spec.get("action"): + button = QPushButton("未改动") + button.setObjectName("propertyRowEditButton") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setProperty("changed", False) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row)) + card_layout.addWidget(button) + status_label.setVisible(False) + row_widgets["action_button"] = button + + self.property_card_rows[row] = row_widgets + card.style().unpolish(card) + card.style().polish(card) + return card + + def _property_card_hint_text(self, spec: dict[str, object], *, editable: bool) -> str: + parts: list[str] = [] + if editable: + scope_label = str(spec.get("scope_label") or "").strip() + if scope_label: + parts.append(f"建模意图:{scope_label}") + tip = str(spec.get("enabled_tip") or "").strip() + if tip: + parts.append(tip) + range_hint = self._property_range_hint(spec) + if range_hint: + parts.append(range_hint) + else: + tip = str(spec.get("disabled_tip") or spec.get("enabled_tip") or "").strip() + status = str(spec.get("status_text") or "").strip() + if tip: + parts.append(tip) + elif status: + parts.append(status) + return _compact_property_card_text(" ".join(part for part in parts if part), 150) + + def _make_property_scope_combo(self, row: int, spec: dict[str, object]) -> NoWheelComboBox: + combo = NoWheelComboBox() + combo.setObjectName("propertyScopeCombo") + modes = spec.get("scope_modes") + default_scope = self._property_scope_value(row, spec) or self._property_scope_default(spec) + selected_index = 0 + if isinstance(modes, dict): + for index, (scope_key, mode) in enumerate(modes.items()): + if not isinstance(mode, dict): + continue + label = str(mode.get("label") or scope_key) + if not bool(mode.get("enabled", True)): + label = f"{label}(不可用)" + combo.addItem(label, scope_key) + tip = str(mode.get("enabled_tip") or mode.get("disabled_tip") or "").strip() + if tip: + combo.setItemData(index, tip, Qt.ItemDataRole.ToolTipRole) + if scope_key == default_scope: + selected_index = index + combo.setCurrentIndex(selected_index) + combo.setToolTip(self._property_scope_tooltip(spec, default_scope)) + combo.currentIndexChanged.connect(lambda _index=0, target_row=row: self._on_property_scope_changed(target_row)) + return combo + def _refresh_property_editor(self) -> None: if not hasattr(self, "property_table"): return @@ -1352,9 +1908,12 @@ class WindowStateMixin: specs = self._sort_property_specs_for_display(self._property_editor_specs(info, action_info)) self.property_editor_specs = specs self.property_table_expanded = False + self.property_editor_selected_row = None + self.property_command_active_key = "" self.property_editor_updating = True was_blocked = self.property_table.blockSignals(True) try: + self.property_table.clearSpans() self.property_table.setRowCount(len(specs)) for row, spec in enumerate(specs): effective_spec = self._effective_property_spec(spec) @@ -1363,12 +1922,16 @@ class WindowStateMixin: input_editable = editable and value_type != "command" label_item = self._property_table_item(str(effective_spec.get("label", "")), editable=False) current_item = self._property_table_item(str(effective_spec.get("current_text", "")), editable=False) - scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False) - target_item = self._property_table_item( - "" if input_editable else str(effective_spec.get("target_text", "")), + span_value_columns = bool(effective_spec.get("span_value_columns")) and not editable + scope_item = self._property_table_item( + "" if span_value_columns else str(effective_spec.get("scope_text", "")), editable=False, ) - action_text = "" if editable else str(effective_spec.get("status_text", "")) + target_item = self._property_table_item( + "" if input_editable or span_value_columns else str(effective_spec.get("target_text", "")), + editable=False, + ) + action_text = "" if editable or span_value_columns else str(effective_spec.get("status_text", "")) action_item = self._property_table_item(action_text, editable=False) target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) row_items = (label_item, current_item, scope_item, target_item, action_item) @@ -1376,6 +1939,9 @@ class WindowStateMixin: for column, item in enumerate(row_items): item.setToolTip(item.toolTip() or item.text()) self.property_table.setItem(row, column, item) + if span_value_columns: + current_item.setToolTip(str(effective_spec.get("disabled_tip") or current_item.text())) + self.property_table.setSpan(row, PROPERTY_CURRENT_COLUMN, 1, 4) self.property_table.setRowHeight(row, 28 if editable else 24) if spec.get("scope_modes"): self._set_property_scope_editor(row, spec) @@ -1390,10 +1956,13 @@ class WindowStateMixin: else: self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN) self.property_table.removeCellWidget(row, PROPERTY_ACTION_COLUMN) + self._rebuild_property_command_bar() + self._rebuild_property_cards() self._resize_property_table_height() finally: self.property_table.blockSignals(was_blocked) self.property_editor_updating = False + self._resize_property_table_columns() self._update_property_apply_state() def _sort_property_specs_for_display(self, specs: list[dict[str, object]]) -> list[dict[str, object]]: @@ -1479,6 +2048,11 @@ class WindowStateMixin: modes = spec.get("scope_modes") if not isinstance(modes, dict) or not modes: return "" + card_widget = self._property_card_widgets(row).get("scope_combo") + if isinstance(card_widget, NoWheelComboBox): + value = card_widget.currentData() + if value in modes: + return str(value) widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN) if hasattr(self, "property_table") else None if isinstance(widget, NoWheelComboBox): value = widget.currentData() @@ -1578,8 +2152,30 @@ class WindowStateMixin: effective_spec = self._effective_property_spec(specs[row], row=row) editable = bool(effective_spec.get("editable") and effective_spec.get("enabled")) input_editable = editable and str(effective_spec.get("value_type", "number")) != "command" + card_widgets = self._property_card_widgets(row) + card_scope_widget = card_widgets.get("scope_combo") + if isinstance(card_scope_widget, NoWheelComboBox): + selected_scope = card_scope_widget.currentData() + card_scope_widget.setToolTip(self._property_scope_tooltip(specs[row], self._property_scope_value(row, specs[row]))) + card_target_widget = card_widgets.get("target_editor") + if isinstance(card_target_widget, QLineEdit): + card_target_widget.setEnabled(input_editable) + card_target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) + hint_label = card_widgets.get("hint_label") + if isinstance(hint_label, QLabel): + hint_text = self._property_card_hint_text(effective_spec, editable=editable) + hint_label.setText(hint_text) + hint_label.setVisible(bool(hint_text)) scope_widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN) if isinstance(scope_widget, NoWheelComboBox): + if "selected_scope" in locals(): + target_index = scope_widget.findData(selected_scope) + if target_index >= 0 and target_index != scope_widget.currentIndex(): + was_blocked = scope_widget.blockSignals(True) + try: + scope_widget.setCurrentIndex(target_index) + finally: + scope_widget.blockSignals(was_blocked) scope_widget.setToolTip(self._property_scope_tooltip(specs[row], self._property_scope_value(row, specs[row]))) target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) if isinstance(target_widget, QLineEdit): @@ -1593,6 +2189,19 @@ class WindowStateMixin: action_item.setText(str(effective_spec.get("status_text", ""))) self._update_property_apply_state() + def _on_property_card_target_changed(self, row: int) -> None: + card_widget = self._property_card_widgets(row).get("target_editor") + table_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) if hasattr(self, "property_table") else None + if isinstance(card_widget, QLineEdit) and isinstance(table_widget, QLineEdit): + text = card_widget.text() + if table_widget.text() != text: + was_blocked = table_widget.blockSignals(True) + try: + table_widget.setText(text) + finally: + table_widget.blockSignals(was_blocked) + self._update_property_apply_state() + def _property_target_tooltip(self, spec: dict[str, object], *, editable: bool) -> str: tip_key = "enabled_tip" if editable else "disabled_tip" parts = [str(spec.get(tip_key, "")).strip()] @@ -1625,7 +2234,7 @@ class WindowStateMixin: def _resize_property_table_height(self) -> None: if not hasattr(self, "property_table"): return - row_count = self.property_table.rowCount() + row_count = len(getattr(self, "property_editor_specs", []) or []) collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 6) or 6) expanded = bool(getattr(self, "property_table_expanded", False)) visible_rows = row_count if expanded else min(row_count, collapsed_rows) @@ -1642,19 +2251,54 @@ class WindowStateMixin: if expanded or not has_hidden_rows else Qt.ScrollBarPolicy.ScrollBarAsNeeded ) + if hasattr(self, "property_card_scroll"): + card_visible_rows = self._visible_property_card_rows() + card_heights: list[int] = [] + for row, _spec in card_visible_rows: + card = self._property_card_widgets(row).get("card") + if isinstance(card, QFrame): + card_heights.append(max(int(card.sizeHint().height()), 40)) + if card_heights: + content_height = sum(card_heights) + max(0, len(card_heights) - 1) * 4 + 10 + else: + content_height = 96 + card_height = content_height + card_height = min(card_height, 560 if expanded else 360) + card_height = max(96, card_height) + if hasattr(self, "property_card_container"): + self.property_card_container.setMinimumHeight(content_height) + self.property_card_scroll.setMinimumHeight(card_height) + self.property_card_scroll.setMaximumHeight(card_height) + self.property_card_scroll.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded if content_height > card_height else Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) if hasattr(self, "property_expand_button"): self.property_expand_button.setVisible(has_hidden_rows) if expanded: - self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项") - self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几项。") + self.property_expand_button.setText(f"收起诊断信息,保留前 {collapsed_rows} 项") + self.property_expand_button.setToolTip("收起只读诊断信息,保留可编辑参数和关键说明。") else: - self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)") - self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。") + self.property_expand_button.setText(f"更多诊断信息 ({row_count} 项)") + self.property_expand_button.setToolTip("展开完整参数、识别依据和一级关系诊断;参数化建模按钮会继续留在下方。") + self._resize_property_table_columns() + + def _resize_property_table_columns(self) -> None: + if not hasattr(self, "property_table"): + return + table = self.property_table + viewport_width = int(table.viewport().width()) if table.viewport() is not None else int(table.width()) + if viewport_width <= 0: + return + widths = _property_table_column_widths(viewport_width) + for column, width in enumerate(widths): + table.setColumnWidth(column, width) def toggle_property_table_expanded(self) -> None: if not hasattr(self, "property_table"): return self.property_table_expanded = not bool(getattr(self, "property_table_expanded", False)) + self._rebuild_property_command_bar() + self._rebuild_property_cards() self._resize_property_table_height() def _property_table_item(self, text: str, *, editable: bool) -> QTableWidgetItem: @@ -1764,9 +2408,34 @@ class WindowStateMixin: action_info: dict[str, object], ) -> list[dict[str, object]]: root_rows = self._feature_property_specs(root_specs, action_info) + context_rows: list[dict[str, object]] = [] + context_note = str(action_info.get("feature_context_note") or "").strip() + if context_note: + level_text = str(action_info.get("feature_detection_level") or "").strip() + associated_count = _int_or_none(action_info.get("associated_feature_count")) + count_text = "" if associated_count is None else f";关联特征 {associated_count} 项" + context_rows.append( + { + "key": "feature_context_note", + "label": "关联探测", + "current_text": context_note, + "current_raw": context_note, + "target_text": "", + "editable": False, + "enabled": False, + "status_text": "说明", + "scope_text": f"{level_text}{count_text}".strip(";"), + "disabled_tip": ( + "这里说明当前特征探测级别和已经找到的一级/二级关联特征;" + "有关联尺寸时,会作为带“关联 Face”的可修改行显示在同一张参数表里。" + ), + "pin_top": True, + "span_value_columns": True, + } + ) associated = action_info.get("associated_feature_infos") if not isinstance(associated, (list, tuple)) or not associated: - return root_rows + return context_rows + root_rows root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"] root_explanations = [dict(spec) for spec in root_rows if spec.get("parameter_role") != "dimension"] @@ -1789,7 +2458,7 @@ class WindowStateMixin: related["association_index"] = index related_rows.append(related) - return root_dimensions + related_rows + root_explanations + return context_rows + root_dimensions + related_rows + root_explanations def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: items = self._ordered_info_items(info) @@ -2266,6 +2935,7 @@ class WindowStateMixin: "status_text": "说明", "disabled_tip": tip or text, "pin_top": pin_top, + "span_value_columns": True, } ) @@ -2624,6 +3294,36 @@ class WindowStateMixin: ) if has_edge: + topology_depth = _int_or_none(action_info.get("topology_relation_depth")) + if topology_depth == 1: + selected_edge_count = _int_or_none(action_info.get("selected_edge_count")) or 1 + vertex_count = _int_or_none(action_info.get("first_level_vertex_count")) or 0 + adjacent_edge_count = _int_or_none(action_info.get("first_level_adjacent_edge_count")) or 0 + adjacent_face_count = _int_or_none(action_info.get("first_level_adjacent_face_count")) or 0 + included_edge_count = _int_or_none(action_info.get("first_level_edge_count")) or 0 + topology_note = str(action_info.get("first_level_topology_note") or "").strip() + fact_summary = str(action_info.get("first_level_fact_summary") or "").strip() + ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip() + topology_tip = "\n".join( + item + for item in ( + topology_note, + fact_summary, + ignored_note, + "当前阶段只把被选 Edge、端点 Vertex、共享端点相邻 Edge 和直接包含该 Edge 的 Face 作为一级关系;不会自动递归传播到二级、三级关系。", + ) + if item + ) + add_readonly_spec( + key="edge_first_level_topology", + label="一级关系", + text=( + f"当前 Edge {selected_edge_count} 条;端点 Vertex {vertex_count} 个;" + f"共享端点相邻 Edge {adjacent_edge_count} 条;直接相邻 Face {adjacent_face_count} 个;" + f"一级范围 Edge {included_edge_count} 条。" + ), + tip=topology_tip, + ) add_readonly_spec( key="edge_edit_semantics", label="建模意图", @@ -4702,18 +5402,46 @@ class WindowStateMixin: ) for row, spec in enumerate(getattr(self, "property_editor_specs", [])): effective_spec = self._effective_property_spec(spec, row=row) - target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) + card_widgets = self._property_card_widgets(row) + target_widget = card_widgets.get("target_editor") + if not isinstance(target_widget, QLineEdit): + target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) if isinstance(target_widget, QLineEdit): editable = bool(effective_spec.get("editable") and effective_spec.get("enabled")) input_editable = editable and str(effective_spec.get("value_type", "number")) != "command" target_widget.setEnabled(input_editable) target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) - widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN) + hint_label = card_widgets.get("hint_label") + if isinstance(hint_label, QLabel): + hint_text = self._property_card_hint_text( + effective_spec, + editable=bool(effective_spec.get("editable") and effective_spec.get("enabled")), + ) + hint_label.setText(hint_text) + hint_label.setVisible(bool(hint_text)) + card = card_widgets.get("card") + card_changed = False + card_invalid = False + if str(effective_spec.get("value_type", "number")) != "command": + text = self._property_target_text(row) + card_changed = self._property_target_changed(effective_spec, text) + card_invalid = bool(text.strip() and self._property_target_validation_error(effective_spec, text)) + if isinstance(card, QFrame): + card_state = (bool(card_changed and not card_invalid), bool(card_invalid)) + if getattr(card, "_geom_param_card_state", None) != card_state: + card.setProperty("changed", card_state[0]) + card.setProperty("invalid", card_state[1]) + card.style().unpolish(card) + card.style().polish(card) + setattr(card, "_geom_param_card_state", card_state) + widget = card_widgets.get("action_button") + if not isinstance(widget, QPushButton): + widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN) if isinstance(widget, QPushButton): validation_error = "" if str(effective_spec.get("value_type", "number")) == "command": row_changed = True - widget.setText(str(effective_spec.get("button_text") or "执行")) + widget.setText(str(effective_spec.get("button_text") or "执行当前命令")) enabled = bool(has_model and effective_spec.get("enabled") and effective_spec.get("action")) tooltip = f"执行“{effective_spec.get('label', '当前操作')}”。" else: @@ -4722,11 +5450,11 @@ class WindowStateMixin: empty_target = not bool(text.strip()) validation_error = "" if empty_target else self._property_target_validation_error(effective_spec, text) if empty_target: - widget.setText("未输入") + widget.setText("输入目标值后应用") elif validation_error: - widget.setText("无效") + widget.setText("目标无效") else: - widget.setText("应用" if row_changed else "未改动") + widget.setText("应用当前命令" if row_changed else "修改目标值后应用") enabled = bool( has_model and effective_spec.get("enabled") @@ -4753,6 +5481,10 @@ class WindowStateMixin: tooltip = f"{tooltip}\n\n{range_hint}" widget.setProperty("changed", bool(row_changed and not validation_error)) widget.setProperty("invalid", bool(validation_error)) + card = card_widgets.get("card") + if isinstance(card, QFrame): + card.setProperty("changed", bool(row_changed and not validation_error)) + card.setProperty("invalid", bool(validation_error)) button_state = ( widget.text(), bool(enabled), @@ -4766,6 +5498,9 @@ class WindowStateMixin: widget.style().unpolish(widget) widget.style().polish(widget) widget.setEnabled(enabled) + if isinstance(card, QFrame): + card.style().unpolish(card) + card.style().polish(card) setattr(widget, "_geom_param_button_state", button_state) if not hasattr(self, "apply_property_button"): return @@ -4807,6 +5542,9 @@ class WindowStateMixin: def _property_target_text(self, row: int) -> str: if not hasattr(self, "property_table"): return "" + card_widget = self._property_card_widgets(row).get("target_editor") + if isinstance(card_widget, QLineEdit): + return card_widget.text().strip() widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) if isinstance(widget, QLineEdit): return widget.text().strip()