From 25592fa6c2ba7bf98cfb679a447b3fd13cebcabe Mon Sep 17 00:00:00 2001 From: will123song Date: Wed, 9 Sep 2026 08:35:15 -0500 Subject: [PATCH] WillTools 2026-09-09 -- clean snapshot of the development repo for review History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings. --- .gitattributes | 15 + .github/workflows/ci.yml | 38 + .gitignore | 96 + .scrub-terms.example | 30 + .setup-opts.example | 31 + AGENTS.md | 32 + CLAUDE.md | 899 ++ CONTRIBUTING.md | 102 + INSTALL.md | 325 + PROJECT.md | 709 ++ QNA/README.md | 52 + README.md | 173 + .../BeamAnalysis.pushbutton/bundle.yaml | 12 + .../BeamAnalysis.pushbutton/icon.png | Bin 0 -> 2558 bytes .../BeamAnalysis.pushbutton/script.py | 28 + .../ConnectionAnalysis.pushbutton/bundle.yaml | 12 + .../ConnectionAnalysis.pushbutton/icon.png | Bin 0 -> 3541 bytes .../ConnectionAnalysis.pushbutton/script.py | 28 + .../GravityLoadChase.pushbutton/bundle.yaml | 14 + .../GravityLoadChase.pushbutton/icon.png | Bin 0 -> 3702 bytes .../GravityLoadChase.pushbutton/script.py | 215 + .../LateralExtract.pushbutton/bundle.yaml | 10 + .../LateralExtract.pushbutton/icon.png | Bin 0 -> 4026 bytes .../LateralExtract.pushbutton/script.py | 351 + .../PanelAnalysis.pushbutton/bundle.yaml | 17 + .../PanelAnalysis.pushbutton/icon.png | Bin 0 -> 3110 bytes .../PanelAnalysis.pushbutton/script.py | 4460 ++++++++ .../PanelLiftCheck.pushbutton/bundle.yaml | 27 + .../PanelLiftCheck.pushbutton/icon.png | Bin 0 -> 4413 bytes .../PanelLiftCheck.pushbutton/script.py | 1066 ++ .../DesignHub.pulldown/bundle.yaml | 36 + .../Design.panel/DesignHub.pulldown/icon.png | Bin 0 -> 5126 bytes .../WillTools.tab/Design.panel/bundle.yaml | 16 + .../Lab.panel/GodMod.pushbutton/bundle.yaml | 13 + .../Lab.panel/GodMod.pushbutton/icon.png | Bin 0 -> 1136 bytes .../Lab.panel/GodMod.pushbutton/script.py | 178 + .../WillTools.tab/Lab.panel/bundle.yaml | 10 + .../ExtractDesign.pushbutton/bundle.yaml | 8 + .../ExtractDesign.pushbutton/icon.png | Bin 0 -> 2977 bytes .../ExtractDesign.pushbutton/script.py | 714 ++ .../HotReload.pushbutton/bundle.yaml | 8 + .../HotReload.pushbutton/icon.png | Bin 0 -> 3697 bytes .../HotReload.pushbutton/script.py | 93 + .../JobRecon.pushbutton/bundle.yaml | 12 + .../JobRecon.pushbutton/icon.png | Bin 0 -> 2793 bytes .../JobRecon.pushbutton/script.py | 207 + .../ProjectConfig.xaml | 167 + .../ProjectConfig.pushbutton/bundle.yaml | 29 + .../ProjectConfig.pushbutton/icon.png | Bin 0 -> 6585 bytes .../ProjectConfig.pushbutton/script.py | 1588 +++ .../SelfCheck.pushbutton/bundle.yaml | 8 + .../SelfCheck.pushbutton/icon.png | Bin 0 -> 3697 bytes .../SelfCheck.pushbutton/script.py | 405 + .../Takeoff.pushbutton/bundle.yaml | 10 + .../Takeoff.pushbutton/icon.png | Bin 0 -> 2732 bytes .../Takeoff.pushbutton/script.py | 210 + .../Setup.panel/Config.pulldown/bundle.yaml | 31 + .../Setup.panel/Config.pulldown/icon.png | Bin 0 -> 4029 bytes .../WillTools.tab/Setup.panel/bundle.yaml | 10 + .../ReinforceSuggest.pushbutton/bundle.yaml | 11 + .../ReinforceSuggest.pushbutton/icon.png | Bin 0 -> 358 bytes .../ReinforceSuggest.pushbutton/script.py | 459 + .../ReinforcingQC.pushbutton/bundle.yaml | 28 + .../ReinforcingQC.pushbutton/icon.png | Bin 0 -> 4153 bytes .../ReinforcingQC.pushbutton/script.py | 29 + .../TicketQC.pushbutton/bundle.yaml | 27 + .../TicketQC.pushbutton/icon.png | Bin 0 -> 4153 bytes .../TicketQC.pushbutton/script.py | 32 + .../Tools.panel/Modeling.pulldown/bundle.yaml | 27 + .../Tools.panel/Modeling.pulldown/icon.png | Bin 0 -> 2810 bytes .../SnapshotClose.pushbutton/bundle.yaml | 11 + .../SnapshotClose.pushbutton/icon.png | Bin 0 -> 3112 bytes .../SnapshotClose.pushbutton/script.py | 889 ++ .../WillTools.tab/Tools.panel/bundle.yaml | 28 + WillTools.extension/WillTools.tab/bundle.yaml | 20 + WillTools.extension/extension.json | 8 + WillTools.extension/lib/vibe/__init__.py | 25 + WillTools.extension/lib/vibe/aiextract.py | 316 + WillTools.extension/lib/vibe/batchrun.py | 254 + WillTools.extension/lib/vibe/bridgeguard.py | 423 + WillTools.extension/lib/vibe/compat.py | 31 + WillTools.extension/lib/vibe/configdump.py | 768 ++ WillTools.extension/lib/vibe/configform.py | 1210 +++ WillTools.extension/lib/vibe/configmodel.py | 1631 +++ WillTools.extension/lib/vibe/configpage.py | 557 + WillTools.extension/lib/vibe/designspec.py | 818 ++ .../lib/vibe/extractrules/__init__.py | 206 + .../lib/vibe/extractrules/mc.py | 118 + WillTools.extension/lib/vibe/foldreport.py | 521 + WillTools.extension/lib/vibe/helpers.py | 279 + WillTools.extension/lib/vibe/joblocate.py | 185 + WillTools.extension/lib/vibe/jobrecon.py | 1092 ++ WillTools.extension/lib/vibe/jobreconscan.py | 332 + WillTools.extension/lib/vibe/knowledge.py | 8693 ++++++++++++++++ WillTools.extension/lib/vibe/latextract.py | 407 + WillTools.extension/lib/vibe/latmodel.py | 414 + WillTools.extension/lib/vibe/latrisa.py | 232 + WillTools.extension/lib/vibe/liftexport.py | 3435 +++++++ WillTools.extension/lib/vibe/pa_criteria.py | 944 ++ WillTools.extension/lib/vibe/pa_defaults.py | 807 ++ WillTools.extension/lib/vibe/pa_elevation.py | 150 + WillTools.extension/lib/vibe/pa_extents.py | 674 ++ WillTools.extension/lib/vibe/pa_handling.py | 1296 +++ WillTools.extension/lib/vibe/pa_lifters.py | 383 + WillTools.extension/lib/vibe/pa_pagespec.py | 747 ++ WillTools.extension/lib/vibe/pa_prefill.py | 64 + WillTools.extension/lib/vibe/pa_reinf.py | 761 ++ .../lib/vibe/pa_reinforcement.py | 1436 +++ .../lib/vibe/pa_sketch_extents.py | 994 ++ .../lib/vibe/pa_sketch_freebody.py | 920 ++ .../lib/vibe/pa_sketch_handling.py | 1285 +++ .../lib/vibe/pa_sketch_reinf.py | 1388 +++ WillTools.extension/lib/vibe/pa_structural.py | 802 ++ WillTools.extension/lib/vibe/pa_supports.py | 192 + WillTools.extension/lib/vibe/pa_units.py | 67 + .../lib/vibe/panelanalysisform.py | 3994 ++++++++ WillTools.extension/lib/vibe/panelsketch.py | 1871 ++++ WillTools.extension/lib/vibe/plantpath.py | 131 + WillTools.extension/lib/vibe/reinforcebind.py | 1584 +++ WillTools.extension/lib/vibe/reinforcecore.py | 1499 +++ .../lib/vibe/reinforceindex.py | 807 ++ .../lib/vibe/reinforcematch.py | 805 ++ .../lib/vibe/reinforcereport.py | 1598 +++ WillTools.extension/lib/vibe/runlog.py | 410 + WillTools.extension/lib/vibe/scope.py | 422 + WillTools.extension/lib/vibe/snapshotmeta.py | 720 ++ WillTools.extension/lib/vibe/specformat.py | 532 + WillTools.extension/lib/vibe/specsave.py | 254 + WillTools.extension/lib/vibe/strandmatrix.py | 655 ++ WillTools.extension/lib/vibe/takeoffcore.py | 597 ++ WillTools.extension/lib/vibe/takeoffcount.py | 470 + WillTools.extension/lib/vibe/takeoffgather.py | 364 + WillTools.extension/lib/vibe/takeoffreport.py | 777 ++ WillTools.extension/lib/vibe/ticketchecks.py | 9000 +++++++++++++++++ WillTools.extension/lib/vibe/ticketdelta.py | 861 ++ WillTools.extension/lib/vibe/ticketextract.py | 714 ++ WillTools.extension/lib/vibe/ticketpage.py | 5825 +++++++++++ WillTools.extension/lib/vibe/ticketqc.py | 3690 +++++++ WillTools.extension/lib/vibe/ticketqc_run.py | 811 ++ WillTools.extension/lib/vibe/ticketvision.py | 1428 +++ WillTools.extension/lib/vibe/webhost.py | 320 + .../lib/vibe/webhost_demo/index.html | 32 + WillTools.extension/lib/vibe/yamlkit.py | 791 ++ WillTools.extension/pyproject.toml | 9 + .../runners/detach_snapshot.py | 335 + WillTools.extension/startup.py | 845 ++ WillTools.extension/tests/conftest.py | 69 + .../tests/pyrevit_yaml_fake.py | 175 + .../tests/test_aiextract_run.py | 1327 +++ .../tests/test_app_js_live_kv.py | 77 + WillTools.extension/tests/test_ba_pagespec.py | 490 + WillTools.extension/tests/test_ba_web.py | 280 + WillTools.extension/tests/test_batchrun.py | 381 + .../tests/test_bridge_correlation.py | 578 ++ .../tests/test_bridge_query_channel.py | 191 + WillTools.extension/tests/test_bridgeguard.py | 519 + .../tests/test_button_tooltips.py | 254 + .../tests/test_coding_cookie.py | 96 + .../tests/test_compression_limit_setting.py | 156 + WillTools.extension/tests/test_configdump.py | 393 + WillTools.extension/tests/test_configform.py | 1294 +++ WillTools.extension/tests/test_configmodel.py | 875 ++ WillTools.extension/tests/test_configpage.py | 447 + WillTools.extension/tests/test_deckcheck.py | 509 + WillTools.extension/tests/test_dsh_pool.py | 226 + WillTools.extension/tests/test_dsh_watch.py | 260 + WillTools.extension/tests/test_dsh_worker.py | 303 + .../tests/test_extractrules.py | 192 + WillTools.extension/tests/test_find_python.py | 370 + WillTools.extension/tests/test_foldreport.py | 315 + .../tests/test_insulation_hatch.py | 243 + WillTools.extension/tests/test_joblocate.py | 154 + WillTools.extension/tests/test_jobrecon.py | 709 ++ WillTools.extension/tests/test_knowledge.py | 5127 ++++++++++ .../tests/test_knowledge_dotted_parameters.py | 37 + .../tests/test_knowledge_drift.py | 523 + .../tests/test_knowledgelint.py | 208 + WillTools.extension/tests/test_latmodel.py | 348 + WillTools.extension/tests/test_libindex.py | 740 ++ .../tests/test_lifter_capacity_parts_table.py | 128 + WillTools.extension/tests/test_liftexport.py | 1959 ++++ .../tests/test_liftexport_removed.py | 91 + .../tests/test_liftexport_section.py | 65 + WillTools.extension/tests/test_pa_criteria.py | 641 ++ WillTools.extension/tests/test_pa_defaults.py | 368 + .../tests/test_pa_elevation.py | 111 + WillTools.extension/tests/test_pa_extents.py | 466 + WillTools.extension/tests/test_pa_handling.py | 895 ++ WillTools.extension/tests/test_pa_lifters.py | 176 + WillTools.extension/tests/test_pa_pagespec.py | 420 + WillTools.extension/tests/test_pa_prefill.py | 115 + WillTools.extension/tests/test_pa_reinf.py | 320 + .../tests/test_pa_reinforcement.py | 1118 ++ .../tests/test_pa_settings_prefill.py | 82 + .../tests/test_pa_sketch_extents.py | 474 + .../tests/test_pa_sketch_freebody.py | 768 ++ .../tests/test_pa_sketch_handling.py | 584 ++ .../tests/test_pa_sketch_reinf.py | 1030 ++ .../tests/test_pa_structural.py | 676 ++ WillTools.extension/tests/test_pa_supports.py | 140 + .../tests/test_panelanalysis.py | 239 + .../tests/test_panelanalysis_service.py | 133 + .../tests/test_panelanalysis_solve.py | 223 + .../tests/test_panelanalysis_staging.py | 317 + .../tests/test_panelanalysis_wind.py | 202 + .../tests/test_panelanalysisform.py | 2160 ++++ WillTools.extension/tests/test_panelsketch.py | 1120 ++ .../tests/test_plant_detect.py | 67 + WillTools.extension/tests/test_plant_scope.py | 276 + .../tests/test_pyrevit_yaml_fake.py | 107 + .../tests/test_reinforcebind.py | 1374 +++ .../tests/test_reinforcecore.py | 1371 +++ .../tests/test_reinforcematch.py | 884 ++ .../tests/test_reinforceoffline.py | 269 + .../tests/test_reinforcereport.py | 1353 +++ .../tests/test_reinforcing_qc.py | 333 + WillTools.extension/tests/test_runlog.py | 300 + WillTools.extension/tests/test_rvt_bridges.py | 225 + WillTools.extension/tests/test_scope.py | 318 + WillTools.extension/tests/test_scrub.py | 641 ++ .../tests/test_setup_machine.py | 511 + .../tests/test_snapshot_cycle.py | 800 ++ .../tests/test_snapshot_defer.py | 300 + .../tests/test_snapshotmeta.py | 468 + WillTools.extension/tests/test_specformat.py | 334 + WillTools.extension/tests/test_specsave.py | 274 + .../tests/test_strand_count_expectation.py | 36 + .../tests/test_strandmatrix.py | 445 + .../tests/test_stress_limit_setting.py | 197 + .../tests/test_summary_vocabulary.py | 366 + WillTools.extension/tests/test_sweep_scope.py | 187 + .../tests/test_sweep_source_model.py | 206 + WillTools.extension/tests/test_takeoffcore.py | 277 + .../tests/test_takeoffcount.py | 336 + .../tests/test_takeoffreport.py | 518 + .../tests/test_ticketchecks_host.py | 4566 +++++++++ WillTools.extension/tests/test_ticketdelta.py | 546 + WillTools.extension/tests/test_ticketpage.py | 4128 ++++++++ .../tests/test_ticketpage_opening_diagonal.py | 195 + .../tests/test_ticketqc_evidence.py | 721 ++ .../tests/test_ticketqc_paramreaders.py | 357 + .../tests/test_ticketqc_weight_fallback.py | 135 + .../tests/test_ticketvision.py | 1068 ++ WillTools.extension/tests/test_weight_gate.py | 144 + WillTools.extension/tests/test_wpf_smoke.py | 328 + WillTools.extension/tests/test_yamlkit.py | 401 + beamcalc/fixtures/rba1_8x48.json | 363 + beamcalc/fixtures/rba5_16x22.json | 63 + beamcalc/pyproject.toml | 19 + beamcalc/src/beamcalc/__init__.py | 13 + beamcalc/src/beamcalc/__main__.py | 3 + beamcalc/src/beamcalc/cli.py | 70 + beamcalc/src/beamcalc/deflection.py | 273 + beamcalc/src/beamcalc/develop.py | 127 + beamcalc/src/beamcalc/flexure.py | 246 + beamcalc/src/beamcalc/loads.py | 138 + beamcalc/src/beamcalc/model.py | 771 ++ beamcalc/src/beamcalc/report.py | 383 + beamcalc/src/beamcalc/section.py | 80 + beamcalc/src/beamcalc/shear.py | 178 + beamcalc/src/beamcalc/solve.py | 573 ++ beamcalc/src/beamcalc/statics.py | 243 + beamcalc/tests/conftest.py | 16 + beamcalc/tests/test_shear_zones.py | 89 + beamcalc/tests/test_statics_shear_jump.py | 59 + beamcalc/tools/compare_eriksson.py | 224 + beamcalc/tools/eriksson_extract.py | 219 + docs/architecture-migration.md | 641 ++ docs/ba-web-spec.md | 236 + docs/beam-analysis-state.md | 86 + docs/checklist-code-map.md | 277 + docs/dsh-worker.md | 389 + docs/eriksson-wall-algorithms.md | 657 ++ docs/eriksson-wall-method.md | 426 + docs/handoff.md | 322 + docs/interference.md | 246 + docs/knowledge-override.md | 2036 ++++ docs/library-index.md | 236 + docs/lift-check.md | 1523 +++ docs/pa-unused-options.md | 88 + docs/pa-web-spec.md | 809 ++ docs/panellift-ewf-alignment.md | 77 + docs/plan-panellift-three-stage.md | 1079 ++ docs/plan-strands-auto-layout.md | 911 ++ docs/plan-ticketqc-refactor.md | 288 + docs/revit-api-cheatsheet.md | 261 + docs/revit-bridge-context.md | 236 + docs/snapshot-cycle.md | 253 + docs/snapshot-tool.md | 93 + docs/std-clash-all.md | 120 + docs/std-clash-judgement.md | 80 + docs/std-nonclash.md | 45 + docs/takeoff.md | 418 + docs/ticket-audit-wp1-12.md | 213 + docs/ticket-check.skill.md | 426 + docs/ticket-checklist.md | 2027 ++++ docs/ticket-knowledge-deltas.md | 156 + docs/ticketqc-vision.skill.md | 189 + docs/todo-units.md | 95 + knowledge/README.md | 66 + knowledge/catalogue-rationale.md | 1046 ++ knowledge/catalogue.yaml | 3205 ++++++ knowledge/catalogue.yaml.template | 102 + panellift/PLAN.md | 436 + panellift/pyproject.toml | 19 + panellift/src/panellift/__init__.py | 13 + panellift/src/panellift/__main__.py | 9 + panellift/src/panellift/capacity.py | 1829 ++++ panellift/src/panellift/checks.py | 1244 +++ panellift/src/panellift/cli.py | 1184 +++ panellift/src/panellift/inplace.py | 1615 +++ panellift/src/panellift/layout.py | 787 ++ panellift/src/panellift/linalg.py | 56 + panellift/src/panellift/model.py | 595 ++ panellift/src/panellift/pci.py | 452 + panellift/src/panellift/prestress.py | 180 + panellift/src/panellift/report.py | 2921 ++++++ panellift/src/panellift/sandwich.py | 1118 ++ panellift/src/panellift/section.py | 385 + panellift/src/panellift/statics.py | 686 ++ panellift/src/panellift/units.py | 238 + panellift/src/panellift/windload.py | 541 + panellift/tests/conftest.py | 120 + panellift/tests/test_aspire.py | 141 + panellift/tests/test_capacity.py | 1134 +++ panellift/tests/test_checks.py | 482 + panellift/tests/test_cli.py | 607 ++ panellift/tests/test_compression_limit.py | 285 + panellift/tests/test_derating.py | 72 + panellift/tests/test_detailed_shear.py | 233 + panellift/tests/test_developed_at_opening.py | 130 + panellift/tests/test_edge_anchor.py | 302 + panellift/tests/test_edge_sliver.py | 61 + panellift/tests/test_extra_cast.py | 87 + panellift/tests/test_fps_powercurve.py | 153 + panellift/tests/test_handling_stress.py | 203 + .../tests/test_ignore_lateral_prestress.py | 15 + panellift/tests/test_inplace.py | 552 + panellift/tests/test_inplace_service.py | 202 + panellift/tests/test_layout_memo.py | 174 + panellift/tests/test_layout_pinning.py | 118 + panellift/tests/test_mild_flexure.py | 269 + panellift/tests/test_no_recommend.py | 192 + panellift/tests/test_notation.py | 276 + panellift/tests/test_pci_fig832.py | 110 + panellift/tests/test_plots.py | 237 + panellift/tests/test_prestress.py | 208 + panellift/tests/test_recess.py | 261 + panellift/tests/test_row_flexure.py | 395 + panellift/tests/test_safety_factors.py | 244 + panellift/tests/test_sandwich.py | 594 ++ panellift/tests/test_sandwich_section.py | 196 + panellift/tests/test_span.py | 136 + panellift/tests/test_stage_enabled.py | 259 + panellift/tests/test_statics.py | 431 + panellift/tests/test_station_table.py | 372 + panellift/tests/test_tension_limit.py | 239 + panellift/tests/test_units.py | 203 + panellift/tests/test_up_end_disclosure.py | 196 + panellift/tests/test_windload.py | 263 + requirements.txt | 11 + tools/aiextract_run.py | 1547 +++ tools/ba_pagespec.py | 2319 +++++ tools/ba_web.py | 516 + tools/ba_web/static/ba_drawings.js | 543 + tools/ba_web/static/index.html | 20 + tools/deckcheck.py | 777 ++ tools/dsh-here.cmd | 15 + tools/dsh_pool.py | 213 + tools/dsh_watch.py | 319 + tools/dsh_worker.py | 260 + tools/knowledge_drift.py | 1056 ++ tools/knowledge_drift_allow.txt | 329 + tools/knowledgelint.py | 378 + tools/layersplit.py | 173 + tools/libindex.py | 918 ++ tools/library.py | 135 + tools/make_dist.py | 1091 ++ tools/make_icons.py | 363 + tools/pa_pagespec.py | 141 + tools/pa_web.py | 632 ++ tools/pa_web/static/app.js | 1308 +++ tools/pa_web/static/draw.js | 952 ++ tools/pa_web/static/index.html | 19 + tools/pa_web/static/units.js | 246 + tools/pa_web/static/willtools.css | 475 + tools/package.py | 160 + tools/panelanalysis.py | 2862 ++++++ tools/panelanalysis_schema.py | 1149 +++ tools/qna_watch.py | 248 + tools/reinforce_offline.py | 338 + tools/rvt.py | 763 ++ tools/scrub.py | 839 ++ tools/setup_machine.py | 1075 ++ tools/snapshot_cycle.py | 1158 +++ tools/snapshot_defer.py | 445 + tools/sweep.py | 431 + tools/sweep_in_revit.py | 249 + tools/tests/test_pa_web.py | 372 + tools/tests/test_panelanalysis_report.py | 128 + tools/tests/test_panelanalysis_schema.py | 257 + tools/tests/test_units_js.py | 115 + tools/ticket_delta.py | 226 + tools/vision_read.py | 273 + tools/webshot.py | 90 + tools/wpf_smoke.py | 212 + 406 files changed, 223435 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .scrub-terms.example create mode 100644 .setup-opts.example create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 INSTALL.md create mode 100644 PROJECT.md create mode 100644 QNA/README.md create mode 100644 README.md create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/GravityLoadChase.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/icon.png create mode 100644 WillTools.extension/WillTools.tab/Design.panel/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Lab.panel/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ExtractDesign.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/HotReload.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/HotReload.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/HotReload.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/JobRecon.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ProjectConfig.pushbutton/ProjectConfig.xaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ProjectConfig.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ProjectConfig.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/ProjectConfig.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/SelfCheck.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/SelfCheck.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/SelfCheck.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/Takeoff.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/Takeoff.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/Takeoff.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/icon.png create mode 100644 WillTools.extension/WillTools.tab/Setup.panel/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/ReinforceSuggest.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/ReinforceSuggest.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/ReinforceSuggest.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/ReinforcingQC.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/ReinforcingQC.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/ReinforcingQC.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/TicketQC.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/TicketQC.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/TicketQC.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/Modeling.pulldown/icon.png create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/SnapshotClose.pushbutton/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/SnapshotClose.pushbutton/icon.png create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/SnapshotClose.pushbutton/script.py create mode 100644 WillTools.extension/WillTools.tab/Tools.panel/bundle.yaml create mode 100644 WillTools.extension/WillTools.tab/bundle.yaml create mode 100644 WillTools.extension/extension.json create mode 100644 WillTools.extension/lib/vibe/__init__.py create mode 100644 WillTools.extension/lib/vibe/aiextract.py create mode 100644 WillTools.extension/lib/vibe/batchrun.py create mode 100644 WillTools.extension/lib/vibe/bridgeguard.py create mode 100644 WillTools.extension/lib/vibe/compat.py create mode 100644 WillTools.extension/lib/vibe/configdump.py create mode 100644 WillTools.extension/lib/vibe/configform.py create mode 100644 WillTools.extension/lib/vibe/configmodel.py create mode 100644 WillTools.extension/lib/vibe/configpage.py create mode 100644 WillTools.extension/lib/vibe/designspec.py create mode 100644 WillTools.extension/lib/vibe/extractrules/__init__.py create mode 100644 WillTools.extension/lib/vibe/extractrules/mc.py create mode 100644 WillTools.extension/lib/vibe/foldreport.py create mode 100644 WillTools.extension/lib/vibe/helpers.py create mode 100644 WillTools.extension/lib/vibe/joblocate.py create mode 100644 WillTools.extension/lib/vibe/jobrecon.py create mode 100644 WillTools.extension/lib/vibe/jobreconscan.py create mode 100644 WillTools.extension/lib/vibe/knowledge.py create mode 100644 WillTools.extension/lib/vibe/latextract.py create mode 100644 WillTools.extension/lib/vibe/latmodel.py create mode 100644 WillTools.extension/lib/vibe/latrisa.py create mode 100644 WillTools.extension/lib/vibe/liftexport.py create mode 100644 WillTools.extension/lib/vibe/pa_criteria.py create mode 100644 WillTools.extension/lib/vibe/pa_defaults.py create mode 100644 WillTools.extension/lib/vibe/pa_elevation.py create mode 100644 WillTools.extension/lib/vibe/pa_extents.py create mode 100644 WillTools.extension/lib/vibe/pa_handling.py create mode 100644 WillTools.extension/lib/vibe/pa_lifters.py create mode 100644 WillTools.extension/lib/vibe/pa_pagespec.py create mode 100644 WillTools.extension/lib/vibe/pa_prefill.py create mode 100644 WillTools.extension/lib/vibe/pa_reinf.py create mode 100644 WillTools.extension/lib/vibe/pa_reinforcement.py create mode 100644 WillTools.extension/lib/vibe/pa_sketch_extents.py create mode 100644 WillTools.extension/lib/vibe/pa_sketch_freebody.py create mode 100644 WillTools.extension/lib/vibe/pa_sketch_handling.py create mode 100644 WillTools.extension/lib/vibe/pa_sketch_reinf.py create mode 100644 WillTools.extension/lib/vibe/pa_structural.py create mode 100644 WillTools.extension/lib/vibe/pa_supports.py create mode 100644 WillTools.extension/lib/vibe/pa_units.py create mode 100644 WillTools.extension/lib/vibe/panelanalysisform.py create mode 100644 WillTools.extension/lib/vibe/panelsketch.py create mode 100644 WillTools.extension/lib/vibe/plantpath.py create mode 100644 WillTools.extension/lib/vibe/reinforcebind.py create mode 100644 WillTools.extension/lib/vibe/reinforcecore.py create mode 100644 WillTools.extension/lib/vibe/reinforceindex.py create mode 100644 WillTools.extension/lib/vibe/reinforcematch.py create mode 100644 WillTools.extension/lib/vibe/reinforcereport.py create mode 100644 WillTools.extension/lib/vibe/runlog.py create mode 100644 WillTools.extension/lib/vibe/scope.py create mode 100644 WillTools.extension/lib/vibe/snapshotmeta.py create mode 100644 WillTools.extension/lib/vibe/specformat.py create mode 100644 WillTools.extension/lib/vibe/specsave.py create mode 100644 WillTools.extension/lib/vibe/strandmatrix.py create mode 100644 WillTools.extension/lib/vibe/takeoffcore.py create mode 100644 WillTools.extension/lib/vibe/takeoffcount.py create mode 100644 WillTools.extension/lib/vibe/takeoffgather.py create mode 100644 WillTools.extension/lib/vibe/takeoffreport.py create mode 100644 WillTools.extension/lib/vibe/ticketchecks.py create mode 100644 WillTools.extension/lib/vibe/ticketdelta.py create mode 100644 WillTools.extension/lib/vibe/ticketextract.py create mode 100644 WillTools.extension/lib/vibe/ticketpage.py create mode 100644 WillTools.extension/lib/vibe/ticketqc.py create mode 100644 WillTools.extension/lib/vibe/ticketqc_run.py create mode 100644 WillTools.extension/lib/vibe/ticketvision.py create mode 100644 WillTools.extension/lib/vibe/webhost.py create mode 100644 WillTools.extension/lib/vibe/webhost_demo/index.html create mode 100644 WillTools.extension/lib/vibe/yamlkit.py create mode 100644 WillTools.extension/pyproject.toml create mode 100644 WillTools.extension/runners/detach_snapshot.py create mode 100644 WillTools.extension/startup.py create mode 100644 WillTools.extension/tests/conftest.py create mode 100644 WillTools.extension/tests/pyrevit_yaml_fake.py create mode 100644 WillTools.extension/tests/test_aiextract_run.py create mode 100644 WillTools.extension/tests/test_app_js_live_kv.py create mode 100644 WillTools.extension/tests/test_ba_pagespec.py create mode 100644 WillTools.extension/tests/test_ba_web.py create mode 100644 WillTools.extension/tests/test_batchrun.py create mode 100644 WillTools.extension/tests/test_bridge_correlation.py create mode 100644 WillTools.extension/tests/test_bridge_query_channel.py create mode 100644 WillTools.extension/tests/test_bridgeguard.py create mode 100644 WillTools.extension/tests/test_button_tooltips.py create mode 100644 WillTools.extension/tests/test_coding_cookie.py create mode 100644 WillTools.extension/tests/test_compression_limit_setting.py create mode 100644 WillTools.extension/tests/test_configdump.py create mode 100644 WillTools.extension/tests/test_configform.py create mode 100644 WillTools.extension/tests/test_configmodel.py create mode 100644 WillTools.extension/tests/test_configpage.py create mode 100644 WillTools.extension/tests/test_deckcheck.py create mode 100644 WillTools.extension/tests/test_dsh_pool.py create mode 100644 WillTools.extension/tests/test_dsh_watch.py create mode 100644 WillTools.extension/tests/test_dsh_worker.py create mode 100644 WillTools.extension/tests/test_extractrules.py create mode 100644 WillTools.extension/tests/test_find_python.py create mode 100644 WillTools.extension/tests/test_foldreport.py create mode 100644 WillTools.extension/tests/test_insulation_hatch.py create mode 100644 WillTools.extension/tests/test_joblocate.py create mode 100644 WillTools.extension/tests/test_jobrecon.py create mode 100644 WillTools.extension/tests/test_knowledge.py create mode 100644 WillTools.extension/tests/test_knowledge_dotted_parameters.py create mode 100644 WillTools.extension/tests/test_knowledge_drift.py create mode 100644 WillTools.extension/tests/test_knowledgelint.py create mode 100644 WillTools.extension/tests/test_latmodel.py create mode 100644 WillTools.extension/tests/test_libindex.py create mode 100644 WillTools.extension/tests/test_lifter_capacity_parts_table.py create mode 100644 WillTools.extension/tests/test_liftexport.py create mode 100644 WillTools.extension/tests/test_liftexport_removed.py create mode 100644 WillTools.extension/tests/test_liftexport_section.py create mode 100644 WillTools.extension/tests/test_pa_criteria.py create mode 100644 WillTools.extension/tests/test_pa_defaults.py create mode 100644 WillTools.extension/tests/test_pa_elevation.py create mode 100644 WillTools.extension/tests/test_pa_extents.py create mode 100644 WillTools.extension/tests/test_pa_handling.py create mode 100644 WillTools.extension/tests/test_pa_lifters.py create mode 100644 WillTools.extension/tests/test_pa_pagespec.py create mode 100644 WillTools.extension/tests/test_pa_prefill.py create mode 100644 WillTools.extension/tests/test_pa_reinf.py create mode 100644 WillTools.extension/tests/test_pa_reinforcement.py create mode 100644 WillTools.extension/tests/test_pa_settings_prefill.py create mode 100644 WillTools.extension/tests/test_pa_sketch_extents.py create mode 100644 WillTools.extension/tests/test_pa_sketch_freebody.py create mode 100644 WillTools.extension/tests/test_pa_sketch_handling.py create mode 100644 WillTools.extension/tests/test_pa_sketch_reinf.py create mode 100644 WillTools.extension/tests/test_pa_structural.py create mode 100644 WillTools.extension/tests/test_pa_supports.py create mode 100644 WillTools.extension/tests/test_panelanalysis.py create mode 100644 WillTools.extension/tests/test_panelanalysis_service.py create mode 100644 WillTools.extension/tests/test_panelanalysis_solve.py create mode 100644 WillTools.extension/tests/test_panelanalysis_staging.py create mode 100644 WillTools.extension/tests/test_panelanalysis_wind.py create mode 100644 WillTools.extension/tests/test_panelanalysisform.py create mode 100644 WillTools.extension/tests/test_panelsketch.py create mode 100644 WillTools.extension/tests/test_plant_detect.py create mode 100644 WillTools.extension/tests/test_plant_scope.py create mode 100644 WillTools.extension/tests/test_pyrevit_yaml_fake.py create mode 100644 WillTools.extension/tests/test_reinforcebind.py create mode 100644 WillTools.extension/tests/test_reinforcecore.py create mode 100644 WillTools.extension/tests/test_reinforcematch.py create mode 100644 WillTools.extension/tests/test_reinforceoffline.py create mode 100644 WillTools.extension/tests/test_reinforcereport.py create mode 100644 WillTools.extension/tests/test_reinforcing_qc.py create mode 100644 WillTools.extension/tests/test_runlog.py create mode 100644 WillTools.extension/tests/test_rvt_bridges.py create mode 100644 WillTools.extension/tests/test_scope.py create mode 100644 WillTools.extension/tests/test_scrub.py create mode 100644 WillTools.extension/tests/test_setup_machine.py create mode 100644 WillTools.extension/tests/test_snapshot_cycle.py create mode 100644 WillTools.extension/tests/test_snapshot_defer.py create mode 100644 WillTools.extension/tests/test_snapshotmeta.py create mode 100644 WillTools.extension/tests/test_specformat.py create mode 100644 WillTools.extension/tests/test_specsave.py create mode 100644 WillTools.extension/tests/test_strand_count_expectation.py create mode 100644 WillTools.extension/tests/test_strandmatrix.py create mode 100644 WillTools.extension/tests/test_stress_limit_setting.py create mode 100644 WillTools.extension/tests/test_summary_vocabulary.py create mode 100644 WillTools.extension/tests/test_sweep_scope.py create mode 100644 WillTools.extension/tests/test_sweep_source_model.py create mode 100644 WillTools.extension/tests/test_takeoffcore.py create mode 100644 WillTools.extension/tests/test_takeoffcount.py create mode 100644 WillTools.extension/tests/test_takeoffreport.py create mode 100644 WillTools.extension/tests/test_ticketchecks_host.py create mode 100644 WillTools.extension/tests/test_ticketdelta.py create mode 100644 WillTools.extension/tests/test_ticketpage.py create mode 100644 WillTools.extension/tests/test_ticketpage_opening_diagonal.py create mode 100644 WillTools.extension/tests/test_ticketqc_evidence.py create mode 100644 WillTools.extension/tests/test_ticketqc_paramreaders.py create mode 100644 WillTools.extension/tests/test_ticketqc_weight_fallback.py create mode 100644 WillTools.extension/tests/test_ticketvision.py create mode 100644 WillTools.extension/tests/test_weight_gate.py create mode 100644 WillTools.extension/tests/test_wpf_smoke.py create mode 100644 WillTools.extension/tests/test_yamlkit.py create mode 100644 beamcalc/fixtures/rba1_8x48.json create mode 100644 beamcalc/fixtures/rba5_16x22.json create mode 100644 beamcalc/pyproject.toml create mode 100644 beamcalc/src/beamcalc/__init__.py create mode 100644 beamcalc/src/beamcalc/__main__.py create mode 100644 beamcalc/src/beamcalc/cli.py create mode 100644 beamcalc/src/beamcalc/deflection.py create mode 100644 beamcalc/src/beamcalc/develop.py create mode 100644 beamcalc/src/beamcalc/flexure.py create mode 100644 beamcalc/src/beamcalc/loads.py create mode 100644 beamcalc/src/beamcalc/model.py create mode 100644 beamcalc/src/beamcalc/report.py create mode 100644 beamcalc/src/beamcalc/section.py create mode 100644 beamcalc/src/beamcalc/shear.py create mode 100644 beamcalc/src/beamcalc/solve.py create mode 100644 beamcalc/src/beamcalc/statics.py create mode 100644 beamcalc/tests/conftest.py create mode 100644 beamcalc/tests/test_shear_zones.py create mode 100644 beamcalc/tests/test_statics_shear_jump.py create mode 100644 beamcalc/tools/compare_eriksson.py create mode 100644 beamcalc/tools/eriksson_extract.py create mode 100644 docs/architecture-migration.md create mode 100644 docs/ba-web-spec.md create mode 100644 docs/beam-analysis-state.md create mode 100644 docs/checklist-code-map.md create mode 100644 docs/dsh-worker.md create mode 100644 docs/eriksson-wall-algorithms.md create mode 100644 docs/eriksson-wall-method.md create mode 100644 docs/handoff.md create mode 100644 docs/interference.md create mode 100644 docs/knowledge-override.md create mode 100644 docs/library-index.md create mode 100644 docs/lift-check.md create mode 100644 docs/pa-unused-options.md create mode 100644 docs/pa-web-spec.md create mode 100644 docs/panellift-ewf-alignment.md create mode 100644 docs/plan-panellift-three-stage.md create mode 100644 docs/plan-strands-auto-layout.md create mode 100644 docs/plan-ticketqc-refactor.md create mode 100644 docs/revit-api-cheatsheet.md create mode 100644 docs/revit-bridge-context.md create mode 100644 docs/snapshot-cycle.md create mode 100644 docs/snapshot-tool.md create mode 100644 docs/std-clash-all.md create mode 100644 docs/std-clash-judgement.md create mode 100644 docs/std-nonclash.md create mode 100644 docs/takeoff.md create mode 100644 docs/ticket-audit-wp1-12.md create mode 100644 docs/ticket-check.skill.md create mode 100644 docs/ticket-checklist.md create mode 100644 docs/ticket-knowledge-deltas.md create mode 100644 docs/ticketqc-vision.skill.md create mode 100644 docs/todo-units.md create mode 100644 knowledge/README.md create mode 100644 knowledge/catalogue-rationale.md create mode 100644 knowledge/catalogue.yaml create mode 100644 knowledge/catalogue.yaml.template create mode 100644 panellift/PLAN.md create mode 100644 panellift/pyproject.toml create mode 100644 panellift/src/panellift/__init__.py create mode 100644 panellift/src/panellift/__main__.py create mode 100644 panellift/src/panellift/capacity.py create mode 100644 panellift/src/panellift/checks.py create mode 100644 panellift/src/panellift/cli.py create mode 100644 panellift/src/panellift/inplace.py create mode 100644 panellift/src/panellift/layout.py create mode 100644 panellift/src/panellift/linalg.py create mode 100644 panellift/src/panellift/model.py create mode 100644 panellift/src/panellift/pci.py create mode 100644 panellift/src/panellift/prestress.py create mode 100644 panellift/src/panellift/report.py create mode 100644 panellift/src/panellift/sandwich.py create mode 100644 panellift/src/panellift/section.py create mode 100644 panellift/src/panellift/statics.py create mode 100644 panellift/src/panellift/units.py create mode 100644 panellift/src/panellift/windload.py create mode 100644 panellift/tests/conftest.py create mode 100644 panellift/tests/test_aspire.py create mode 100644 panellift/tests/test_capacity.py create mode 100644 panellift/tests/test_checks.py create mode 100644 panellift/tests/test_cli.py create mode 100644 panellift/tests/test_compression_limit.py create mode 100644 panellift/tests/test_derating.py create mode 100644 panellift/tests/test_detailed_shear.py create mode 100644 panellift/tests/test_developed_at_opening.py create mode 100644 panellift/tests/test_edge_anchor.py create mode 100644 panellift/tests/test_edge_sliver.py create mode 100644 panellift/tests/test_extra_cast.py create mode 100644 panellift/tests/test_fps_powercurve.py create mode 100644 panellift/tests/test_handling_stress.py create mode 100644 panellift/tests/test_ignore_lateral_prestress.py create mode 100644 panellift/tests/test_inplace.py create mode 100644 panellift/tests/test_inplace_service.py create mode 100644 panellift/tests/test_layout_memo.py create mode 100644 panellift/tests/test_layout_pinning.py create mode 100644 panellift/tests/test_mild_flexure.py create mode 100644 panellift/tests/test_no_recommend.py create mode 100644 panellift/tests/test_notation.py create mode 100644 panellift/tests/test_pci_fig832.py create mode 100644 panellift/tests/test_plots.py create mode 100644 panellift/tests/test_prestress.py create mode 100644 panellift/tests/test_recess.py create mode 100644 panellift/tests/test_row_flexure.py create mode 100644 panellift/tests/test_safety_factors.py create mode 100644 panellift/tests/test_sandwich.py create mode 100644 panellift/tests/test_sandwich_section.py create mode 100644 panellift/tests/test_span.py create mode 100644 panellift/tests/test_stage_enabled.py create mode 100644 panellift/tests/test_statics.py create mode 100644 panellift/tests/test_station_table.py create mode 100644 panellift/tests/test_tension_limit.py create mode 100644 panellift/tests/test_units.py create mode 100644 panellift/tests/test_up_end_disclosure.py create mode 100644 panellift/tests/test_windload.py create mode 100644 requirements.txt create mode 100644 tools/aiextract_run.py create mode 100644 tools/ba_pagespec.py create mode 100644 tools/ba_web.py create mode 100644 tools/ba_web/static/ba_drawings.js create mode 100644 tools/ba_web/static/index.html create mode 100644 tools/deckcheck.py create mode 100644 tools/dsh-here.cmd create mode 100644 tools/dsh_pool.py create mode 100644 tools/dsh_watch.py create mode 100644 tools/dsh_worker.py create mode 100644 tools/knowledge_drift.py create mode 100644 tools/knowledge_drift_allow.txt create mode 100644 tools/knowledgelint.py create mode 100644 tools/layersplit.py create mode 100644 tools/libindex.py create mode 100644 tools/library.py create mode 100644 tools/make_dist.py create mode 100644 tools/make_icons.py create mode 100644 tools/pa_pagespec.py create mode 100644 tools/pa_web.py create mode 100644 tools/pa_web/static/app.js create mode 100644 tools/pa_web/static/draw.js create mode 100644 tools/pa_web/static/index.html create mode 100644 tools/pa_web/static/units.js create mode 100644 tools/pa_web/static/willtools.css create mode 100644 tools/package.py create mode 100644 tools/panelanalysis.py create mode 100644 tools/panelanalysis_schema.py create mode 100644 tools/qna_watch.py create mode 100644 tools/reinforce_offline.py create mode 100644 tools/rvt.py create mode 100644 tools/scrub.py create mode 100644 tools/setup_machine.py create mode 100644 tools/snapshot_cycle.py create mode 100644 tools/snapshot_defer.py create mode 100644 tools/sweep.py create mode 100644 tools/sweep_in_revit.py create mode 100644 tools/tests/test_pa_web.py create mode 100644 tools/tests/test_panelanalysis_report.py create mode 100644 tools/tests/test_panelanalysis_schema.py create mode 100644 tools/tests/test_units_js.py create mode 100644 tools/ticket_delta.py create mode 100644 tools/vision_read.py create mode 100755 tools/webshot.py create mode 100644 tools/wpf_smoke.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eeac259 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Line endings. Issue #18: on a Windows clone with core.autocrlf=true the +# served static files came out of the checkout with CRLF, while the test that +# proves /app.js is PA's single copy read the same file in text mode (which +# folds CRLF to LF) -- so the bytes the server sent and the bytes the test +# expected differed on every Windows clone and on no Linux one. +# +# Everything text is normalised to LF in the repository (the index already +# was: `git ls-files --eol` showed 381 i/lf and 21 i/-text the day this was +# added, so `text=auto` renormalised nothing). The files pa_web / ba_web hand +# to a browser byte-for-byte are pinned to LF in the WORKING TREE too, so a +# served asset is the same bytes on every machine. +* text=auto + +tools/pa_web/static/** text eol=lf +tools/ba_web/static/** text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd80201 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install + # requirements.txt is the single source of truth for host-side deps. + # PyYAML fixes collection (ticketpage._read_yaml falls back to it off + # IronPython; without it eight test files die before running). pymupdf + # is also required, or test_libindex.py's 77 tests silently SKIP via + # importorskip instead of running -- a green run that tested less. + run: pip install -r requirements.txt + + - name: panellift tests + run: python -m pytest panellift -q -p no:cacheprovider + + - name: vibe tests (the Revit-free half of the extension) + run: python -m pytest WillTools.extension -q -p no:cacheprovider + + - name: Syntax smoke (extension + tools + panellift) + run: python -m compileall -q WillTools.extension tools panellift/src + + - name: Scrub worktree with the committed generic sample terms + # Real client names never leave the local machine: .scrub-terms is + # gitignored, so CI runs the generic .scrub-terms.example instead. + run: python tools/scrub.py --worktree --terms .scrub-terms.example diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dea3001 --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# local Revit/bridge state +.bridge-sessions.log +# one per port: "the last request on this bridge never came back", written by +# tools/rvt.py so the next call drains the channel before it trusts an answer. +.bridge-timeout-*.json +_ticketqc/ + +# build output +dist/ +build/ +*.egg-info/ + +# python +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ + +# editors / local tool settings +.vscode/ +.idea/ +.claude/settings.local.json + +# scratch +scratch/*.py +!scratch/scratch.py +# _scratch is where the agents work: probe scripts, bridge responses, run logs. +# It is not the same directory as scratch/ above, and the rule there never +# covered it -- 745 untracked files accumulated in it carrying client names, +# job names and absolute user paths, one `git add -A` from the history. Work +# that is meant to be kept gets moved OUT of here into its real home. +_scratch/ +# Real exported tickets, kept locally to check against. They carry the job +# name and the client's name in the title block, and scrub.py cannot read a +# PDF -- the gate would pass them straight through. Never commit them. +sample data/ +*.pdf +*.log + +# client-name denylist, must never be committed +.scrub-terms + +# QNA: 约定进仓库,问答内容不进 +QNA/* +!QNA/README.md + +# The knowledge folder is the OTHER repository's territory. It sits inside this +# working tree for convenience during development and is tracked by KNOWLEDGE, +# never by this one -- that split is the whole reason there are two repos: this +# one can be shown to anybody, that one cannot. +# +# catalogue.yaml is the exception and is deliberately tracked here: it is the +# public layer, published manufacturer data, and it says so in its own header. +knowledge/plant/ +knowledge/job/ +knowledge/_archive/ +# ...and the SYMLINK spellings: on the host machine these three are links into +# the private KNOWLEDGE checkout (git matches a trailing-slash rule against +# directories only, so the link itself would show up untracked -- same lesson +# as knowledge/library below, 2026-09-05). +knowledge/plant +knowledge/job +knowledge/_archive + +# knowledge/library/ is the PDF drop and its ledger -- manuals, manufacturer +# catalogues, datasheets, plus the record of what has been mined out of each +# and where every fact landed. Same territory as the three lines above and +# excluded for the same reason: it is KNOWLEDGE's, never this one's. +# +# The PDFs themselves are already caught by `*.pdf`, and that rule exists +# because scrub.py cannot read a PDF -- a title block carrying the job and the +# client goes straight through the gate. The ledger and the extraction notes +# beside it are worse, not better: quoting the source is their whole job, so +# a name in them is certain rather than possible. The FOLDER is what is +# excluded, not the extension, so a note, an index or a scratch transcript +# dropped in there is covered the day it is written and not the day somebody +# remembers to add a rule for it. `scrub.py --private` is what reads it. +# Both spellings on purpose: with the slash git matches only a real directory, +# and on this machine `knowledge/library` is a SYMLINK to the private repo's +# checkout -- the link itself showed up untracked (2026-09-05) and one `git add` +# would have committed a path into the owner's home directory. +knowledge/library/ +knowledge/library + +# knowledge/MC/ is a second producer's document drop, same territory as +# library/ above and excluded whole-folder for the same reason: the files +# carry the producer's name in their own filenames, which *.pdf alone does +# not cover (there is a spreadsheet in there today). +knowledge/MC/ + +# machine-local install choices (tools/setup_machine.py); .setup-opts.example is the tracked template +.setup-opts + +# CPython virtualenv on the Debian dev box +.venv/ +.gitea-issue-handler.lock diff --git a/.scrub-terms.example b/.scrub-terms.example new file mode 100644 index 0000000..3ae01c4 --- /dev/null +++ b/.scrub-terms.example @@ -0,0 +1,30 @@ +# Terms that must never appear in the repo, one per line. Case-insensitive. +# +# This is the GENERIC sample file, committed so CI can run the scrub without +# ever seeing a real client name. It is also the copy-you-fill-in for local +# use: copy it to .scrub-terms (gitignored) and REPLACE the sample parameters +# below with your real client / job / plant names. +# +# .scrub-terms is gitignored on purpose: the real list of owner/job names must +# not itself become the thing that publishes them. +# +# List anything that identifies an owner or a job: client name, building +# owner, project name, site address, job number, plant name, Windows +# usernames that appear in scraped output. +# +# When you need to refer to one in code or a comment, write a placeholder +# instead of deleting the sentence (e.g. a client -> sample_client, a job -> +# sample_job, a plant -> sample_plant). Different real clients change only the +# parameters below -- one file per parallel job, never hard-coded in code. +# +# Product codes are not client information and stay as they are: design +# numbers (WPA2.0), piece marks (WPA170), and hardware part numbers +# (LPA4T512) are naming conventions, not identities. + +# ---- GENERIC SAMPLE TERMS (CI-safe). Replace in local .scrub-terms. ---- +SAMPLE_CLIENT_MOTORS +SAMPLE_JOB_PROJECT_2026 +SAMPLE_PLANT_WEST +SAMPLE_USER_ABC123 +CLIENT1_SAMPLE +CLIENT2_SAMPLE diff --git a/.setup-opts.example b/.setup-opts.example new file mode 100644 index 0000000..e41459a --- /dev/null +++ b/.setup-opts.example @@ -0,0 +1,31 @@ +# tools/setup_machine.py opt-file -- TEMPLATE, not read. +# +# This is `.setup-opts.example`. Only `.setup-opts` (no `.example`) is ever +# read, and setup_machine.py never creates that file itself -- declaring an +# install choice is the owner's to make, not a default this script picks for +# you. Copy this file to `.setup-opts` beside the repo root and edit it if +# you actually want to declare one. +# +# FORMAT -- one `: ` per line. `#` comments and blank lines are +# ignored. A step name this script does not know, an unstated value +# (`panellift:` with nothing after the colon), or a mode a known step does +# not accept are all reported as FAILURES by `--check` and by a full run -- +# an opt file only stays trustworthy if a line nobody is honouring any more +# is loud, not quiet. +# +# THE ONLY STEP OPT-ABLE TODAY: +# +# panellift: dev-mode +# +# Meaning: "panellift is not `pip install -e`d on purpose, while the package +# is still being shaped. PYTHONPATH=src is the supported route on this +# checkout, not a workaround." With this declared: +# - `python tools/setup_machine.py --check` reports the installed-CLI step +# SKIP ("dev mode, per .setup-opts") instead of a FAIL that never clears. +# - `python tools/setup_machine.py` (full run) skips `pip install -e +# panellift` and instead runs `PYTHONPATH=src python -m panellift --help` +# to prove the route this checkout actually uses still works, and says so. +# +# Delete the line (or the whole file) once panellift is pip-installed for +# real -- then the ordinary "installed, no PYTHONPATH" check verifies that +# directly again, which is the stronger proof. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3ca00d9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +**Read [CLAUDE.md](CLAUDE.md) first and treat it as binding.** It is written +for whichever agent is driving this repo, not for one vendor: the live-Revit +bridge, the IronPython 2.7 rules, the unit conventions, and the rules that +exist because breaking them killed Revit. Nothing in it is restated here, +because two copies of a hard rule drift and the copy that drifts is the one +that takes the model down. + +Then read [docs/handoff.md](docs/handoff.md) for the state of the work: what +is done, what is knowingly approximate, and what to pick up first. + +## Harness notes + +Instruction files are loaded by the harness's `agent-instructions` plugin, +which reads `AGENTS.md` and `CLAUDE.md` in each directory from the project +root down. Two files with the same content collapse to one; two with +different content both load. That is why this file is a pointer and not a +copy — it costs a few lines and leaves CLAUDE.md as the single source. + +**The plugin ships disabled in the default `web` profile.** If neither file +appears in the session's context, enable it in +`~/.dsh/profiles/web/cordis.patch.yml`: + +```yaml +- id: agent-instructions + disabled: false +``` + +Check what is actually composed with `dsh --profile web --dump-config`. An +agent working here without CLAUDE.md loaded will reload pyRevit sooner or +later, and that ends the Revit session. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b574e35 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,899 @@ +# pyrevit-dev — you are wired to a live Revit session + +You were launched from a button inside Revit. **Revit is running right now**, +with a model open, and you can read and modify it directly. Use that — do not +write Revit code you have not run. + +Engine: **IronPython 2.7.12**. The Revit and pyRevit versions differ between +the machines this is developed on (2025.4 / pyRevit 6.5.3 on one, 2024.3 / +pyRevit 5.0.1 on the other), so ask rather than assume — `rvt.py status` +answers it — and guard any API name that moved between versions. Two already +known: `BrowserOrganization.GetFolderFields()` does not exist on the older +binding, and `Dimension.Curve` throws on drafting views. + +--- + +## 1. Talking to the running Revit + +```bash +python tools/rvt.py status # which model is open +python tools/rvt.py exec -c "print(doc.Title)" # run a line inside Revit +python tools/rvt.py exec somefile.py # run a file inside Revit +python tools/rvt.py exec somefile.py --tx "名字" # wrap in a transaction +python tools/rvt.py sel # what the user has selected +python tools/rvt.py collect OST_Walls --limit 20 +python tools/rvt.py params 123456 # every parameter of one element +``` + +`exec` returns `{ok, stdout, result, error, traceback}` — real stack traces, so +iterate until it works. Snippets get `doc`, `uidoc`, `uiapp`, `view`, `DB`, +`UI`, `vibe`, and the common Revit types pre-imported. Assign to `result` (or +make the snippet a single expression) to send a value back. + +If `status` cannot connect: Revit is closed, or a **modal dialog** is open in +Revit — handlers run in Revit's API context and cannot fire behind a dialog. +Say so rather than guessing. + +### Every answer is checked against the request that asked for it + +Since 2026-09-01 `rvt.py` stamps every request with an id (in the JSON body, +GET routes included — request headers never reach handler code either), +`startup.py` echoes it back, and an answer carrying a **different** id is +refused outright: both ids named, nothing returned. The reason is that pyRevit +serves every route through **one shared response slot** — +`routes/server/server.py`'s module-level `REQUEST_HNDLR` / `EVENT_HNDLR`, whose +single `_response`/`_done` pair every connection thread resets, waits on and +reads — so a client-side timeout cancels nothing, and the abandoned request's +late answer is handed to whoever asks next. Measured that day, twice: one sweep +call timed out at 300s, and the `doc.Title` calls after it came back with the +sweep's progress string, which the sweep printed as the document title and an +agent read as the model having changed underneath it. The files on disk were +right; only the attribution was wrong, and **a response from the wrong request +is indistinguishable from a valid answer**. So a timeout is now an explicit +could-not-judge (exit 1, `COULD NOT JUDGE`, no value) that latches the channel +in `.bridge-timeout-.json`; the next call drains the stale answer out of +that slot with a self-identifying probe before it sends anything real, and +refuses to send at all if the channel cannot be proven clean. An in-Revit half +that echoes **no** id — which is every session until Revit is restarted after +this lands — is warned about on stderr and flagged `_unverified` in the body +rather than refused, because a restart is the only way to load the new half and +refusing would take the bridge down for every tool in the meantime; pass +`--strict` (or set `WILLTOOLS_BRIDGE_STRICT=1`) to refuse instead. + +### The bridge can now REFUSE, and a refusal is not a fault + +Since 2026-09-01 every route — `/status` included — refuses outright when the +open document's title is on `bridge.refuse_titles` in the job or plant layer. +That is the owner's *不要碰我的live* written as code. The answer comes back +`{"ok": false, "refused": true, "doc_title": ..., "pid": ..., "error": "REFUSED: …"}` +and it means **you reached the live session, not the sandbox**. Do not retry +it, do not work around it, and do not go looking for another way in. + +**The owner reversed that policy the same day, for the model it protects.** +A title on `bridge.readonly_titles` instead is served normally through the +bridge — including `/exec` — but only for reads: the iron rule is *铁律是在 +live里面只读不写*, and `/exec` refuses outright when the request carries a +transaction or the snippet matches known write-capable Revit API surface, +coming back the same `refused: true` shape as above with a one-line reason +naming the title and the rule, never a silent no-op. + +Why it can happen when you did nothing wrong: pyRevit walks the routes port +**up** from the configured one past every Revit already holding one, so 48884 +is "whichever Revit started first" rather than the session you meant. With two +sessions open the sandbox is usually on **48885** — and its `doc.Title` is +identical to the live model's, because the snapshot deliberately keeps the +title so the job folder does not move. `rvt.py` reads `WILLTOOLS_BRIDGE_PORT`; +set it, or let `tools/snapshot_cycle.py` find the right session for you. The +whole mechanism, including how a snapshot proves it is a snapshot, is in +`docs/snapshot-cycle.md`. + +### Never reload pyRevit. Restart Revit instead. + +This is the single most important rule here, and it is not a style preference — +it kills Revit outright. + +pyRevit's routes request handler is an external event handler owned by the +scripting engine that first loaded it. `reload_pyrevit()` tears that engine +down but leaves the handler and the HTTP server pointing at it. **The next +bridge request after any reload executes against a dead engine and takes Revit +down** — an unhandled exception on a non-main thread, which Revit cannot catch. +Measured on 2026-08-14: 11 clean requests, one reload, the 12th request fatal. +It happened twice. + +Nothing inside Revit can defend against this — our code never gets to run. So: + +- There is no `/reload` endpoint, and `rvt.py reload` refuses. +- `startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load. +- `rvt.py` reads that log before **every** request and hard-refuses if it sees + two loads under the same Revit process. + +If `rvt.py` tells you the bridge is unsafe, do not work around it, do not curl +the endpoint directly. Ask the user to **restart Revit**. A restart picks up new +buttons exactly as well as a reload does, and a fresh process is safe. + +If `rvt.py` warns that there is no session log while Revit is clearly running, +the tripwire itself is broken — say so and do not reload. + +--- + +## 2. Layout + +``` +pyrevit-dev/ +├── CLAUDE.md ← you are here +├── INSTALL.md deploying to another machine / Revit version +├── tools/rvt.py client for the live bridge +├── tools/package.py builds dist/willtools--.zip +├── tools/sweep.py check every WP assembly in the open model +├── tools/snapshot_cycle.py make a timestamped detached copy, cycle a +│ Revit onto it, run a batch, close it +├── docs/snapshot-cycle.md …and why the detach is not done in-session +├── docs/revit-api-cheatsheet.md working Revit 2025 patterns — read when stuck +├── docs/interference.md why the clash check is shaped the way it is +├── docs/lift-check.md the handling check, and what it will not answer +├── docs/handoff.md open engineering questions, the last sweep, +│ what to pick up first +├── knowledge/catalogue.yaml published hardware ratings (ships with the tool) +├── panellift/ PCI Ch.8 handling checks — CPython, own suite +│ ├── src/panellift/cli.py `python -m panellift job.json -o report.html` +│ └── src/panellift/layout.py where the inserts should go instead +└── WillTools.extension/ + ├── startup.py registers the bridge (no UI) + ├── lib/vibe/ + │ ├── helpers.py q() p() set_p() tx() ft() mm() selected() ... + │ ├── compat.py text_type / string_types — py2+py3 shim + │ ├── designspec.py evidence scrape, spec schema, spec io + │ ├── aiextract.py the AI reader — NOT the extractor any more + │ ├── knowledge.py knowledge.yaml → defaults, family rules + │ ├── configdump.py all five layers flattened into one file + │ ├── configmodel.py what may be edited, its provenance, the splice + │ ├── liftexport.py assembly → panellift job file, and runs it + │ ├── ticketqc.py reads tickets, the comparison checks + │ ├── ticketchecks.py the checks that need arithmetic + │ └── ticketqc_run.py the shared engine behind TicketQC and + │ ReinforcingQC (a `mode` parameter apart) + └── WillTools.tab/ the ribbon — "Folder names are the UI" + │ below is the whole of how it is built + ├── Tools.panel/ Modeling.pulldown/ — ReinforceSuggest, + │ ReinforcingQC, TicketQC — plus + │ SnapshotClose as the one remaining large + │ icon (reorganised 2026-09-05; this panel + │ used to hold all four as large icons) + ├── Setup.panel/ Config.pulldown/ — one large button with a + │ dropdown arrow: ProjectConfig, + │ ExtractDesign, SelfCheck, JobRecon, + │ Takeoff + ├── Design.panel/ DesignHub.pulldown/ — PanelLiftCheck and + │ PanelAnalysis (both finished, both grouped + │ here on the owner's own instruction), then + │ BeamAnalysis, ConnectionAnalysis, + │ GravityLoadChase and LateralExtract, all + │ four unfinished and titled "(WIP)" + └── Lab.panel/ GodMod — the button that launched you. +``` + +**`aiextract` no longer extracts anything.** Design Extract was rewritten to +read the scrape in-process with `ticketqc`'s regex/geometry parser — its own +header says "no AI call, no subprocess, nothing that leaves this process" — +and it imports `designspec`, `ticketqc`, `knowledge` and `specsave`, not this. +The module is kept, and its only live callers are `find_claude()`: Self Check +probes with it to say whether the CLI is on this machine, and the GOD_MOD +button uses it to find the CLI it launches. The prompt, the `claude -p` spawn +and the cross-check are all still there and nothing calls them. Spec files +written before the rewrite can still carry `source.extractor: 'opus'` (or the +older `'claude-code'`); the mechanical reader stamps `'regex'`. Nothing +branches on which of the three it finds — the field is displayed, in Design +Extract's own spec table, and never acted on — so anything touching it has to +tolerate all three. Do not describe this module as the extractor. + +**`panellift` is not importable from Revit and that is deliberate.** It is +CPython — dataclasses, typing syntax — and the extension is IronPython 2.7. +The two talk through a JSON job file, so the engineering can be tested without +a licence, a model, or Windows. `vibe.liftexport` is the whole Revit side of +that boundary: it measures the piece, writes the job, shells out to CPython, +and reads the summary back. Anything about *mechanics* belongs on the far +side of that line; anything about *this model* belongs on this side. + +Most of `vibe` does not touch Revit at module scope either — only at the top +of individual functions that need a live `doc` (`knowledge.load()`, +`liftexport.export()`, and anything that reaches for `vibe.designspec` / +`vibe.ticketqc`, which import the Revit API directly). That leaves real logic +testable with no model and no licence, the same way `panellift` is. +`WillTools.extension/tests/` is 34 files covering about thirty of the `vibe` +modules — `ticketpage`, `knowledge`, the host-side half of `ticketchecks`, the +`configmodel` / `configform` / `configpage` set, the `reinforce*` and +`takeoff*` sets, the part of `liftexport.py` below `export()`, and more — run +straight on host CPython: + +```bash +cd WillTools.extension && python -m pytest +python -m pytest # from the repo root: this suite + panellift +``` + +**Run it; do not quote a count from here.** The count in this paragraph has +been wrong every time anybody has checked it — the suite grows daily, and on +2026-08-31 the root total moved twice in one afternoon while several agents +were working. For scale only, and only that: it was roughly 1,800 under +`WillTools.extension` and roughly 2,100 from the repo root that day. The root +run also sweeps `_scratch/`, which is gitignored agent working space, so its +total moves for reasons that have nothing to do with the tool. + +**One test fails on a configured machine and it is the test that is wrong.** +`tests/test_reinforceoffline.py::test_no_index_and_no_jobs_root_says_all_three_ways_to_give_one` +calls `resolve_index(None, None, None)` expecting the "no jobs root" refusal, +but `resolve_index` falls back to the ambient `WILLTOOLS_JOBS`, so on any +machine that actually has the tool set up it gets the "which model?" refusal +instead. It passes under `env -u WILLTOOLS_JOBS`. Nothing in the tool is +broken; the test needs to clear the variable. Measured 2026-08-31, not yet +fixed — so a green suite here is everything passing **except that one**, and +one failure is not automatically your change. + +`WillTools.extension/tests/conftest.py` is how: it loads a module by path +instead of `import vibe.whatever`, because `vibe/__init__.py` pulls in the +Revit API the moment it runs. Read the docstring there before adding a test +for a new module — it explains exactly which shape of code this can and +cannot reach, and `ticketqc.py` / `ticketchecks.py` are the standing example +of code that cannot: they import `Autodesk.Revit.DB` at module scope, not +just inside a function, so freeing them needs more than moving an import. + +Ticket-QC data lives beside the **model**, not here: +`/_ticketqc/{evidence,specs,log}/` + `knowledge.yaml`. + +Knowledge is read in **five layers**, each overriding the one before: + +| | | +| --- | --- | +| `knowledge.DEFAULTS` | what the code assumes — in this repo | +| `knowledge/catalogue.yaml` | published hardware ratings — in this repo, identifies nobody | +| `%APPDATA%\WillTools\knowledge.yaml` | what this **plant** always does — naming conventions, casting-bed rails | +| `/_ticketqc/knowledge.yaml` | what this **job** does — parameter names, tolerances, per-job overrides | +| the **project config file** | what the engineer set, wherever they put it — opt-in, and it wins | + +Only the first two are in the repository, and that is the point: between them +the other three are a full description of one office's template. Set +`WILLTOOLS_KNOWLEDGE` to put the plant layer on a share. +`knowledge.sources(doc)` lists the layers; Self Check prints them. + +**Where the layers get their facts from: `knowledge/library/`.** It is a drop +folder for source PDFs — manuals, manufacturer catalogues, datasheets — plus a +ledger with one row per document, keyed by content hash, recording what was +extracted from it and **which file and key path each fact landed in**. The +folder is gitignored as a folder (not merely by `*.pdf`), because the ledger +and the extraction notes quote their sources verbatim and `scrub.py` cannot +read a PDF; like `knowledge/plant/` it is the other repository's territory, and +`scrub.py --private` is what reads it. **Idle capacity drains it by +`knowledge/library/RUNBOOK.md`** — inventory, extract with a page cite and a +confidence mark on every fact, **land**, verify, report. The landing step is +the point and it is the one that gets skipped: a 929-page manual was once mined +into ten notes and a 25-fact audit of which almost nothing became YAML, and the +owner ruled on it — *"提取出来以后没有写入。今天不要再犯这个错误"*. A document +read but not landed is a debt, not progress, and the ledger says so in the +status column. `python tools/library.py` lists the folder against the ledger. + +**Extraction notes are clues, never sources — a number lands only off the +page itself.** Measured 2026-08-31: a cheap-model extraction pass over one +document drop carried a **42% page-error rate** on independently sampled pages +— inverted inequalities, misidentified parts, specs blurred into vagueness — +and the landing pass then caught three more errors the audit itself had +missed, including a one-row table shift and a column read under the wrong +heading. Three stages, none may collapse into another: + +1. **Extract** with a page cite on every fact (cheap model is fine — speed is + what it buys). +2. **Audit independently** — a different agent samples pages against the note. + A sub checking its own family's work reported zero errors on the same + material where the independent auditor found the 42%; self-audit is not + audit. +3. **Land off the page** — any NUMBER moving from a note into a YAML layer is + re-read against the rendered source page by the landing agent, cited + `# SOURCE: p.N, verified `. A number that cannot be confirmed + lands as `null` with the reason and an open question — never as the note's + value, never as a plausible reconstruction. + +**Then run `python tools/knowledgelint.py`.** Landing is not finished until it +passes. pyRevit's YAML reader is strict — one mapping key stated twice raises +`Duplicate key` and it returns *nothing* — and a layer that fails to load looks +exactly like a layer that agrees with the defaults. Measured 2026-09-01: one +nested duplicate in a plant file discarded that whole 255KB layer inside Revit, +every parameter role came back None, the sweep enumerated 0 assemblies, and +every report rendered normally. The lint strict-parses every layer on this +machine and exits 1 on any that will not load; `knowledge.config_problems()` +and Self Check now say so too, but only after the file has already been +written. + +Where a table carries its own internal identity (a recommended value derived +from two measured columns, a series that must be monotonic), assert the +identity across the whole table at landing — it is the cheapest error +detector there is, and it is what turned "one value off by one" into "the +whole block is shifted a row". + +**`knowledge.load()` reads the job file twice, and that is not a bug.** The +job layer holds `job.plant`, which names *which plant folder* the plant layer +is read from — but the plant layer merges **before** the job layer, so by the +time the name is in the merged tree the plant file has already been read from +the wrong path. The dependency runs backwards through the merge order and no +reordering fixes it. So `plant_id()` reads the job file (and the config file +above it, which can override the same key) once on its own, before anything +merges, purely to answer "which plant" — then the ordinary five-layer merge +runs. **Do not collapse this into one pass.** Deleting the pre-pass makes the +tool read the machine-wide plant file while the job file plainly says which +plant it is for, and every report still looks completely normal. +`_plant_layer_stale` is the tripwire for exactly that, comparing the plant the +file was chosen by against the plant the merged tree ends up naming. Same +discipline as `_config_missing`: a named plant whose file is absent sets +`_plant_missing` and never falls back to the machine-wide file. + +The last layer is what the **Project Config** button writes. A model has one +only once somebody has pressed that button, and the file goes wherever they +choose — so a one-line pointer at `/_ticketqc/active-config.txt` records +where it is. It is deliberately last: it is the layer a human edited most +recently and by hand. + +Two rules around it, both learned the hard way in other tools: + +- A pointer to a file that is **gone** must never fall back quietly. The run + would use a configuration nobody chose and look completely normal. + `load()` marks it `_config_missing` and `knowledge.config_problems(doc, know)` + turns that into a sentence a button can refuse on. +- The file opens with `decisions:` — every setting that is the engineer's to + make, gathered from wherever it is really read and put above everything + else, each under its own comment. Where the reports go, whether they open, + the prestress loss, the transfer length, and the per-stage rigging mode. + `knowledge._merge_decisions` maps every one of them back on load. + **A file where editing the obvious place does nothing is a trap**, so that + mapping has to exist in both directions or the section is decoration. + +**Write for both Pythons.** pyRevit picks the engine and this extension ships +to machines whose engine we do not control, so on top of the IronPython 2.7 +rules below: no `unicode` / `basestring` (use `vibe.compat`), and guard any +Revit API name that moved between versions. `python tools/package.py` and +`INSTALL.md` cover the rest. + +Folder names are the UI: `Foo.tab / Bar.panel / Baz.pushbutton / script.py`, +plus `Bar.panel / Thing.pulldown / Baz.pushbutton` for a pulldown. Copy +`GodMod.pushbutton` as a structural template (not its contents). + +`*.stack` also exists and this tab used two of them until 2026-08-30. Both are +gone: a stack is up to three *small* buttons in one panel slot, and the owner +wanted the set-up commands off the panel entirely rather than shrunk on it. +Prefer a `.pulldown` — one large icon plus an arrow, unlimited rows, and the +rows are free to carry readable titles because a row is text beside a 16px +icon rather than a ribbon slot. + +**Which name renders, and from where** — this is not uniform and guessing it +wastes a Revit restart: + +| | | +| --- | --- | +| tab | the **folder** name. `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)`; a `title:` in a tab bundle is ignored. | +| panel | the **folder** name, same reason (`create_ribbon_panel(panel.name)`). Rename the folder to rename the panel — spaces are fine, pyRevit ships panels with them. | +| stack | nothing renders. The folder name is only what the panel's `layout:` refers to it by. | +| pulldown | `bundle.yaml`'s **`title:`** — unlike a stack, a pulldown container really does read one. `genericcomps.GenericUIComponent._read_bundle_metadata` sets `_ui_title` from it and `uimaker._produce_ui_pulldown` passes that to `create_pulldown_button`. With no `title:` it falls back to the folder name. | +| button | `bundle.yaml`'s **`title:`**, and nothing else. `__title__` in the script is the fallback pyRevit reads only `if not self.meta`, i.e. only for a button with no `bundle.yaml` at all — keep it in step anyway, or the old name comes back the day a bundle file is trimmed. | + +**Order is `layout:` in the container's own `bundle.yaml`**, a list of child +folder names (minus their `.panel` / `.stack` / `.pushbutton` suffix), read by +`genericcomps.GenericUIContainer.parse_layout_metadata`. The old `_layout` +file is gone in pyRevit 5.0.1 — do not write one, nothing reads it. Two things +about that list are worth knowing before it bites: + +- **A child not listed is not created.** Not misordered — absent. Add a + button, add it to the layout, or it silently never appears. +- `"---"` inserts a separator and `">>>"` a **slideout** (everything after it + hides behind the arrow at the panel's bottom-right). Both must be quoted — + a bare `>` starts a folded block scalar and the panel fails to parse — and + neither may be the last entry, because `__iter__` only honours a marker + whose index is below the last one. + +**A `.pulldown` needs its own `icon.png` or it renders blank.** A pulldown is a +component like any other — `genericcomps` resolves an `icon.png` for it, and +`_produce_ui_pulldown` hands that to `create_pulldown_button`. When there +isn't one, `ribbon._create_button_group` catches the icon error and only +`mlogger.debug`s it, so you get an empty ribbon slot and no message anywhere. +`python tools/make_icons.py` draws both of the tab's pulldown icons; run it +and **read its output**, because it prints "skipped, no such component" for a +path that has moved rather than failing. + +**Unfinished tools never sit on a panel with finished ones.** Standing owner +instruction, 2026-08-30. `Design.panel` is where they go — a panel of its own +whose single button is the `DesignHub` pulldown, so nothing half-built is on +screen until somebody opens it; the `(WIP)` suffix on the title is what names +them once they do. (Until 2026-08-30 this was the slideout on `Lab.panel`. +Same rule, better door: a slideout arrow is easy to miss and shares its panel +with a working button, which is the thing the rule is against.) `DesignHub` +also holds two FINISHED rows today, PanelLiftCheck and PanelAnalysis — the +owner grouped them here on 2026-09-05 because he wants the analysis tools +together, not because either stopped working; see that pulldown's own bundle +comment. + +A button that is finished but not wanted on screen used to move to +`WillTools.extension/parked/`, outside every `*.tab`. **That folder is gone** +as of 2026-08-30 — JobRecon and Takeoff, its only residents, came back onto +the ribbon inside `Config.pulldown`. A pulldown row costs no panel space, so +there is now somewhere on the ribbon to put a button nobody wants underfoot, +and parking has no job left. Do not recreate `parked/` without asking; if a +button really must leave the ribbon, taking it out of the parent's `layout:` +already stops it being built. + +Titles are **short and two lines**: a large ribbon button is as wide as its +widest line, which is how "Reinforcing Suggestion" came to dwarf everything +next to it. `title: "Ticket\nQC"` — double quotes, because YAML only honours +`\n` inside them. Keep each line under ~9 characters. Rows inside a pulldown +are the opposite case — text beside a 16px icon, in a dropdown that grows to +fit its longest row — so those stay **single-line and may say what they +mean** (`Project Config`, `Design Extract`). A `\n` in a pulldown row has +nowhere to go. + +**No button carries hover text.** The owner asked for it twice, so it is now +a test, not a convention: `bundle.yaml` gets no `tooltip:` and no `author:`, +and `script.py` gets **no module docstring at all**. Document a button in `#` +comments under the coding cookie instead — pyRevit cannot render those, and +nothing is lost. `tests/test_button_tooltips.py` fails and names the button +if any of the three comes back. The trap it exists for: pyRevit reads the +docstring only `if not self.meta` (no `bundle.yaml`), so a docstring can sit +harmless for months and become hover text the day a bundle file is trimmed +or a button ships without one. + +pyRevit still appends `Bundle Name:\n (pushbutton)` to every tooltip in +`uimaker._make_button_tooltip`, with no setting to suppress it. Those two +lines are the floor; do not try to beat them by editing the pyRevit install. + +**Editing an existing `script.py` needs no reload** — pyRevit re-reads it on +every click, so most iteration costs nothing. **Adding or renaming** a button +needs a **Revit restart** (see the rule above — not a pyRevit reload). + +--- + +## 3. Hard rules + +**Dry-run and self-test before the owner touches it.** Owner, 2026-09-02: +"以后写完代码先测试,做dry-run,能修的bug修完以后再让我来测试." Finishing a +change is not "the code is written" -- it is "I ran it, on a real input, +saw no garbage, and fixed every bug I could before handing it over." For a +tool that reads the live model, that means: export a real assembly to a +deck through the bridge (read-only), run the offline solver on it +(`panellift`), read the output, and fix what breaks -- BEFORE asking the +owner to press the button. His time is the last check, not the first. A +handoff that says "try it" without a clean dry-run behind it wastes the one +reviewer who cannot be replaced. This sits beside "report outcomes +faithfully": a dry-run that still errors is reported as such, not smoothed +over. + + +**A green host suite is not a live dry-run, and a clean merge is not one +either.** Added 2026-09-03, after Panel Analysis reached the owner broken twice +in a day. The form passed all 127 `panelanalysis` host tests and pre-filled +*nothing* live: it read mark and f'c only through plant `knowledge` roles, and +on that job the plant mapped those roles to nothing, so every field came up +blank, +the header said "unnamed panel", and the project / client / date fields the +owner expected did not exist at all. Host tests check the schema; they cannot +see that a form reads no real value from a real model. So anything that READS A +MODEL or renders a FORM is dry-run **through the live bridge, read-only**, with +the actual values it recovered confirmed present, before it is handed over -- +the button press is the owner's last check, never the first. The same holds for +code that arrives by MERGE from the other machine: a merge that applied cleanly +is UNVERIFIED, not validated. The `\xe2` crash and the blank form both shipped +in merged front-end code and both first failed when the owner ran them -- run +every gate AND a live dry-run on merged code exactly as on your own. +**A WPF button gets a bridge smoke-import before it reaches the owner.** The +host cannot import `System.Windows` at all, so a wrong WPF namespace is +invisible to every host gate -- 2026-09-03, `StretchDirection` imported from +`System.Windows.Media` (it lives in `System.Windows.Controls`) and the button +died at import the first time the owner clicked it, the day after the rule +above was written. The bridge runs IronPython INSIDE Revit, so the check is +cheap: exec the button's `from System.Windows...` import block through it +(`python tools/wpf_smoke.py `) and require zero import errors. +Owner, the same day: "写的东西测试过了再给我,别老让我报错." A button handed +over without that run is a button the owner is testing for you. + +**A failure class that is invisible on the host earns a guard test.** The Panel +Analysis crash on 2026-09-03 was a non-ASCII byte in an IronPython 2.7 file with +no `# -*- coding: utf-8 -*-` cookie: a hard `SyntaxError` inside Revit, and +completely silent on host CPython 3, which defaults to UTF-8. A bug that only +bites in the engine we cannot run here, and passes every host check, recurs +unless a host test reproduces its shape. `tests/test_coding_cookie.py` now walks +every extension `.py` and fails on a non-ASCII byte with no cookie, the way +`test_button_tooltips.py` guards hover text. When a class of failure is +invisible on the host but real in Revit, add the guard rather than trusting the +next person to remember. + +**Reading a model must be GENERIC -- geometry and built-ins first, a mapped role +only as an accelerator.** Owner, 2026-09-03: "很多的预填充应该是通用的代码,避免 +换一个活儿就得重来." A pre-fill or scrape keyed to one job's parameter names is +rework waiting for the next job. Dimensions and weight come from GEOMETRY +(bounding box, solid volume) -- job-independent, the floor. Names come from +Revit BUILT-INS (`Mark`; ProjectInformation's Project Name / Client Name). +Strengths and the like, when the plant role is unmapped, fall back to +case-insensitive parameter-NAME PATTERN matching (f'c: "f'c" / "fc" / "28 day"; +release: "fci" / "release"; unit weight: "density" / "pcf"), never one job's +literal name. The plant `knowledge` role is the accelerator where it exists, +never the only path. This is "no client/job information in the repo" seen from +the code side, and the same law as the five knowledge layers: switching to +another job must pre-fill out of the box with no edit. + +**Delegation is EXPLORE (me) -> WRITE (DSV4F, to a settled spec) -> REVIEW (me), +and the tooling is what makes it safe.** DSV4F's code quality is not the risk -- +on 2026-09-03 its `tools/rvt.py` work was correct and passed review plus 16 new +tests; every DSV4F failure that day was INFRASTRUCTURE, and each has a standing +fix. (a) The dsh headless CLI times out and the dsh sandbox denies pytest's +`tmp_path`, so GATES RUN OUTSIDE the sandbox: I run pytest / scrub on my side, +DSV4F only writes. (b) A dsh sub that stalls or hits an approval prompt does not +report back, so a dispatched dsh sub is WATCHED with `tools/dsh_watch.py +` in the background -- its exit is the wake signal, instead of the +owner having to tell me it stopped. And do NOT `fork` for execution work: a fork +inherits the conversation and on 2026-09-03 continued in reply-to-the-owner mode +with zero tool calls instead of doing the job. For "go build / change X", spawn +a FRESH agent with an imperative, self-contained brief -- the JUDGEMENT about +what to build is still mine; the reading that informs it goes to a Sonnet +sub-agent, not to me and not to DSV4F (PROJECT.md §3.9). And SPLIT the settled spec before it reaches DSV4F (owner, +2026-09-03: "确认了边界的活儿,拆细了再让 DS 干"): one bounded piece per task +-- a function, a section, one file's change -- each sized to finish inside a +SINGLE reply. A whole-feature spec handed to DSV4F in one turn truncates at +max-tokens (the PanelAnalysis write stopped after the easy schema edits, +`stopReason: length`), which reads as "it skipped the hard parts" when it +simply ran out of output. Exploration never goes to DSV4F -- but it does not +stay with the expensive coordinator either: it goes to a Sonnet sub-agent +(owner, 2026-09-05: "探索你带着sub sonnet…DSV4F别做探索。DSV4F就干点儿体力活儿"). +DSV4F gets labour only: extraction, mining, translation, bounded pre-split +writes. PROJECT.md §3.9 carries the incident, the `cmd -p` Command Code route +when the DeepSeek platform balance is empty, and the `systemd-run --user` rule +for any worker that outlives a turn. + + +**IronPython 2.7 syntax** for everything under `WillTools.extension/` and every +`exec` snippet: + +- No f-strings. Use `'{0}'.format(x)`. +- `print('a:', x)` prints a **tuple** `('a:', 5)`. Build one string instead. +- No `pathlib`, `dataclasses`, typing syntax, or walrus. +- Only `tools/rvt.py` runs on host CPython 3.11 — modern syntax is fine there. + +**Any change to the model needs a Transaction.** Reading never does. + +```python +from vibe import tx +with tx('Name the user will see in Undo'): + ... +``` + +No nested plain `Transaction` (use `SubTransaction` / `TransactionGroup`). + +**Internal units are decimal FEET.** Always — areas in square feet, angles in +radians. Convert at the boundary with `vibe.ft(mm)` / `vibe.mm(feet)`. Use +`UnitTypeId.*`; `DisplayUnitType` was removed in Revit 2022+. + +**`ElementId` in 2024+**: `.IntegerValue` is deprecated for `.Value`. Read it +defensively (`try IntegerValue / except .Value`) as the existing code does. + +**Don't block Revit's UI thread.** No sleeps or polling loops inside handlers. + +**"Could not judge" and "judged, found nothing" are different answers, and +nothing here may collapse them.** This is the discipline the whole tool is +built on, and it shows up in three places that each look like a local detail: + +- **A knowledge lookup that cannot resolve returns `None`, never `False`.** + `knowledge.same_face()` returns `None` when either token is an abbreviation + it does not know — because "I cannot read that" and "those are different + faces" are opposite answers, and `False` would assert the second. + `rebar_stock()` carries `basis = None` for "nothing knows a stock length" + alongside `'by_size'` and `'generic'`, and `stocked` is a separate `None` + again. `rebar_fy()` returns `basis = None` and `fy = None` when no grade is + defensible — A615 names no strength, so its `default_fy_ksi` is deliberately + null. Each of them also returns a `why` sentence: standing down without + saying why is the same failure one step later. Follow this for any new + lookup. +- **An unrecognised state word renders red, never grey.** + `ticketpage.state_color()` matches GREEN / YELLOW / GREY states by name and + everything else falls through to red. That fallthrough is load-bearing, not + a default: it is what makes it safe to add a state like `MUTED` at all, + because a typo in a state word becomes loud instead of quietly grey. Grey is + reachable only through a word the table knows — a *declared* mute. Do not + "tidy" the fallthrough into grey or into an exception. +- **A check that returns nothing owes an explanation.** See §5 below; this is + the same rule, and it is the one a newcomer trips first. + +**Never run an unfiltered collector, and never guess at an API enum.** This +crashed Revit on 2026-08-18 — a single exploratory snippet that walked the +whole document with `FilteredElementCollector(doc).WhereElementIsNotElementType()`, +touched `.Symbol` on every result, *and* set `Options.DetailLevel` from a value +reached by `__import__('Autodesk').Revit.DB...`. Revit died with no exception +and no journal entry, because a bad value handed to a native API is not a +Python error. So: + +- Always scope a collector — `OfClass`, `OfCategory`, a view id, or just + `asm.GetMemberIds()`. This model has ~377 assemblies and six figures of + elements. +- Import enums by name (`from Autodesk.Revit.DB import ViewDetailLevel`). + If the import fails you get a traceback; if a bogus object reaches the + native layer you get a dead Revit. +- One narrow question per `exec`. Geometry extraction goes in its own request, + never bundled with a model-wide scan. + +**A contradiction hit mid-refactor is the owner's to settle, not yours.** A +restructure on 2026-08-31 (private repo `pyrevit-knowledge`, commit +`767281e`, "总表三层化、厂层收割落库") walked into `lifters.capacity_lb` in a +plant's `knowledge.yaml` and found an override the owner had entered by hand — +`P76 = 16,000 lb`, the correct ALP with-tension-bar rating — disagreeing with +`knowledge/catalogue.yaml`'s published 7,690 lb for the same part. The +restructure emitted the key EMPTY and wrote a new comment reading "Direct +override, checked BEFORE the catalogue. One-offs only," phrased as though the +key had always been blank. `lifter_capacity()` checks that override before the +catalogue, so every export after that quietly fell back to 7,690. Five real +panels (WP242, WP283, WP318, WP322, WP326) each carried a `FAIL / PLUMB` +finding — 8,660 lb each of a 34,638 lb piece against a 7,690 lb tension limit, +113% — before the owner asked on 2026-09-01 where 7,690 came from, and +answering that took a git-history dig across two repositories. The sibling key +in the same block, `lifters.min_edge_distance_in: 9`, survived the identical +restructure untouched, which is what makes the dropped key a mistake and not a +policy. Deleting the losing side of a contradiction, silently preferring the +"published" value, and writing a comment that makes the surviving value look +uncontested are all the same move: each retires the question instead of +asking it. When a refactor meets a contradiction, stop and surface it to him +instead. + +**No client or job information in the repo. Ever.** + +Not in code, not in comments, not in docstrings, not in a commit message, not +in an example. When you need to refer to one, write the placeholder rather +than deleting the sentence: + +| | | +| --- | --- | +| a client / owner | `sample_client` | +| a job / project / model | `sample_job` | + +Product codes are **not** client information and stay as they are: design +numbers (`PNL1.0`), piece marks (`PNL101`), hardware part numbers +(`ANCHOR_FACE_4T`) are naming conventions, not identities. + +`python tools/scrub.py` checks the working tree **and the whole git history** +against `.scrub-terms` (gitignored, so the list of names is not itself the +leak) plus structural patterns — model filenames, absolute user paths, email +addresses. It runs as a pre-commit hook and inside `tools/package.py`. + +The working-tree scan covers **untracked files too**. A scratch script nobody +has added is exactly where a model path gets pasted, and it is one `git add -A` +from being in the commit. `--worktree` is the audit and a hit anywhere fails; +`--staged` is what the hook runs, where only staged files block and untracked +hits are printed as a warning — a gate that blocks work unrelated to the commit +is a gate that gets passed with `--no-verify`. + +**But every scan above honours `.gitignore`, so none of them has ever opened +`QNA/`.** Nor `knowledge/plant/`, `knowledge/job/` or `_scratch/`. That is the +largest body of prose in the checkout and the only part of it whose job is to +quote source material verbatim, so a name is not merely possible there, it is +certain. `--private` is the mode that looks at it, and it is **an inventory, +not a gate: it always exits 0** and the hook never runs it. It asks a +different question, because "does this contain a name" is answered yes by +design — it asks whether a name is sitting in a shape that **travels**: inside +a fenced block, a proposed `key: value`, a quoted literal, a path, or glued +into a longer identifier (`_EMBED_P76`), which is the shape a reader +skims past. Those are aggregated by the distinct string and listed, worst +first; a name in a sentence, or in a value in a plant file, is the file doing +its job and comes back as a number only. Read a row as "has this exact string +already been pasted into a tracked file" and check it with `git grep`. It +exits 0 because it has no verdict of its own: every string it prints carries a +term or a structural pattern, so a copy into a tracked file is already a +`--worktree` failure. What it finds is the raw material a leak gets made out +of, weeks earlier — and a check that failed on that would fail every run, +which is a check somebody wraps in `|| true`. + +Editing a file does not remove a name from the commit that introduced it, and +`git push` sends commits, not the checkout. Before anything is pushed the +history can still be rewritten; afterwards it cannot. + +**The open model is the user's real work.** Before anything that deletes, +renames in bulk, purges, or touches a saved model: prototype read-only, show +counts and a sample, and get an explicit go-ahead. Never use `--tx` to "just +try" a mutation on a model you have not checked with the user. + +--- + +## 4. Idioms + +```python +from vibe import q, p, set_p, tx, selected, ft, mm, out, table +``` + +| Instead of | Write | +| --- | --- | +| `FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_Walls).WhereElementIsNotElementType().ToElements()` | `q('OST_Walls')` | +| `StorageType` branching | `p(el, 'Comments')` | +| manual Transaction start/commit/rollback | `with tx('name'):` | +| `uidoc.Selection.GetElementIds()` + `GetElement` | `selected()` | + +Signatures: `WillTools.extension/lib/vibe/helpers.py`. +API patterns: `docs/revit-api-cheatsheet.md`. + +**Before touching anything that measures geometry, read +`docs/interference.md`.** Two working-looking versions of the clash check +reported no interference anywhere because a cylinder's edges are circles whose +end points coincide; a third was two thirds false positives because a bent +bar's bounding box is mostly the hole in the middle. + +Inside a button script, use pyRevit forms rather than raw WPF: `forms.alert`, +`forms.SelectFromList.show`, `forms.ask_for_string`, `forms.select_levels`, +and `script.get_output()` for `print_md` / `print_table` / `linkify`. + +--- + +## 5. Building a new tool for the user + +1. Prototype with `rvt.py exec` against the live model until it is correct. +2. Write it into `WillTools.extension/WillTools.tab/.panel/.pushbutton/` + as `script.py` + `bundle.yaml` (+ optional 96×96 `icon.png`). +3. **Add `` to its parent container's `bundle.yaml` `layout:` list** — + the panel's, or the pulldown's if it is a row inside one. A child that + is not listed is not created — the button will simply not be there, and + nothing anywhere will say why. +4. A tool that is not finished goes behind the `Design.panel` pulldown with + `(WIP)` in its title, never onto a panel beside the working tools. +5. Ask the user to **restart Revit** (never "pyRevit → Reload"), then try it. + +Do not declare a tool finished before step 1 has actually run clean. + +### Adding a check to Ticket QC — a new check owes a skip probe + +This is the rule most likely to bite you, because adding a check is the first +thing anyone does in `ticketchecks.py` and the failure is silent at write time +and loud on the page. + +Write the check, add it to `CHECKS`, and it runs. But a check returns a list of +`Finding`s, and **an empty return is ambiguous** — it can mean *ran and found +nothing wrong* or *never ran, for want of data*. `run_all` cannot tell, so it +does not guess: + +| what happened | what `run_all` logs | what `checklist_states` shows | +| --- | --- | --- | +| returned findings | `RAN` | the verdict | +| raised | `FAILED` | NOT RUN, plus a `CHECK-ERROR` warning naming the check | +| empty, probe returns a reason | `SKIPPED` | NOT RUN, *with that reason* | +| empty, probe returns `None` | `RAN` | OK — ran, found nothing | +| empty, **no probe registered** | `RAN-EMPTY` | **NOT RUN, "no probe declared"** | + +So a check with no probe that ever legitimately finds nothing renders as a red +NOT RUN row for a piece that is fine. That is deliberate — silence that looks +like "fine" is the exact defect the checklist exists to surface — but it means +**a new check with no probe is a defect you have just shipped**, not a check +that works. + +And **a checklist row may name more than one check.** It is answered only when +every one of them spoke; any quiet half makes the whole row NOT RUN and the +note carries each quiet half's own reason. Under-claiming is the safe +direction, because a green row is a claim — a row that asks two questions and +prints OK because the half that could still fail it was never asked is exactly +the silent pass the table exists to prevent. + +Register one beside the check: + +```python +@_register_skip_probe('check_your_thing') +def _probe_your_thing(t, intent, know, spec): + if not t.get('the_input_it_needs'): + return 'no on this piece -- could not be judged' + return None # None means: it ran, and found nothing. Never ''. +``` + +The probe's return is the *reason*, and it is printed. `None` is the +"everything was there, it just found nothing" answer — do not return an empty +string for that, and do not write a probe that can never return a reason. + +This is now test-enforced rather than a count somebody has to keep re-auditing: +`tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe` +walks `CHECKS` and fails, naming names, the day a new check ships without a +probe registered for it in `_SKIP_PROBES` — the same failure the 2026-08-31 +backlog of seven bare `CHECKS` entries used to sit as silently. Run the test +rather than trusting a number here; a count copied into this paragraph is +exactly the kind of fact this file has gone stale on before. + +### Tests must not write into the working directory + +`vibe.batchrun.make_worker` takes an `io` for exactly this reason. A test that +actually **calls** the worker must pass `io=RecorderIO()`; the sibling tests +all do, and the one that did not created a file literally named `s` — twice, +once at the repo root and once under `WillTools.extension/` — which reached a +commit (`68adc27`) before anyone noticed and had to be deleted in `9504c05`. +Anything that only inspects the worker's code object can pass the real one, +because nothing runs. + +**This is a convention with nothing enforcing it.** There is no fixture and no +guard; `pytest` will happily let a test write anywhere. Use `tmp_path` or a +recorder, and be aware that the next stray file is one `git add -A` from the +history — which is the same reason `tools/scrub.py --worktree` scans untracked +files. A guard would be better than this paragraph; that is a separate job and +nobody has asked for it yet. + +--- + +## 6. When stuck + +- `rvt.py params ` beats guessing a parameter name. It and `/collect` send + their arguments as a JSON body, not a query string, because pyRevit 5.0.1's + routes `Request` exposes no parsed query string under any name. **Confirmed + live 2026-09-01 on Revit 2024.3 / pyRevit 5.0.1: both endpoints return + `ok: true` over the JSON body.** Getting that confirmation took two tries, + and the lesson is worth more than the fix: **`status` returning ok is not + proof of a fresh engine.** A second Revit can hold the bridge port while + serving code older than the file on disk, and the right model open plus a + session-log line dated today both look exactly like a restart. Compare + `System.Diagnostics.Process.GetCurrentProcess().StartTime` (through `/exec`) + against `startup.py`'s mtime before believing new code is loaded. +- **Two things `rvt.py` can now say about an answer, and neither is a fault to + work around.** *"REFUSING this answer - it belongs to a different request"* is + the shared-response-slot desync of §1 caught in the act; the channel is + latched and the next call drains it, so retry rather than reaching for curl. + *"this answer could not be attributed"* means the in-Revit half is older than + the client and echoes no id — a **Revit restart** (never a reload) is what + fixes it, and until then any answer taken shortly after a timeout is + unattributed, which is exactly how a sweep's progress string once got printed + as a document title. +- `rvt.py exec -c "print([m for m in dir(el) if 'Type' in m])"` — reflection on + the live object beats searching docs. +- pyRevit's library is readable source at + `%APPDATA%\pyRevit-Master\pyrevitlib\pyrevit\` — grep it. +- · + +--- + +## 7. Search the transcripts before you ask the owner + +**`PROJECT.md` §3.8 is the rule; this is how to run it.** Before you put a +question to him, search what he has already said. He has answered far more +than any document records, and asking twice gets you a second ruling that does +not match the first. + +The transcripts are `~/.claude/projects//*.jsonl` on the +machine the conversation happened on. **The owner's machine's transcripts +travel in the private KNOWLEDGE repo under `sessions/`** (the Discord agent's +host has them at `~/projects/KNOWLEDGE/sessions/**/*.jsonl`), so a search on a +different machine must include that tree or it is not a search of what he +said (2026-09-05: two issues asked him questions answered there days before). +Locally they are `~/.claude/projects//*.jsonl` — +newline-delimited JSON, **one record per line**, no enclosing array, so read +them line by line and never `json.load()` the file. His messages are the +records with `type == "user"`; `message.content` is either a plain string or a +list of blocks, where the text lives in the blocks with `type == "text"`. Skip +what is not his: a `type: "user"` record can also carry a tool result, and the +long "This session is being continued…" blocks are a **summary written by an +agent, not by him** — quote one as his words and you have invented a ruling. + +**The encoding trap.** The files are UTF-8 and Windows stdout is cp1252, so +printing a Chinese message raises `UnicodeEncodeError` and the search looks +like it crashed rather than like it found something. `PYTHONIOENCODING=utf-8` +is the fix, and it belongs on every one of these commands: + +```bash +PYTHONIOENCODING=utf-8 python -c " +import json,glob,os,sys +q=sys.argv[1] +for f in glob.glob(os.path.expanduser('~/.claude/projects/*/*.jsonl')): + for n,l in enumerate(open(f,encoding='utf-8'),1): + if q not in l: continue + r=json.loads(l) + if r.get('type')!='user': continue + c=r['message']['content'] + if not isinstance(c,str): c=' '.join(p.get('text','') for p in c if isinstance(p,dict)) + if q in c: print(os.path.basename(f)[:8],n,r.get('timestamp',''),c[:300],'\n') +" "7.5sqrt" +``` + +That exact command is what found the ruling the rule was written for. **It +reads all 65 MB in 0.2 seconds**, so run it three or four times with different +terms rather than once — and search **Chinese as well as English**, because he +writes in both and often mixes them inside one sentence. Search the value +(`7.5`), the concept (`应力极限`), the part number, the parameter name. Prefer +a `git log -S` / `git grep` pass too: a ruling that already landed is usually +quoted in the comment beside the value. + +Two ways to get this wrong, and the second is the expensive one: + +- **A hit is not automatically the answer.** Check the timestamp and read the + surrounding lines. Two rulings months apart may be about different questions + — one about *what the number is*, one about *where the setting lives* — and + neither retracts the other. §3.8 has the case. +- **No hit is not proof he never said it.** These are one machine's history, + unindexed and bilingual, and the word you searched is very often not the word + he used. Report ***"I searched `X`, `Y`, `Z` — nothing"***, listing the terms, + and never *"he has never ruled on this"*. Then ask him. Asking is not the + failure; asking **first** is. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e6dea9e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing + +Thanks for trying this out. Short version: run the tests, never paste real +job data anywhere, and expect review on anything that changes a verdict. + +## Running the tests + +Two independent suites, both plain CPython, no Revit and no licence needed: + +```bash +python -m pytest panellift -q # PCI Chapter 8 handling checks +python -m pytest WillTools.extension -q # the Revit-free half of the extension +``` + +Both are self-contained -- fixtures build their own data, nothing reads a +real job. `python -m compileall WillTools.extension tools panellift/src` is +a fast syntax smoke test across all three, including the IronPython-targeted +code under `WillTools.extension/`, which only runs for real inside Revit. + +`python tools/setup_machine.py --check` is the doctor. Run it before filing +an issue and paste its output in full -- see the bug report template. + +### What does not run in a trial install + +`knowledge/catalogue.yaml` ships here, but as a carve-out -- read its own +header for the exact list of what it carries (trade-standard vocabulary, +the shop-ticket checklist skeleton) and what it deliberately omits (the +owner's real manufacturer capacity ratings). A small number of tests exist +specifically to pin THAT real data -- "does the catalogue publish 4:1 in +shear for QLPD4T5WG", not "does the tool's arithmetic work" -- and they are +tests of the owner's knowledge, not of this tool. Faking them with +invented numbers would just be that same real data through a back door, so +instead each suite's `tests/conftest.py` carries an appended block (written +by `tools/make_dist.py`'s `append_test_exclusions()`) that SKIPS them by +name, with that reason, rather than letting them fail: + +| suite | tests skipped | why | +| --- | --- | --- | +| `WillTools.extension` | 6, in `tests/test_knowledge.py` | read `catalogue.inserts` directly, assert a real ALP insert's factor/spacing, or a real HK Composites tie's edge distance | +| `panellift` | 1 parametrized function (7 cases), in `tests/test_safety_factors.py` | its own docstring already calls it "a golden-value check against a page, not a recomputation" | + +Both blocks **auto-detect**: they check whether `catalogue.inserts` in the +`knowledge/catalogue.yaml` the test run actually finds is non-empty, and +stand down with no edit needed the day someone runs these suites against +the owner's real knowledge layers. Run `python -m pytest -rs` (either +suite) to see every skip and its reason printed, rather than trusting a +count copied into this paragraph. + +## No client or job information in the repo. Ever. + +Not in an issue, not in a PR description, not in a commit, not in a +screenshot, not in a test fixture. If you need to refer to one, use the +placeholder convention this repository already uses everywhere — write the +placeholder rather than deleting the sentence: + +| | | +| --- | --- | +| a client / owner | `sample_client` | +| a job / project / model | `sample_job` | + +Product codes are not client information and stay as they are: design +numbers (`PNL1.0`), piece marks (`PNL101`), and hardware part numbers +(`ANCHOR_FACE_4T`) are naming conventions, not identities. + + + +`python tools/scrub.py --worktree --terms .scrub-terms.example` is the same +check CI runs, and you can install it as a pre-commit hook on your own +clone: + +```bash +python tools/scrub.py --install-hook +``` + +It runs on the generic sample terms here (there is no real `.scrub-terms` +file in this distribution) plus structural patterns -- an absolute Windows +user path, a `.rvt`/`.rfa` filename, an email address -- which catch a leak +whether or not it happens to be on anybody's list yet. + +## Opening a PR + +- Keep it focused. A PR that fixes one thing is reviewed faster than one + that fixes three. +- If it changes what a check reports (a verdict, a finding, a threshold), + say so explicitly in the description and expect the owner to review it + personally -- these tools produce numbers a real plant floor may act on, + and "the tests still pass" is necessary but not sufficient for that class + of change. +- If it's a new tool or button, read `docs/architecture-migration.md` and + the `plan-*.md` docs first for the shape this project is moving toward. + +## Filing an issue instead + +See the two issue templates -- **Bug report** for something that crashed or +gave a wrong answer, **Question** for something that was unclear or a +refusal you think should have been an answer. The README's "Filing feedback" +section has more on the difference between those two, and it matters: a +refusal is very often the tool working correctly. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..dc84778 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,325 @@ +# Installing WillTools + +Precast engineering tooling: buttons across four ribbon panels — three +modelling/ticketing tools plus Snapshot behind **Tools**' `Modeling` +pulldown, five set-up commands behind the **Config** pulldown, two finished +and four unfinished analysis tools behind **DesignHub**, and GOD_MOD — plus a +live bridge that lets Claude Code drive a running Revit session. `README.md` +has the table of what each one does. + +Developed and exercised on **Revit 2025.4 · pyRevit 6.5.3 · IronPython 2.7.12**. +Everything else in this file is about what to check when one of those differs. + +--- + +## 1. Prerequisites + +| | Needed for | Notes | +| --- | --- | --- | +| **Revit** 2021 or newer | everything | see [§6](#6-a-different-revit-version) before assuming a version works | +| **pyRevit** 4.8 or newer | everything | | +| **Python 3.9+** on the host | `tools/*.py`, `panellift` | not needed to use the ribbon buttons themselves | +| **Claude Code CLI** | GOD_MOD button only | optional — every engineering button runs without it | + +**No button that does engineering work needs the CLI, the network or the +bridge.** Design Extract reads a design packet in-process with a +regex/geometry parser and never leaves Revit. GOD_MOD is the only button +that launches the CLI, and it is the developer button. + +### The Windows Store Python trap + +If `python` at a terminal prints something like *"Python was not found; run +without arguments to install from the Microsoft Store"* and then exits +non-zero, that is not really Python — it is a stub Windows ships in +`WindowsApps` that only exists to point you at the Store. Every command in +this file, and `tools/setup_machine.py` itself, will fail in confusing ways +if that stub is what `python` resolves to. + +Fix: install Python from (check "Add +python.exe to PATH" during setup), then open a **new** terminal and confirm +with `python --version` — it should print a real version number, not a +Store prompt. If both a real install and the Store stub are on `PATH`, the +Store one usually wins unless you reorder `PATH` or use the `py` launcher +instead (`py -3 tools/setup_machine.py`). + +## 2. Install + +1. **Clone or unzip anywhere you can write to.** A checkout under your user + folder is easiest. The path is not hardcoded anywhere; the buttons find + the folder relative to themselves. + + ```bash + git clone https://github.com/will123song/pyrevit-dev.git + cd pyrevit-dev + ``` + +2. **Run the setup script.** It installs the host-side Python dependencies + (`requirements.txt`: PyYAML, pytest, pymupdf) and then *proves* the + install rather than just reporting exit codes — it runs `panellift` + against a published worked example and checks the computed stress + against the number the article prints. + + ```bash + python tools/setup_machine.py + ``` + + Read the output top to bottom. `PASS` and `SKIP` lines need nothing from + you; a `SKIP` is a deliberate non-answer (this distribution ships no + knowledge layers, so the checks that need one stand down cleanly — see + `knowledge/README.md`), not a problem. A `FAIL` line names what broke and + what to try — fix the first one and re-run; failures often cascade from + one root cause. Re-run any time with `--check` to just re-verify without + changing anything: + + ```bash + python tools/setup_machine.py --check + ``` + +3. **Register the folder with pyRevit.** pyRevit tab → *Settings* → + *Custom Extension Directories* → add the folder that **contains** + `WillTools.extension` (the cloned folder itself, not the `.extension` + folder inside it) → *Save Settings*. + + Equivalent to this in `%APPDATA%\pyRevit\pyRevit_config.ini`: + + ```ini + [core] + userextensions = ["C:\\path\\to\\pyrevit-dev"] + ``` + +4. **Turn on the routes server** — only if you want the Claude Code bridge + (GOD_MOD). pyRevit → *Settings* → *Routes* → enable, port `48884`. Or: + + ```ini + [routes] + enabled = true + port = 48884 + host = "127.0.0.1" + ``` + +5. **Restart Revit.** Not "pyRevit → Reload" — see [§7](#7-the-one-rule). + +A **WillTools** tab appears with four panels, left to right: + +| Panel | | +| --- | --- | +| **Tools** | one button, **Modeling**, with a dropdown arrow (Reinforce Suggestion, Reinforcing QC, Ticket QC), plus **Snapshot** as a large button beside it. | +| **Setup** | one button, **Config**, with a dropdown arrow: Project Config, Design Extract, Self Check, Job Recon, Takeoff. | +| **Design** | one button, **DesignHub**, with a dropdown arrow: Panel Lift Check and Panel Analysis (finished), then Beam Analysis, Connection Analysis, Gravity and Lateral, marked `(WIP)`. | +| **Lab** | **GOD_MOD**, the CLI button. | + +## 3. Check the install + +Open a model and press **Self Check**. It probes every assumption these tools +make — Revit version, pyRevit version, Python engine, routes, library imports, +unit conversion, whether the CLI is findable, where the data folder resolved +to, whether a published checklist is available, and whether this model +actually follows the naming conventions the checks depend on — and prints +each as OK / WARN / FAIL. + +Read the FAILs top-down; they cascade. A WARN never blocks a run, and on a +knowledge-less install several rows are *expected* to read WARN — that is +the "checks stand down" state, not a broken install. + +## 4. Try it with no Revit at all + +```bash +python -m panellift examples/example-panel.job.json -o report.html +``` + +That runs the PCI Chapter 8 handling check against a published worked +example (the ASPIRE Winter 2019 article) and writes an HTML report. It needs +no Revit, no model, and no knowledge layer — a good first thing to run right +after `tools/setup_machine.py` to confirm the arithmetic reaches you intact. + +## 5. Using it on a real job + +Per model, in order: + +1. **Extract Design** — pick the design summaries, one extraction terminal + opens per design, each writes `specs/.json`. +2. **Read the spec.** It is JSON on purpose. Fix anything wrong and fill in + `source.reviewed_by`. Nothing downstream questions the spec again. +3. **Ticket QC** — checks every piece of those designs against the spec. + **Needs a knowledge layer with the published checklist** — see below; + without one it refuses to open with a one-line reason. +4. Edit the `knowledge.yaml` files as you learn the job — see the layers + below for which one. They are re-read on every run — **no restart needed + for knowledge or spec edits.** + +Everything lands in `_ticketqc/` **beside the model file**: + +``` +/_ticketqc/ + evidence/.json raw scrape, no interpretation + specs/.json the reviewable spec <-- read this + knowledge.yaml your hand-written rules + log/.txt what the extractor printed +``` + +If the model is not saved, or its folder is read-only, it falls back to +`%APPDATA%\pyrevit-ticketqc\\`. Self Check tells you which. + +### The knowledge layers, and why this distribution has none of them + +Knowledge is not one file. It is read in layers, each overriding the one +before it key by key, and the split is by **how long the fact stays true**: + +| layer | where | what belongs there | +| --- | --- | --- | +| defaults | `lib/vibe/knowledge.py` — in this repo | what the code assumes when nobody has said | +| catalogue | `knowledge/catalogue.yaml` — **not shipped** | published hardware ratings, plus the master shop-ticket checklist | +| **plant** | `%APPDATA%\WillTools\knowledge.yaml` — **not shipped** | naming conventions, casting-bed rails, **parameter names**, which catalogue item each part number is, rigging mode and dunnage position | +| **job** | `/_ticketqc/knowledge.yaml` — **not shipped** | tolerances, and anything where this job departs from the plant | +| **project config** | wherever you put it — see below | the whole configuration, pinned to one job | + +This distribution ships **none of the last four**, deliberately — see +[`knowledge/README.md`](knowledge/README.md) and +[`knowledge/catalogue.yaml.template`](knowledge/catalogue.yaml.template) for +the shape of what a real layer looks like, with every value blanked out. +Everything on **Tools** and **Setup** except Ticket QC runs without one, +reading only `knowledge.DEFAULTS`; Self Check reports every plant-specific +parameter role as unmapped until real layers are wired in. **Ask the owner** +for real layers when you have a job to run for real — see the README's +"What to ask the owner for." + +Copying a job file forward to the next job should be nearly empty work — if +you find yourself copying a lot, it belongs in the plant layer. Set +`WILLTOOLS_KNOWLEDGE` to a full path to put the plant layer on a share so a +whole office reads one file. (`tools/setup_machine.py` deliberately leaves +this variable unset on a knowledge-less install — see its own comments if +you are wiring one in by hand rather than through the script.) + +Handling decisions are plant knowledge too: `handling.stages[].rig_mode` +(`rolling` or `static`, blank inherits the global mode) and +`handling.dunnage.mode` (`lifter_rows` or `fraction`, with `fraction` such as +`0.2` for supports at 0.2L from each end). A plant that has not set dunnage +still gets transportation as a lift, and the report labels it as approximate. + +Self Check prints which layers it found, and lists any parameter role still +unmapped. + +### Pinning a configuration to a job + +The **Project Config** button writes every layer above, merged, into one file +wherever you choose — the job folder on the share, usually, next to the rest +of the job's paperwork. From then on the model runs on that file, and it is +the last word: edit it and the next run uses what you wrote. + +A one-line pointer at `/_ticketqc/active-config.txt` records +where the file went. Press the button again to refresh the file from the +layers underneath, or to drop back to them. + +Two things to know before you use it: + +- **It freezes the plant layer.** The merged copy in the file contains the + plant's settings as values of its own, so while the file is live, fixing + something in the plant file no longer reaches this job. That is the point of + pinning, and it is also how a plant-wide correction quietly fails to arrive. +- **Do not move or delete the file without clearing the pointer.** Every + button refuses to run rather than falling back, because a run on the layered + files when you meant the pinned ones looks entirely normal and is not the + configuration you set. + +A new model has no pointer, so it starts from the layered files and gets its +own configuration the first time the button is pressed there. + +### Nothing is hard-coded, including public facts + +The obvious things stay out of the code: client names, job names, parameter +names. Less obviously, **so do facts that are perfectly public.** A lifting +insert's rated working load is printed in the manufacturer's catalogue and is +no secret — but written into a `.py` file it becomes a number nobody can +correct without a release, and it will be wrong eventually. + +So a rating is a lookup, and it takes two hops, because a plant models its own +part number rather than the manufacturer's designation: + +``` +family name in the model + -> lifters.catalogue_alias plant layer: P000 -> SAMPLE_ANCHOR_4T + -> catalogue.inserts product data: SAMPLE_ANCHOR_4T -> 8000 lb +``` + +Each hop fails differently and is fixed in a different place, so the check +says which one is missing rather than reporting a bare "unknown". The same +shape applies to anything else with a published number behind a local name. + +## 6. A different Revit version + +The code is written against the stable parts of the API and guards the two +places Autodesk moved the floor: + +- **`UnitTypeId`** (Revit 2021+) with a `DisplayUnitType` fallback for 2020 and + older, resolved once at import so an old host cannot break the whole library. +- **`ElementId.IntegerValue`** → **`.Value`** (Revit 2024+), read defensively + at every site. + +Also version-proofed: the `/exec` snippet namespace skips API types that a +given release does not have, rather than failing to register. + +**Not verified anywhere but 2025.** If you move to 2023 or 2026, press Self +Check first — it reports the host version as a WARN and exercises the imports +and a unit round-trip, which is where a version problem shows up first. + +### A different Python engine + +pyRevit chooses the interpreter. This extension is written in the intersection +of Python 2 and 3 — no f-strings, no `print` statement, no implicit integer +division, no `pathlib`, and `unicode`/`basestring` resolved in +`lib/vibe/compat.py` — so IronPython 2.7, IronPython 3 and CPython 3 should all +load it. Only IronPython 2.7 has actually been run in anger; Self Check warns +on anything else. + +## 7. The one rule + +**Never use pyRevit → Reload. Restart Revit.** + +pyRevit's routes handler is owned by the engine that first loaded it. A reload +tears that engine down and leaves the handler pointing at the corpse; the next +bridge request runs against a dead engine and takes Revit down with it — +an unhandled exception off the main thread, which Revit cannot catch. + +There is no reload endpoint, `rvt.py` refuses to run after it sees two loads in +one Revit process, and a restart picks up new buttons just as well. + +Restart Revit after: installing, adding or renaming a button, or adding an icon. +No restart needed after: editing a `script.py`, a spec, or `knowledge.yaml`. + +## 8. What is in this distribution + +``` +pyrevit-dev/ +├── INSTALL.md this file +├── README.md what the tool is, what needs knowledge +├── CONTRIBUTING.md tests, the no-client-data rule, PRs +├── examples/example-panel.job.json a published worked example, try this first +├── docs/ reference material and design docs +├── knowledge/README.md why this folder is otherwise empty +├── knowledge/catalogue.yaml.template the shape of the layer that isn't here +├── tools/setup_machine.py run this first +├── tools/rvt.py bridge client (host CPython 3) +├── panellift/ the handling-check engine, its own test suite +└── WillTools.extension/ + ├── startup.py registers the bridge routes + ├── lib/vibe/ helpers, compat, designspec, knowledge, + │ ticketqc, ticketchecks, aiextract + └── WillTools.tab/ the ribbon, four panels left to right: + ├── Tools.panel/ Modeling.pulldown/ — Reinforce + │ Suggestion · Reinforcing QC · Ticket QC — + │ plus Snapshot as the one remaining large + │ button + ├── Setup.panel/ Config.pulldown/ — Project Config · + │ Design Extract · Self Check · Job Recon · + │ Takeoff + ├── Design.panel/ DesignHub.pulldown/ — Panel Lift Check · + │ Panel Analysis (finished, grouped here on + │ the owner's own instruction), then Beam + │ Analysis (WIP) · Connection Analysis (WIP) + │ · Gravity (WIP) · Lateral (WIP) + └── Lab.panel/ GOD_MOD +``` + +No model data or knowledge ships with it. `_ticketqc/` folders stay with +their models; knowledge layers come from the owner, on request — see the +README's "What to ask the owner for." diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..849d570 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,709 @@ +# WillTools + +This is the document to read before opening a file here. It assumes you know precast and +Python and nothing about this codebase. Four parts, in the order they matter: **what the +tool is**, **how it is built**, **the discipline**, **where everything stands**. + +Read part 3 properly. Parts 1 and 2 orient you; part 3 is what stops you doing damage. +Every rule in it was bought with a failure and carries the incident that bought it, +because a rule whose reason has been lost gets "simplified" away by the next reader. + +One standing convention, and it is the project's premise: **this repository does not state +things it has not checked.** Anything unverified is marked unverified; anything measured +says when. Where a number rots — line counts, test counts, timings — you get the command +instead, because those have each been wrong more than once in a single week. Where a +document and the code disagree, **the code wins**; §4.6 lists the disagreements known on +2026-08-30. + +--- + +## 1. What it is + +### 1.1 The product + +WillTools is one engineer's QC instrument, not a plant production system. The owner is +an **independent precast specialty consultant** — not the plant's employee — and on any +job his responsibility is **cladding and wall panels only**. Other engineers hold the +beams, slabs and columns, and the plant knowledge every check runs on was validated +against panels and nothing else. + +`lib/vibe/scope.py` exists because of that, and insists that **out of scope is a third +state** — not a pass, not a failure, not an absence. Every report names how much work it +left alone and of what kind. The scope is *declared* in the job knowledge layer +(`scope.mark_prefixes`), never inferred. The project already paid for the alternative: +one unauthorised exclusion hid forty tickets and every check that ran came back clean. + +That is the first face of the rule the whole tree is organised around — **silence is +never a pass**. Its full statement is §3.1, and it is the single most important idea +here. + +### 1.2 The ribbon + +One tab, four panels: **Tools**, **Setup**, **Design**, **Lab**. Folder names *are* the +UI, and `layout:` in a container's `bundle.yaml` decides what gets built at all — a child +not listed is absent, not misordered. No button carries hover text (no `tooltip:`, no +`author:`, no module docstring); `tests/test_button_tooltips.py` fails by name if one +returns. + +**Reinforcing Suggestion** asks which already-reinforced panels, *between them*, account +for every feature of this one — a set-cover jigsaw, not a similarity ranking — capped at +four donors, with the shortfall named rather than relaxed to a fifth. Search in +`reinforcematch`, index in `reinforceindex`/`reinforcecore`, join in `reinforcebind`, +wording in `reinforcereport`. **It opens no Transaction, by hard rule**: a wall panel's +middle vertical band is per-design engineering, and copying a donor's cage in +automatically would be the most dangerous thing this repo could do. An ordinary press +rebuilds only pieces whose cheap signature moved; shift-click forces a full rebuild, +because the signature cannot see an opening moved or resized in place. + +**Panel Lift Check** is a PCI Chapter 8 handling check over stripping, transportation and +erection. The piece is measured from the model, then **you click the inserts carrying each +stage, on the piece itself** — the rules supply highlighted defaults and no longer get the +last word. It refuses rather than guesses: fewer than two support lines, a stage naming an +insert the panel has not got, a stage with no multiplier and no `pci_case`, short/long +above 0.75 (two-way spanning, which a beam idealisation cannot represent). Multi-select +runs a background batch whose worker lives in `vibe/batchrun.py` and never in the button — +a thread reading the command module's scope after pyRevit tears it down raises on a +non-main thread and kills Revit. Batches skip the position search — 93% of a run, 12.6 s +of a 13.6 s median piece on the reference sweep. + +**Ticket QC** is the flagship. It checks every shop ticket of a design against the +extracted spec plus the knowledge layers: concrete strengths, jacking force, strand +count/grade/diameter/position, W.W.F., unit weight, embeds in both directions, bar marks, +note views, size envelope, interference. **27 check functions** in `ticketchecks.CHECKS` +feed **34 numbered checklist rows** in `ticketpage.CHECKLIST_ITEMS` — both counted +2026-08-30, both countable with `len()`. Every check is arithmetic; the one exception is +quarantined (§3.1). A row's status comes from `run_all`'s disposition log, never from an +absence of findings. And a ticket is not one sheet: it is the union of every sheet named +for the mark. + +**Snapshot & Close** is the daily close-out. The model is 300+ MB on disk and 10+ GB open, +so there is never a second copy in memory: phase A indexes the live document, phase B +closes Revit (the press *is* the authorisation), phase C optionally reopens it detached in +a deferred host process once the memory is free. The request is journalled before the close +and the deferred process journals its outcome, so a day it silently did not run is reported +in capitals on the next press. + +Behind the **Config** pulldown on the Setup panel: *Project Config*, *Design Extract* +(scrapes a design packet into a reviewable `spec.json`, with whatever the parser could not +place landing verbatim in `unresolved` rather than dropped), *Self Check*, *Job Recon* +(reconciles the issued ticket set against the whole job — the question no per-ticket check +can answer, because a ticket that is not there cannot be checked) and *Takeoff*. + +The **Design** panel is a quarantine: everything on it sits behind the `DesignHub` +pulldown, on a panel of its own, so nothing half-built ever sits beside a finished tool. +On the **Lab** panel, **GOD_MOD** opens a terminal running `claude` at the repo root with +default permissions. + +### 1.3 What a run produces + +**Ticket QC.** The checklist table *is* the page — first, unfolded, one per ticket that +has anything to say, in loudness order. `ticketpage.state_color`: OK green; WARN and FOR +ENGINEER yellow; **NOT RUN and FAIL both red**. An all-green ticket gets one line, not a +table. + +Everything else is a `foldreport.Section`, and the folding is a contract: *the answer is +the headline and is never folded; everything else starts closed; a section carrying a +problem opens itself, and its `problem` is a **sentence** naming what is wrong, not a +boolean; folded is not gone — a closed header still says what is inside and how much.* +The reader's side of that contract is **if nothing below the tables is open, nothing needs +me.** A problem section renders open as plain HTML needing no script, because pyRevit's +output panel is a WinForms `WebBrowser` in IE7 document mode where `
` does not +exist and fails *silently*. + +Three files land in `_ticketqc/` on every press: `last-ticketqc.txt` (the whole report, +nothing folded, long notes wrapped rather than cut, so a dead fold costs a file open and +never a sentence); `qc-run.json` (`findings` and `model_read` as two keys, never one list, +each entry carrying `source` and a boolean `computed`; muting never touches it, because a +muted row is a rendering decision and a muted check that failed still fails the ticket — +but it does not yet carry the run log, §4.4); and the `ticketqc/2` evidence file of §2.2 +(`qc-run.json` itself carries `ticketvision.RUN_SCHEMA = 'qc-run/3'` — read the constants, +both have moved since this paragraph was written). + +**Panel Lift Check** produces a self-contained HTML file with the `.job.json` beside it as +the complete input, re-runnable where there is no Revit: a stage table, an "Inserts picked" +table recording the one part of the run that came from a human, recommended moves in dx/dy +inches, and inline SVG free-body, shear, moment and stress plots that load **nothing +external**. Two habits in it are worth copying. A stage that was not analysed prints +*words*, never a dash, because a dash among numbers reads as a small number. And the +station tables under each plot thin ~2000 stations by stride but never thin what matters — +both ends, every opening edge, every pick or dunnage line, the peak and trough of V and M, +the worst compression, and the governing tension station, tinted and bold and saying so. + +Read Δx as "about here", not as a dimension, until §4.4's solver work is done. + +--- + +## 2. How it is built + +### 2.1 Two Pythons, and the line between them + +`WillTools.extension/` is **IronPython 2.7.12** on Revit's UI thread: no f-strings, no +`pathlib`, no `dataclasses`, no typing syntax. `panellift/` and `tools/` are **host +CPython** (`panellift` declares `>=3.9`). + +**`panellift` is deliberately unimportable from Revit** — it uses dataclasses and typing +syntax on purpose. A calculation whose output reaches a plant must be verifiable, and +verifiable means a suite that needs no licence, no model and no Windows. That is the +whole reason the line is where it is. + +`vibe/liftexport.py` is the entire Revit side of it: it measures the piece, writes the +job file, shells out with a timeout and tick callback (`find_python`, `run`), and reads +the summary back. **Mechanics live on the far side of that line; facts about *this +model* live on the near side.** + +### 2.2 The contract is the file, not the function + +Modules hand each other versioned JSON carrying `schema`, `written_at`, `written_by`, +`doc_key` and `knowledge_sources` — not objects. Four instances today: +`liftexport.SCHEMA = 'panellift/1'`, `reinforcecore.SCHEMA = 6`, +`specformat.SCHEMA_VERSION = 1`, and `ticketqc.SCHEMA = 'ticketqc/2'` (bumped from `/1` on +2026-08-31; read the constant, it will move again), written to +`/_ticketqc/evidence/run-.json` on every Ticket QC press. **Nothing reads +the last one yet** — that is step 2 of the migration (§4.5), and precisely why the writer +could ship alone. The four rules, from `docs/architecture-migration.md` §2: + +1. **Discard whole on mismatch.** `reinforcecore.load()` and `ticketqc.load_evidence()` + throw a stale file away entirely — never partially, never migrated. +2. **No live objects.** The plan named two holdouts; the build found four (`t['doc']`, + `t['legends']`, `t['sheets']`/`t['sheet']`, and live `XYZ` in `t['strand_points']` on + the no-framing early return) — so the serialiser gates on **type**, not on a list of + known offenders. +3. **Absence is not a value.** A gather that could not read something writes an explicit + `null` *and* a reason, through one `put()`. A merely missing key reads downstream as + "nothing to check" — the same failure mode `ticketchecks._SKIP_PROBES` exists to + prevent (§3.1). +4. **Provenance travels** — which layers were in force, which spec source (`spec` or + `regex`), which build. `intent` and `spec_source` ride with each ticket. Findings do + not: `qc-run.json` owns verdicts and the evidence file owns inputs, and step 2 needs + them apart so it can compare one against the other. + +### 2.3 The five knowledge layers + +Each layer overrides the one before it. `knowledge.load(doc)` merges them; +`knowledge.sources(doc)` lists them, and Self Check prints them. + +| layer | where it lives | +| --- | --- | +| `knowledge.DEFAULTS` | this repo — what the code assumes | +| `knowledge/catalogue.yaml` | this repo — published hardware ratings, identifies nobody | +| plant `knowledge.yaml` | `%APPDATA%\WillTools\` or `WILLTOOLS_KNOWLEDGE`, per-plant folder | +| job `knowledge.yaml` | `/_ticketqc/` | +| the project config file | wherever the engineer put it; `_ticketqc/active-config.txt` points at it | + +**Maps deep-merge, lists replace.** Two consequences bite: a list in the job file +*removes* the plant's entries rather than adding to them, and `_deep_update` simply +assigns, so **an override and a typo are indistinguishable today**. + +Two refusals guard the edges. A plant named in `job.plant` whose file is missing sets +`_plant_missing` rather than silently falling back to the machine-wide file; a pointer to +a vanished config sets `_config_missing`. `knowledge.config_problems()` turns either into +a sentence the buttons refuse on. + +Note the deliberate two-pass in `load()`: `job.plant` chooses the plant file but lives in +a layer that merges *after* it, so `plant_id()` reads the job and config files once, +alone, first. **Delete that pre-pass and the tool reads the wrong plant while every report +looks normal.** + +Which layer a fact belongs in is not a preference; it is an argument, and it is part of +the discipline — §3.2. + +### 2.4 The bridge + +`tools/rvt.py` is a host client for a pyRevit routes endpoint registered by `startup.py`. +`rvt.py exec` runs a snippet *inside* the live session and returns +`{ok, stdout, result, error, traceback}`. It is how anything is prototyped against a real +model, and it is the most dangerous thing in the repo. Its one absolute rule is §3.5. + +### 2.5 Two repos + +This repository can be shown to anybody. The other one cannot. `knowledge/plant/`, +`knowledge/job/` and `knowledge/_archive/` sit in this working tree for convenience and +are tracked by the **other** repository, never by this one; `.gitignore` says so at the +point of exclusion. `knowledge/catalogue.yaml` is the one deliberate exception, being +published manufacturer data, and it says so in its own header. The gate that keeps the +split honest is §3.4. + +--- + +## 3. The discipline + +### 3.1 The honesty invariants + +**NOT RUN is never a pass. Silence that looks like success is the cardinal sin.** NOT RUN +renders **red**, with FAIL, and `ticketpage.py` carries a comment forbidding the next +reader from greying it out: a check that did not run tells you nothing about the piece, so +it must never look calmer than one that failed. One unauthorised exclusion hid forty +tickets and every check that ran came back clean (`scope.py`, §1.1); seven `die()` calls +let refusals leave through a dialog, so a batch where every panel refused produced a blank +report. Every refusal now goes through `not_run()`. + +**`None` ("could not judge") is not `[]` ("judged, found nothing").** `ticketchecks.run_all` +disambiguates an empty return with a **skip probe**: `_SKIP_PROBES[check_name]` gives +either a reason or `None` (ran, found nothing). A check with *no* probe logs `RAN-EMPTY` +and renders NOT RUN reading "no probe declared" — unexplained silence is the defect, not +agreement. A probe's sentence must name a verifiable place: row 5.4 now separates *no +spec*, *a spec predating the field*, and *a design recording no mesh*. Every registered +check has a probe now, and it is test-enforced (§4.4) rather than a count to keep +re-auditing by hand. + +**A declared out-of-scope row is a third state, not a silence.** The scope is declared in +the job layer, never inferred. No declaration means judge everything, and a mark whose +prefix cannot be read is never set aside — "I could not tell" may only resolve towards +judging. + +**A model-read (`AI-*`) finding is always WARN**, never OK and never FAIL. With +`vision_escalation` on, grade-D items no comparison can settle go to a small model reading +one rendered view; the answer lands in `model_read`, never in `findings`, and **can never +set a checklist row**. `ticketvision` carries that provenance four independent ways (the +`source` field, an `AI-` code owning no row, its own list, its own report section), on the +assumption that an edit will drop one and the other three still have to hold. A model that +saw nothing wrong in one view is a reason to look, not to stop looking. + +**Muting never silences a FAIL**, and a muted check still *runs*: `apply_muting` repaints +it grey rather than removing it, and MUTED gets its own tally bucket. "31 OK" when four of +them were nobody's judgement is the quiet lie the table exists to stop. + +**A check may not claim what the tool is in no position to claim.** Rows 5.6 and 5.7 both +printed *"no reinforcing found on this piece"*; this plant models bars outside any rebar +category, so with `drawings.reinforcing_families` empty a **301-member** piece produced the +same words as a piece with no steel in it. They now say the tool could not **recognise** +any, name the key that would turn it on, and **stay red** — *a better sentence is not +permission to soften the state*. Two siblings landed the same day: 5.1 printed OK off one +of its two questions, so a check slot now takes a tuple and **any quiet half makes the row +NOT RUN**; and 5.3, a person's job by design, printed red because a hand-kept +`FOR ENGINEER` tuple omitted it — that list is now derived from the table. + +### 3.2 The knowledge layer law + +The mechanism is §2.3. The law is this: **job beats plant beats what everybody in the +trade knows** — not a tie-break, but a statement about where truth lives. The trade knows +a bar mark is a recipe; the plant knows its own hardware; the job knows what was drawn. +So **placement is an argument, not a preference**. Three examples from a single day: + +- **Rebar stock lengths → the plant.** One number for every size let a 30 ft `#4` pass + where the plant stocks `#4` in 20 ft — a false OK on a real defect class. It became a + **map keyed by printed size, in integer inches** (`catalogue.rebar.stock_by_size`, values + supplied by the plant layer). As a *list*, a job adding one size would silently delete + the plant's whole table. +- **A job's odd-grade bar → the job.** The manual files every stock length under a + designation the owner says is retired; if it reappears it is an exception to this job, + so it is job knowledge. +- **Which end is up → public.** The plant layer carried `top_is_higher_global_z`, and both + obvious repairs were wrong. An assembly origin is placed when the assembly is made, and + **placing it is the statement**: that is how precast is built, not a house habit, so it + belongs in the repo catalogue. A key asking a plant to declare it is not an unread key, + it is the wrong question — and it named a *mechanism* rather than an answer, which a + layer must never be able to swap. + +**Dormant and loud.** A key not yet wired ships **null**, with `*_verified: false` and a +written reason — never a guess. The reinforcing rule shipped that way and proved the right +*shape* around the wrong *field*; the flag did its job, because the rule never ran and the +report named the reason, where a guess would have left every bar in the job silently +un-recognised. Turning it on was two YAML fields and no code change. §4.3 lists what is +dormant now. + +### 3.3 Never guess a plant fact + +Parameter names, categories, family tokens and casting conventions are not code's to know. +They are reached through **roles** declared in the knowledge layers (`PARAMETER_ROLES`, +`knowledge.param_names`). Two incidents from one day: + +- **Three guesses, three wrong.** An agent needed one plant parameter name and guessed it + three ways; none of the three existed in the model. `rvt.py params ` answers it in + one request. The precedent: the mark parameter name left the code entirely and became + the role `member.mark`. +- **Types read where the values were on instances.** A census of the reinforcing parameter + read family *types* only, but this plant writes that value on the **instance** — so the + census saw nothing and the agent told the owner the wall panels carried no reinforcing + at all. Measured properly, one piece had 112 bars and another 84. The rule now carries + `read_from: [instance, type]`: the value sits on one or the other, mutually exclusively + per element, and a reader taking only one finds nothing on half the model **and returns + a result that looks perfectly clean**. + +**Measure it, ask the owner, or stand down with a written reason — never assume.** When a +bar-size parameter could not be measured, nobody guessed; it is still an open question +(§4.3). *"Ask the owner" is the third resort, not the second:* **search the transcripts +first** — §3.8, one-liner in `CLAUDE.md` §7. + +### 3.4 Secrecy and the scrub gate + +**No client, job or plant information anywhere** — code, comments, docstrings, commit +messages, examples. Where you need to refer to one, write the `CLAUDE.md` §3 placeholder +rather than deleting the sentence. Product codes are not identities and stay as they are. + +`tools/scrub.py` is the gate, in two modes on purpose. `--worktree` is the audit: every +file git can see, tracked *and* untracked, and a hit anywhere is a hit. `--staged` is the +pre-commit hook: only staged files can block a commit, while untracked hits print loudly +without stopping unrelated work — because a gate that blocks unrelated work is one people +learn to pass with `--no-verify`. It also runs inside `tools/package.py`. The term list +lives in gitignored `.scrub-terms`, so the list of names is never itself the leak; +structural patterns (model filenames, absolute user paths) are checked whether or not +anyone remembered to add a term. + +**The cautionary tale (2026-08-30).** The gate built each term as `\b\b`. In regex +`_` and digits are word characters, so `\bTerm\b` never matched `PREFIX_TERM` — and +`PREFIX_ROLE` was the dominant naming convention here before the roles refactor. **The +terms most likely to be a real leak were written in the one shape the gate was blind to.** +The fix is letter-only lookarounds, and the sanctioned placeholders are now read out of +`CLAUDE.md`'s own table so the gate cannot drift from the rule. The rescan: working tree 0 +hits → **4**; history 19 hits in 3 commits → **22 in 6**, all six already pushed. +**Assume every scan before that fix under-reported.** Editing a file does not remove a +name from the commit that introduced it — §4.4 has the decision still outstanding. + +### 3.5 Revit safety + +- **Never reload pyRevit. Restart Revit.** `reload_pyrevit()` tears down the engine that + owns the routes handler while the HTTP server still points at it, so the next bridge + request runs against a dead engine and takes Revit down — on a non-main thread, where + Revit cannot catch it. Measured 2026-08-14: 11 clean requests, one reload, the 12th + fatal. It happened twice. Our code never gets to run, so the guard sits outside Revit: + `startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load, and `rvt.py` + reads that log before *every* request and hard-refuses on two loads under one process id. + There is no `/reload` endpoint. If `rvt.py` says the bridge is unsafe, do not work around + it and do not curl the endpoint — ask for a restart, which picks up new buttons just as + well. +- **Never run an unfiltered collector; never guess an API enum.** 2026-08-18: one snippet + walked the whole document with `WhereElementIsNotElementType()`, touched `.Symbol` on + every result, and set `Options.DetailLevel` from a value reached by `__import__`. Revit + died with no exception and no journal entry, because **a bad value handed to a native + API is not a Python error**. Scope every collector; import enums by name, so a bad name + is a traceback rather than a dead process. +- **Transactions for writes; reads never need one** (`with tx('name')`). The production + model is read-only to agents: prototype read-only, show counts and a sample, and get an + explicit go-ahead before any mutation. **Nothing in the extension writes document data or + geometry, on purpose** — a wrong report can be disbelieved, but wrong geometry gets built. + The one exception is view state: Panel Lift Check opens a `tx('Show assembly')` to + isolate the piece temporarily and put an 85% transparency override on its non-insert + members, and writes both back off when it is done. + +### 3.6 How the work is organised + +The owner works through sub-agents. **One dedicated long-lived agent holds the bridge**, +and all live-Revit work routes through it. Everything else is split into **non-overlapping +file sets** and run in parallel, each landing committed **by explicit path** and pushed. + +**`git add -A` is prohibited**, and it has cost twice. `cb1587d` swept in a +**deliberately broken** button that the tooltip agent had planted to prove its guard +fires, so HEAD briefly shipped a fake tooltip (repair and rule in `14de0d3`). And +`109d7df` — titled *"Today is the 30th, not the 31st"* — swept in the first half of +another agent's unfinished up-end work; the follow-up, `cf9e164`, opens by saying so. + +A glob is the same mistake in a different coat: `git add .`, `git commit -a`, +`git add lib/` while another agent is mid-edit. **Name the files.** + +### 3.7 Testing + +**Host-testable by design.** The whole suite runs on plain CPython with no Revit and no +licence: + +```bash +python -m pytest WillTools.extension/tests -q # the extension +cd panellift && python -m pytest -q # the solver +``` + +**Run them; do not quote them.** Two readings on 2026-08-30, hours apart on the same +machine: `panellift` 272 passed both times, while the extension went from 1807 passed / 1 +failed to 1860 passed / 1 failed, because another agent was adding tests in between. That +is the normal condition here, and it is why every count in this document is date-stamped +or replaced by a command. The one thing that held across both readings is the single +failure — a real one, environment-coupled, described in §4.4. Several documents in this +tree still quote counts that were true when written and are not now (§4.6). + +`tests/conftest.py` is what makes it possible: it registers a bare module object as +`sys.modules['vibe']` with `__path__` pointed at the real package, so `vibe/__init__.py` — +which imports the Revit API the moment it runs — never executes. That reaches any module +touching Revit only *inside* functions needing a live `doc`, and cannot reach one +importing the Revit API at **module scope**. `ticketqc.py` / `ticketchecks.py` are the +standing example: `ticketqc` imports eight `Autodesk.Revit.DB` names at the top and +`ticketchecks` imports `Finding` from it, so freeing them takes more than moving an +import. `test_ticketchecks_host.py` reaches a few pure decisions through a deliberately +**inert** stub of those names: if an edit makes one really call the API, the test raises +instead of quietly passing. + +**Pin against a written-down golden, not a recomputation.** The takeoff count is pinned to +numbers typed into the test, because *a pin that recomputes both sides pins nothing* — it +agrees with any refactor, including a wrong one. + +**Tombstone tests** keep a deleted thing deleted. `test_no_cap_survives_anywhere_in_the_module` +asserts that `ticketpage` has no `capped_note` and no `NOTE_CAP`: that 180-character cap +had truncated a debond run mid-number, in both the panel and the text file. + +### 3.8 Search the transcripts before you ask him + +**A question he has already answered is worse than a question nobody asked.** It costs him +time, it tells him nobody looked, and — the real damage — it invites him to answer a second +time. Two rulings then sit on the record with nothing to say which governs, and the next +agent gets to pick. The rule, in his words, 2026-08-31: *"开发纪律里面再加一条,有问题先搜索 +聊天记录。搜不到再来问我。"* + +**The incident that bought it (2026-08-31).** An overnight audit put +`handling.tension_limit_sqrt_fc` at the top of the owner's list as **A0**, under a heading +saying it was *"the only open item that changes a live engineering answer"* — reasoning +that he had made the stress limit a per-job choice, that the job layer held nothing, and +that the job was therefore running on 7.5 inherited from a layer he never said should +decide it. Every one of those facts was true. The conclusion was still wrong: he had ruled +the value twelve days earlier, **2026-08-19: *"就用7.5sqrt。"*** The plant layer holds +exactly 7.5, the tool had been doing the right thing the whole time, and A0 existed only +because nobody searched. Escalating it would have spent his morning re-deciding a decided +thing. + +Note what the audit actually did wrong, because it is the subtle half: it found a **later** +quote (2026-08-30, *"应力极限写入 projectconfig … 让用户自己填写。I can see it both ways"*) +and read a ruling about **where the knob lives** as if it vacated the ruling about **what +the number is**. Those are different questions and both answers stand. A mechanism ruling +does not retract a value ruling — and a search that returns two hits is not finished until +you have worked out whether they even disagree. + +**Searching is cheap. `CLAUDE.md` §7 has the one-liner; the measured cost over the whole +65 MB of transcripts is 0.2 seconds.** "It is expensive to look" is not available as a +reason, and neither is "I did not know where they were". + +**But a search that finds nothing is not proof he never said it.** The transcripts are one +machine's history, they are not indexed, they hold his messages in two languages, and the +term he used is very often not the term you searched. So the honest report is +***"I searched X, Y and Z and found nothing"* — never *"he has never ruled on this"***. +State the terms; the next reader can then extend the search instead of repeating it. This +is §3.1's invariant wearing different clothes: not-found is a NOT RUN, not a pass. + +When you do find it, **write it down where the value lives** — beside the number, with the +date and his words verbatim — so the next agent finds it by reading rather than by +searching. A ruling recorded only in a transcript will be asked again. + +### 3.9 Three tiers: the coordinator decides, Sonnet explores, DSV4F labours + +**The coordinating agent (Fable / Opus class) is the most expensive process on this +machine, and it spends its budget on four things only: planning and splitting work, judging +results, deciding anything irreversible or anything that touches live Revit, and talking to +the owner.** Below it sit two cheaper tiers, and they are not interchangeable: + +- **Exploration — reading a codebase to answer "how does this work / what would it take" — + goes to a Sonnet sub-agent** (the harness's `Agent` tool with `model: sonnet`; or Opus + driving Sonnet), never to DSV4F. Exploration needs judgement about what matters, and the + owner's ruling is that DSV4F does not get to make it. Sonnet returns a note with + `file:line` citations; the coordinator reads the note, not the files, and goes to the + source only for the fact a decision hinges on. +- **Labour — extracting tables from documents, mining PDFs into notes and fixtures, + translating, classifying, reformatting, and writing code to a settled, pre-split spec — + goes to DSV4F (DeepSeek V4 Flash)**, with V4 Pro only where Flash has demonstrably failed + on the same material. The owner, 2026-09-05: *"探索你带着sub sonnet或者让opus带sonnet去做。 + DSV4F别做探索。DSV4F就干点儿体力活儿"*. + +**The incident that bought it (2026-09-05).** `CLAUDE.md` carried *EXPLORE (me) → WRITE +(DSV4F) → REVIEW (me)* with the sentence *"exploration never goes to DSV4F"*, and no rule +said the exploring had to be cheap. Under that, the coordinator spent an afternoon on +`docs/pa-web-spec.md`, `tools/pa_web/static/app.js`, `draw.js` and every key +`beamcalc/model.py` reads from a job file — twelve consecutive file reads on the most +expensive model, to produce a note a Sonnet sub-agent writes as well. The owner's words: +*"用DSV4F。这些东西开发纪律里面没有?"* — and until that day this section did not exist. The +first draft of the fix then over-corrected and dispatched that same reconnaissance to DSV4F; +he stopped it within minutes with the ruling quoted above. Both halves are the rule: the +coordinator does not do the reading, **and** the reading is Sonnet's, not Flash's. + +**Which DSV4F route.** Two exist and they do not share a balance. `cheap` (in `PATH`) is +the DeepSeek platform direct; on 2026-09-05 that balance was **negative** (`402 Insufficient +Balance`) after the `sample_job` document mining ran V4 Pro at 65k output tokens with parallel retries, and +every `cheap` call and mining script died with it. **Command Code (`cmd -p "" --model +deepseek/deepseek-v4-flash --yolo --no-session`) is a separate account with its own quota**, +and the owner's instruction that day was to use it. Check the balance before dispatching a +batch, not after it has failed; a 402 on the first call means every call. + +**Detach anything that outlives a turn.** The coordinator runs as a systemd service under a +watchdog that restarts it — three times on 2026-09-05: 13:11 and 14:11 for a mis-detected +gateway, 15:09 for a stall. A service restart kills the whole cgroup, and each time it took +the background workers with it: seven `cmd` mining jobs launched from the coordinator's +shell, four dead mid-write, output files 0 bytes or absent. A worker launched with +**`systemd-run --user --unit …`** is its own transient unit and survives; one +launched with `&`, `nohup` or `setsid` from the coordinator's shell does not. Write the +worker's result to a file and its status to a log (§3.1 applies: an empty log is a NOT RUN, +not a pass), so a coordinator that has lost its memory can read what finished instead of +asking. + +**Split before you send, and size to one reply.** A whole-feature brief truncates at +max-tokens and reads as "it skipped the hard parts"; one function, one section, one file's +change per task (`CLAUDE.md` § delegation, owner 2026-09-03: *"确认了边界的活儿,拆细了再让 +DS 干"*). The gates — pytest, scrub, review — run on the coordinator's side, never inside the +worker. + +--- + +## 4. Where it stands + +Written 2026-08-30 against `ff10870`. The working tree was clean when this survey was +taken; it is edited concurrently by several agents, so check `git status` yourself rather +than trusting that. `docs/checklist-code-map.md` was rebuilt 08-30 17:19 and commits have +landed since. + +### 4.1 The checklist, row by row + +Authority is `CHECKLIST_ITEMS` in `lib/vibe/ticketpage.py`. + +| # | grade | state | note | +| --- | --- | --- | --- | +| 1.1 sheet name = mark | A | running | no skip probe (§4.4) | +| 1.2 sheet design reduces to piece design | A | running | judged in `ticketqc.check_ticket`; no named check fn | +| 1.3 one ticket set per piece | A | running | no skip probe | +| 1.4 view names refer to this design | B | running; **dormant out of the box** | needs `nomenclature.design_number_re` — plant layer, not `DEFAULTS`. Re-graded A→B 08-30 | +| 1.5 call-outs do not overlap | C | running, built 08-30 | rotated annotations set aside under `CALLOUT-OVERLAP-UNREAD`; no sample, untested | +| 2.1 printed size vs modelled | B | running | | +| 2.2 weight three ways | A | running | the *measured* leg needs run option `expensive_geometry` (default ON); off, the row is NOT RUN naming it | +| 2.3 unit weight | A | running | | +| 2.4 cast extras in the weight | A | running, built 08-30 | verified end-to-end on a real ticket | +| 3.1 f'c · 3.2 f'ci | A | running | | +| 3.3 release ratio | B | **muted on this job**, still runs | owner's own dated sentence: no panel here has the thing it asks about | +| 4.1 strand count · 4.2 diameter & grade | B/A | running | | +| 4.3 jacking force | A | running | `tolerances.it_lb` wired 08-30 to **100 lb**; the job layer's `1.0` deleted, not corrected | +| 4.4 strand positions vs chain from rail | B | running | stands down with a reason where there is no strand geometry | +| 4.5 debond call-out | C | running, built 08-30 | consumes 4.7; where 4.7 could not judge, 4.5 is NOT RUN with 4.7's reason | +| 4.6 rows & spacing | A | running | same check as 4.4 | +| 4.7 strand debonded where concrete runs short | B | running | | +| 5.1 BED PLAN call-out embed exists | A | **half running by construction** | `check_bom_members` runs; `check_detail_callouts` reports NOT RUN *naming the sheets nobody read* wherever the plan cites a bubble. Only a plan citing no detail can be OK | +| 5.2 embed in model appears on ticket | A | running | | +| 5.3 face designations vs model side | C | **FOR ENGINEER by design** | printed a red NOT RUN on every ticket until 08-30 — missing from a hand-kept tuple (§3.1) | +| 5.4 mesh / WWF designation | A | **muted on this job**, still runs | no mesh on this job | +| 5.5 bar marks legible | D | **FOR ENGINEER by design** | a property of the picture | +| 5.6 bar marks unique | B | running, built 08-30 | split out of the old 5.5; runs where the reinforcing rule resolves (§4.3) | +| 5.7 no bar longer than its stock | B | running, **against one generic number** | the per-size stock table is built, populated and unreachable (§4.3) | +| 6.1 insert capacity · 6.2 edge distance | A | running | no skip probe | +| 6.3 handling result | A | running | no skip probe; three silent `return []` paths | +| 6.4 dunnage not under an opening | A | running, probe added 08-30 | open ruling on the all-blind-embed path: NOT RUN (today) or OK | +| 6.5 note views say something | B | running; **dormant out of the box** | needs `notes.instruction_view_sets`; `DEFAULTS` ships it empty | +| 7.1 interference | B | running | WARN-only by ruling, over 157 findings on 57 archived tickets. Do not re-infer that the rest are noise — that was overruled once | +| 8.1 unresolved design-summary lines | C | running | the one true FAIL of the 08-30 live run ("no probe declared"); probe added | +| 8.2 no invisible characters | A | running | no skip probe | + +Nothing on the list is unbuilt. Two rows are a person's job on purpose, two are muted with +a written dated reason, the rest compute. What varies is how much of its question each row +actually reaches — **5.1 and 5.7 claim least**. + +**The table above is the 34-row census this section was written against and is now stale +by count** — `checklist.master` in `knowledge/catalogue.yaml` carries **46** entries as of +2026-08-31 (verified by reading `len(ticketpage.CHECKLIST_ITEMS)` on a fresh load; run it +rather than trusting this number later). The rows above through 8.2 are still real; twelve +more (`4.8`, `5.8`–`5.15`, `6.6`–`6.8`) have landed in the master since and are not +individually described here. Extend the table from the catalogue, not from memory of it. + +### 4.2 The other tools + +| tool | state | +| --- | --- | +| **Panel Lift Check** | Finished, in daily use, no licence needed to test. Numerics open (§4.4). The per-stage input control aligned to the reference tool's `HandlingData` is a **mapping document only** (`docs/panellift-ewf-alignment.md`); nothing is built from it | +| **Reinforcing Suggestion** | Finished. End-to-end 08-30: cold 10 s, warm ~1 s, offline byte-identical to the bridge. Position recommendation defaults **off**; one invariant test still owed | +| **Ticket QC** | Finished, the flagship. Six in-process `vibe/ticket*.py` modules — `wc -l WillTools.extension/lib/vibe/ticket*.py`, and expect the number to have moved since you last looked; it is what §4.5 is about | +| **Snapshot & Close** | Finished. Phases A and B measured green; phase C (the deferred detached copy) is off unless the job layer enables it, and journals its own silence | +| **Takeoff** | Finished. `nomenclature.ticket_unit` wired 08-30, so the tool stops deciding for itself what one ticket covers | +| **Job Recon** | Finished. Self-loading, scope-aware, 9.3 s on the real job | +| **Design Extract** | Finished, with its caveat printed on it: every value it writes is machine-read and unverified | +| **Self Check** | Finished | +| **Gravity (WIP)** | Neither this nor Lateral writes to the Revit model — both are extractors: read the model, write a file elsewhere. Gravity is meant to extract information and write **Excel**. Today it does none of that: **a button with nothing behind it, and it says so.** ~200 lines of placeholder printing the open questions and assuming nothing. Blocked on the owner supplying old jobs and a control spreadsheet | +| **Lateral (WIP)** | Meant to extract lateral-resisting members and write **RISA** — a structural analysis package — its input format. Half-built, and it does real work: it extracts marked members and writes a JSON intermediate model, two CSVs, and a `.r3d.DRAFT.txt` (`.r3d` is RISA-3D's own file extension) — the `.DRAFT` suffix deliberate, so it cannot be loaded into RISA by accident. What the RISA format needs beyond that is not established | + +### 4.3 Dormant, and what would wake it + +| dormant | what wakes it | who | +| --- | --- | --- | +| `drawings.reinforcing_rule.scopes` — empty, so 5.6 and 5.7 see one population | a ruling on three edge cases: bar-formed hardware carrying the token (one is a strand loop, with neither mark nor stock length), and reinforcement carrying none | owner, A1 | +| `catalogue.rebar.stock_by_size` — populated, read by `knowledge.rebar_stock()`, **never called by `check_bar_stock_length`** | which parameter carries a bar's size | owner, A3 | +| 5.3 as a computed check | what makes an elevation line dashed — line style, subcategory, or nested detail items. The view's 17 filters were read; none carries it | owner, A5 | +| the up-end rule | woken 08-30 on evidence over 282 assemblies. Still open: the vertical-panel case, and the colour→axis mapping, which is inference not measurement | owner, A4 | +| `tolerances.psi` (inert, muted not deleted) and `details`, the 70-entry job catalogue nothing reads | one word each: wire or delete | owner, D1 / D7 | +| `drawings.rebar_must_appear_on` / `section_a` / `section_b` | a dated promise queued behind the current phase — not drift | queued | +| `check_detail_callouts` as a verdict | something that reads a detail sheet's *contents*. Not built; needs the model | build | +| vision escalation (`AI-` codes) | off by default; owns no checklist row and cannot flip one (§3.1) | config | + +### 4.4 Known defects and unfinished business + +- **`qc-run.json` carries findings, not the run log.** `ticketvision.qc_json` takes + `findings` + `model_read` and nothing else — checklist rows, mutes and `run_all`'s log + stay behind. A NOT RUN row's *reason* reaches the HTML page and `last-ticketqc.txt` but + never the machine-readable file, so anything reading the JSON cannot tell a row that + passed from a row nobody asked. +- **A failing test, environment-coupled.** `test_reinforceoffline.py`'s no-index case fails + here because `cli.resolve_index` finds the real, gitignored `knowledge/job/` and answers + with a job list instead of the three-ways message. Green on a clean checkout, red on the + owner's machine. +- **Closed, 2026-08-31: every registered check now has a skip probe, and it is + test-enforced rather than a hand count that can silently rot again.** + `tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe` walks + `ticketchecks.CHECKS` against `_SKIP_PROBES` and fails, naming the orphan, the day a new + check ships without one. A silent `return []` from an unprobed check still logs + RAN-EMPTY and prints a red NOT RUN reading "no probe declared" — the mechanism is + unchanged — but the gap this bullet used to track (eight bare entries, then seven) is + gone; run the test rather than trusting a count copied in here. +- **Client and plant names are in pushed history** (§3.4). **Untaken decision:** redact the + working tree only, or rewrite history and force-push. +- **The lifting solver's numerics.** `docs/lift-check.md`, written up, unstarted. Not a + speed question: today's recommendations are not determined to better than several inches + and on some pieces are not the best layout available. It gates any further + insert-placement work. +- **`families.wwf_prefix` has two defaults** — `takeoffgather` falls back to empty (no mesh + detection), `ticketqc` to `'WWF'`. Reachable only when a layer sets the key null, which is + a plant saying it has no welded wire fabric. If that is what null means, `ticketqc` is + wrong. +- **Smaller, recorded:** `read_ticket` costs 37.6 s on a large piece, entirely outside the + checking wall and with no code-level cure; and `catalogue.rebar.stock_exempt_marks` is + empty pending the 60-ft continuous-bar ruling. + +### 4.5 The roadmap, as it actually exists + +**`docs/architecture-migration.md` — nine steps, approved 08-30.** In-process IronPython +keeps only the gatherers; judgement, matching and rendering move to host CPython, fed by +the versioned JSON of §2.2. + +*Step 1 landed 08-31*: Ticket QC writes `evidence/run-.json` on every press and +**nothing reads it**. As built it corrected its own plan — four live-object holdouts, not +two, so the serialiser gates on type. **§5.1, parity on real data, is not met and could +not be**: it needs a press, and the bridge was held. + +*Step 2* buys a parity proof and nothing else: a host runner judging the 13 Revit-free +checks offline against the twelve-piece audit set. Cost M, no visible change. *Step 3* is +where it pays — the button calls that runner (reusing `liftexport.find_python`/`run`), +making it the first press where a `ticketchecks` judgement edit is visible **without a +Revit restart**. Cost S. + +**§8.2, honestly:** `engine: clean` is landed on three buttons and buys nothing today. A +clean engine is already the default here, because `extension.json` declares no `url`, so +pyRevit's package lookup drops the extension and rocket mode never takes the clean engine +away — verified two ways offline. It is a **pin, not a lever**, against the day somebody +adds a `url`. **The one-minute behavioural check — press, edit a rendered string, press +again without restarting — has still never been run**, and no bridge call answers it. + +**`QNA/eriksson-gap-inventory.md` — the commercial-parity list.** 46 quantities: 10 at +parity, 2 needing no build, **34 to build** (17 S · 5 S-after-dependency · 5 M · the rest +larger). **Mcr is the cheapest first win** — S, P, A and e are held at every station and +`modulus_of_rupture_psi` is written. The load-bearing finding: the reference tool is a **1D +single-member analysis, not 2D FEA** — every plot is a function of x, with no mesh +reference and no Mxy/Wood-Armer column in 18 pages. That reframes tier B entirely. The list +needs the owner's tick before anything on it is built. + +Beyond those two, the only genuine plans are the reinforcing-suggestion brief's open +granularity questions and §4.3's queued coverage rule. The rest of `QNA/` is record, not +plan. + +### 4.6 Where the docs and the code disagree + +Several documents were accurate when written. Where they are not, the code wins. + +- **Every test count quoted in this tree is stale.** `CLAUDE.md` says `panellift` has 76 + tests and the extension suite is `49 passed`; `README.md` says `34 passed`. Run the + commands in §3.7. +- **Every line count quoted in this tree is stale, recent ones included.** + `architecture-migration.md` §3 warns it will drift and has drifted again; on 2026-08-30 + `ticketqc.py` moved between two measurements minutes apart, because other agents were + editing. Use `wc -l`, and read any line count as a date-stamp rather than a fact. §3.3's + "26 registered checks" is now 27. **Verdicts hold; numbers do not.** +- **Design Extract calls no AI**, despite `README.md` ("hands the scrape to Claude Code") + and `CLAUDE.md`. The script says "no AI call, no subprocess", and no button routes + through `vibe/aiextract.py` any more. +- **`configdump.py` and `CLAUDE.md` say four knowledge layers**; `layer_values()` handles + five, with config behind `include_config=False`. +- **`knowledge.load()`'s docstring drops a folder** — it gives the job layer as + `/knowledge.yaml`, where `designspec.knowledge_path()` returns + `/_ticketqc/knowledge.yaml`. +- **`docs/handoff.md` calls Panel Lift Check "the one under development"**; it is one of + the four finished large buttons, and the same file's "Never migrate" section calls it + *already migrated*. +- **Batch speeds disagree across three files** — `panellift/cli.py` 41 min / 40 s, + `docs/lift-check.md` ~40 min / 2 min, `PanelLiftCheck/script.py` 38 min / 2.5 min. The + last is the most specific and names its path. +- **`rails.left_rail_is_fixed` is a dead flag** (`docs/knowledge-override.md` says so + correctly): one reader, `aiextract`, which puts it in a prompt. No check consumes it. diff --git a/QNA/README.md b/QNA/README.md new file mode 100644 index 0000000..01d96c8 --- /dev/null +++ b/QNA/README.md @@ -0,0 +1,52 @@ +# QNA — 有啥不明白的,写在这儿 + +这个文件夹只有一个用途:**你随手记下没搞懂的问题,我看到了就回答。** + +不用等到我在旁边。想到了就写,反正记下来的问题总比忘掉的强。 + +--- + +## 怎么问 + +打开 `questions.md`,在最下面加一段: + +```markdown +## Q: transfer length 那个 33.2 in 是怎么来的? +``` + +`## Q:` 开头,一行说清楚就行。要贴代码、贴报错、贴一段 job JSON 都可以, +跟在下面就是了。**不用管格式,不用管排版。** + +写完存盘。就这样,没有别的步骤。 + +## 怎么得到回答 + +我会在问题下面直接补一段: + +```markdown +**A**(2026-08-22):ACI 318 的 ℓt = f_se·db/3 …… +``` + +答案就跟在问题下面,一个文件从上往下读就是一份记录 —— 不用在两个文件之间来回翻。 + +--- + +## 说清楚一件事:我不是 24 小时在线 + +盯这个文件夹的是 `tools/qna_watch.py`:它是当前 agent 会话里的一个后台任务, +一有改动就退出并叫醒我去读、去做,做完再重新布防。**只有这个会话开着的时候才有效**。 +终端关了、电脑重启了,就没人看了 —— 那之后写进来的问题会一直躺在 +`questions.md` 里,等下次会话开始时 watcher 一启动就会把积压的改动先报出来, +我读一遍,照样会答,只是没那么快。 + +所以:**积压是正常的,丢掉是不会的。** +下次开工的时候直接跟我说一句「看下 QNA」,我就把没答的都补上。 + +## 别在这儿写客户名和项目名 + +跟仓库里别的地方一个规矩(见 `CLAUDE.md` 第 3 节):不写业主名、不写工程名、 +不写模型文件名。产品编号不算 —— design number、piece mark、五金件料号都可以照写。 + +保险起见,`questions.md` 本身是 gitignore 掉的,不会被 push 上去。 +只有这份 README 进仓库,因为它是约定,不是内容。 +哪天你想把问答记录也存进版本库,把 `.gitignore` 里那两行删掉就行。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..63510b2 --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# WillTools + +Precast engineering tooling for Revit: shop-ticket QC against the design +summary, and PCI Chapter 8 handling calculations. Built for one precast +plant's workflow and shared here so a colleague can try it against their own. + +Two halves, deliberately kept apart. `WillTools.extension` runs inside +Revit, on the model. `panellift` is plain CPython outside Revit — the same +handling arithmetic, testable in seconds with no Revit licence at all. + +## Quick start + +```bash +git clone https://github.com/will123song/pyrevit-dev.git +cd pyrevit-dev +python tools/setup_machine.py +``` + +That installs the host-side Python dependencies and proves the install with +real answers — not just exit codes: it runs `panellift` against a published +worked example and checks the arithmetic against the number the article +prints. Read [INSTALL.md](INSTALL.md) for what to do with the output, and +for pointing pyRevit at the extension so the ribbon shows up in Revit. + +Try the handling calculator immediately, no Revit needed: + +```bash +python -m panellift examples/example-panel.job.json -o report.html +``` + +## What works without knowledge layers, and what stands down + +This distribution ships **no knowledge** — not a plant's parameter names, +not its casting-bed conventions, not even the published hardware catalogue. +See [`knowledge/README.md`](knowledge/README.md) for why and what the shape +of a real layer looks like. + +Runs fully, with no knowledge at all: + +- `panellift`'s handling checks against a job file — the example above, or + one you build by hand. +- The ribbon itself, and **Self Check**, which tells you exactly what is and + is not configured on this machine. +- `python tools/setup_machine.py --check` — reports the no-knowledge state + as a **pass**, not a failure. A knowledge-less trial install is an + expected, first-class state here, not a broken one. + +Stands down cleanly, with a stated reason rather than a wrong or silent +answer: anything that needs a plant's parameter mapping, the published +hardware catalogue, or the master shop-ticket checklist — most visibly, +**Ticket QC**, which refuses to open with a one-line reason rather than +rendering a report that looks like every ticket passed. Ask the owner for +real knowledge layers once you are ready to point this at an actual job. + +## `WillTools.extension` — inside Revit + +A pyRevit extension. Runs on IronPython 2.7 on Revit's UI thread, so it does +the things that need the model and nothing that needs testing. + +One tab, **WillTools**, four panels. Reorganised 2026-09-05: the modelling +and ticketing tools now sit behind **Tools**' `Modeling` pulldown, and the +analysis tools — finished and unfinished alike — sit behind **Design**'s +`DesignHub` pulldown. + +| Panel | Button | | +| --- | --- | --- | +| Tools | **Modeling** ▾ | A pulldown. The order is the order of the work: suggest the reinforcing, check it once it is modelled, check it once it is ticketed. | +| | ↳ Reinforce Suggestion | Suggests reinforcing for a design from what comparable designs already carry. | +| | ↳ Reinforcing QC | A cut-down Ticket QC for a panel whose reinforcing is finished but whose ticketing has not started — only the checks whose evidence is the panel's modelled content run. | +| | ↳ Ticket QC | Checks every shop ticket of a design against its spec — f'c, f'ci, I.T., strand count/grade/diameter, W.W.F., unit weight, pattern legend, size envelope. Needs a knowledge layer; see above. | +| | **Snapshot** | Takes a timestamped detached copy of the model at close-out. | +| Setup | **Config** ▾ | A pulldown. The commands you run to get a job ready or to look at it sideways, rather than the ones you press all day. | +| | ↳ Project Config | Writes the project config layer: every setting that is the engineer's to make, in one file, wherever they want it. | +| | ↳ Design Extract | Scrapes a design packet — every sheet whose number reduces to the design, and every view placed on them — reads it with the regex/geometry parser in-process, writes a reviewable `spec.json`. No AI call, no subprocess, nothing that leaves Revit. Run once per design. | +| | ↳ Self Check | Probes everything the tools assume about this machine, this Revit and this model. Run it first on a new computer. | +| | ↳ Job Recon | Reconciles the issued ticket set against the whole job — the question no per-ticket check can answer, because a ticket that is not there cannot be checked. | +| | ↳ Takeoff | What the job contains, per piece and in total: hardware by part number, reinforcing by bar mark. | +| Design | **DesignHub** ▾ | A pulldown. PanelLiftCheck and PanelAnalysis are finished and grouped here with the other analysis tools; the four rows after them are buttons-and-docs only and carry `(WIP)`. | +| | ↳ Panel Lift Check | PCI Chapter 8 handling check on the piece, from the insert positions the model already holds. | +| | ↳ Panel Analysis (WIP) | Opens an input-confirmation form, pre-filled from the model, feeding a Stage-0 self-weight solve; the full in-place/wind/service engine is not built yet. | +| | ↳ Beam Analysis (WIP) | Placeholder; nothing is implemented. | +| | ↳ Connection Analysis (WIP) | Placeholder; nothing is implemented. | +| | ↳ Gravity (WIP) | Gravity load chasing. Unfinished; today the button writes nothing at all. | +| | ↳ Lateral (WIP) | Lateral extraction. Unfinished; today it writes a JSON model, two CSVs, and a RISA draft that is deliberately not a real RISA input yet. | +| Lab | **GOD_MOD** | Opens Claude Code in a terminal on this repository, wired to the running Revit through a local bridge. Optional — every engineering button runs without it. | + +Anything half-built lives behind the **Design** panel's pulldown and carries +`(WIP)` in its name, so nothing unfinished ever sits beside a tool that +works on **Tools** or **Setup** — `DesignHub` itself is the one pulldown +that mixes finished and unfinished rows, because the owner wants the +analysis tools grouped together rather than split across panels. + +The design intent lands on disk as JSON that a human reads and corrects before +anything trusts it. Repeated checking is then pure arithmetic — no model, no +judgement, same answer every time. + +See [INSTALL.md](INSTALL.md) to deploy it, including on a different Revit +version. + +## `panellift` — outside Revit + +PCI Design Handbook Chapter 8 handling checks: stripping and yard handling of +flat panels, from the insert positions the model already holds. + +Plain CPython, **no dependencies**, and no Revit import anywhere. That is the +point — a calculation whose output reaches a plant has to be verifiable, and +verifiable means a test suite that runs in seconds without a licence. + +It reproduces every published number in the ASPIRE Winter 2019 worked examples +(which reprint PCI Table 8.3.1 and Figure 8.3.2), and it *derives* PCI's +0.0214 moment coefficient from the beam solution rather than quoting it: + +```bash +cd panellift && python -m pytest +``` + +**Run it rather than trusting a number printed here** — a suite grows every +day and a README does not. + +See [panellift/PLAN.md](panellift/PLAN.md) for what is built, what is next, +and the idealisations that are known to be idealisations. + +## Filing feedback + +Please do — that is what this trial is for. Two templates when you open an +issue: **Bug report** for something that crashed or gave a wrong answer, +**Question** for something that was unclear. + +One thing worth knowing before you file: **this tool refuses rather than +guesses, by design.** A row that reads "NOT JUDGED" with a stated reason — +an unmapped parameter, a missing knowledge layer, a check that legitimately +has nothing to say about this piece — is very often the tool working +*correctly*, not a bug. The bug report template asks you to tell these two +apart: + +- **Wrong answer** — it computed something, and the something is wrong, or + it crashed. That is a code bug; file the Bug report template, and paste + `python tools/setup_machine.py --check`'s full output — it is the single + most diagnostic thing you can attach. +- **Refused, and I think it shouldn't have** — it declined to answer and you + believe it had enough information to. That is usually knowledge-layer + work (a parameter role that needs mapping, a rating that needs + publishing) rather than a code fix — file it as a Question, and say what + you expected it to know. + +**No client or job data in an issue, a PR, or a screenshot — ever.** See +[CONTRIBUTING.md](CONTRIBUTING.md) for the placeholder convention this +project uses everywhere instead. + +### What to ask the owner for + +This distribution ships no knowledge on purpose. To point it at a real job +you will want, from the owner directly (not filed as an issue — these are +not code, and they identify a real plant): + +- a **plant knowledge layer** — your office's parameter names, casting-bed + conventions, and rigging defaults; +- the **published hardware catalogue** — manufacturer ratings for lifting + inserts and connectors, plus the master shop-ticket checklist Ticket QC + reads; +- sample decks or a walkthrough, if you want a guided first run rather than + starting from the example in `examples/`. + +## Working on this + +- **Never use pyRevit → Reload. Restart Revit.** A reload leaves the routes + handler pointing at a dead engine and the next bridge request kills Revit. +- IronPython 2.7 syntax under `WillTools.extension/`; modern Python is fine in + `tools/` and `panellift/`. +- Any change to a model needs a Transaction. Internal units are decimal feet. +- Scope every `FilteredElementCollector`. An unfiltered one crashed Revit. + +See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/bundle.yaml new file mode 100644 index 0000000..e176b02 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/bundle.yaml @@ -0,0 +1,12 @@ +title: Beam Analysis (WIP) +# No tooltip and no author on purpose. pyRevit renders both on hover; +# the owner wants nothing there. Guarded by +# WillTools.extension/tests/test_button_tooltips.py -- do not add them back. +# +# One line: this button is a row in a `.pulldown`, and a pulldown row renders +# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown +# grows to fit the longest row, so the title may say what it means. +# +# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the +# owner's own instruction, alongside ConnectionAnalysis; nothing behind this +# button is built yet, see script.py. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b7cb3d1c79f73e150adc711e4df631c1aa46e4f2 GIT binary patch literal 2558 zcmb7`_dnZ<8pppOB18~P&Gd*^DK%=QwL)VPsz$jK$B5No6B?0ggtktM4%BF=wuoI- zTWXaSb<$EXs{EDxz_O zot9nMy|oYVW&%N2>G9OW9@nF9g|$bKRH=Uft(N%E`9)U%0#0TN#JOZ8ATT^&lH9#C z77nCw_ny+o+KDaz@ReNg=;+RbBhwyaEez4;b_XlC;O*;TA3!WMl`$%m0Ms+rldX=? zp^o5i9$yMSCJC5dzVL^vNe52;&R5}RUdhZfJ2~$<-ZuW$VU@-kgwDrPTryEIrw-Z+ zx;@7y_l`9yORaOBt`jY_QhhX~)3P>%K5#wt-EQNA$-l8ql5b!^d%O6OU5zq2V|4qn zi4;oQ-k~t&kWYWJJ4-DY2^E}Z0AoZg2^J)w$=1kgack`2ZzKRfup`v1H6((P`4&vt z6){YTJt1_x{h6pJBG?hOx4Tc`LuB)y@8kH<9|=R6S2|*? z)UU}XHQht_AJb&Tx<(w{}N{mkxpi+&z-&@Izt zF-m~0-M5djn3JlD2Z03^9MxoaS(Z#~+GP>{5RCTMW;Zao5||E*X9m__+?s>Mec|lig2U z>hPfzv94OiXk^ zu^>O>VvNd|bim%qtB!^ADg&4xkYrcwAKEN~6hBgm^N4>@SY% zJJ3-{v{hf6qG3TcgfGaZzrL7846DELy;uT3(mzt zcuq?iPi?gLI1A8u+RC3#$K?v#^m~4yo85iLJB4aFqC&c}{Hv~97#ca#R`*wB=-=LV zSFgNsN5eLLuh4-FHyexCqY)rzEN|ROZ$9y~@<^0=i}L;!E8Y2z`D3K;>oqbzWXZx_ z`9!*vZL+AZ=t;xf=_{xrX-l`}V_7fyEd|SawFv&(mlHPEak^XM)uvZ#@%RRrRV!() zIOjmTWkOAW7vn8toSu7}3iawa8gd7;A>nI~-)7%?<>YH*1~3>Q3P<%LW(1EbkUCA| z(|(#}a_{LXy`sXRqBg$n^Sppwu4o{lyz!vI{N-~ofag)H3Bg9K*F8GEawYn9xz(L% zIJb2O)h=KNh;$G0wgd)@r6lI3s;*Lz`pz!5I2SPEleFNBA0ZZ%LE|I04W=TG`@Ma9 zh+`CJ0ZmR-B2+p)mt_|WF-YRdO9nV;KMY8(vE%RGkTI_@+R0E|TUl~I#X)M17LXC^ z?wYpJR|~25d*mH^f11!m|L8XW#*8Iy`8eyb-uVn@0I9xtfvgY~s>NPSGD*Ell(mSh zsrq8y_&HBNOLItP-?KDc{fR0kf2y~3qrtVto#PU@ekN+^eW@x(fC$1z8x)+GnarVx z62qGi2vnrkgugE?#^;MOiRy6f@a!+VuR!~MIsgCQDQ}rKZg|4mDB62ew@R6_dT3m5 zLF36DFkUU;Y$WSb#5|0xGyf!}FjBEkCd+ea1xa}M&ty{*_McD7d;S;b{mD8mHTv|xS9lVnf zyU?biwCXk=GmUHtw&7MsRB~wtXS+3e{|9tY5olS2D*Ohi38|8Du|8L3#r-&#DiSzT zSRPD00ekQ>vC5rGs}o#F)AQ7@LYLF;f0oi(zv==YZ#dIo%}M^l31~BU)tz_F8rVaX z+>`HM7!2;Dbz1=h(7txaSpk20`c1IUw+0;O!Ou=m@C3}`R>+e}47}Abbc9g+*Y4OkVLMd65OCRaAoTOr>n`i6 zOX1uzUz=9>nEhMaTWlBxAI@TBT>?|#9KAY_=`SIDE8g`y8vF=)SJCCSB-S?8xpILi z!T$Z$%tihJT=<3Y=%2cDM?2?bxj!c)+@w>^8unzUx+KVx8z0@E-o!9wFT9o*_H93R zz5KTCv#Qbl?4G4xD&veO-J##q_f#3pcIPj+|G9BHa^{efpr<&v#Mj-|=jQEQg2yM{ zDT&C}U?|yeU%h;$mZpU1DI=A{*CS^=$}7{|*UH_nNQF~r`wl;ZJLcN<(zegaB<3<3 z!Q-aq2|Vwj-(7)2@Lj@W}|*OH!Ok8Te&mu-K-_xgeUd=6MUccDT`26}?Pg~na&sj!i(#}UL!l;rb zV7fg(4w{^|soU-ohmR`N4W`*HrY@T8B|r5hwtKG=DUWK^OXiU(362QNvm?@|?4_>d z{&Np^EdtnYhJqA{R)kZ)i$yhX$RYD!s9zdvhjvPKZS_*sBsMOjD-HBp&K1VRs%K)j zYrLad%3%)Gc6W2y53`v&4TE%i=R?lTWay+8nu?phBYfQEB!+sE=Vaxy{;)y{#dofl z(kUr1OfS_Gbbs}pa=A>`gDwTTvi#k$lf7isDf}e?-NK81t`!3JXem>o(cVb!wzUKOx*{Qsajg z+qGZ3jk;3OKYD2Q^{0g(pUR=mcl2F)no73C(xh3A+}Rhi$it&P*M5l)d37&;{dIt~ LIgwCf=0p1r&@QoW literal 0 HcmV?d00001 diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/script.py b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/script.py new file mode 100644 index 0000000..ca7ec2d --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton/script.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# +# Beam Analysis -- PLACEHOLDER. Nothing is implemented. +# +# Added 2026-09-05 alongside ConnectionAnalysis, on the owner's own +# instruction to put a button here for it. Same posture as GravityLoadChase +# and LateralExtract: a row that opens is still a row somebody clicks, so it +# says plainly that nothing is built rather than staying silent about it. +# +# No scope has been settled -- not what a "beam" is in this model's terms, +# not which code governs, not read-only or not. None of that is guessed +# here; this button exists so there is somewhere to build it once it is. + +# pylint: disable=import-error,invalid-name,broad-except +# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead -- +# pyRevit reads it only `if not self.meta`. It is kept in step so that +# trimming a bundle file cannot bring an old name back. One line, no +# `\n`: this button is a row in a `.pulldown`, and a row has no second +# line to put one on. +__title__ = 'Beam Analysis (WIP)' + +from pyrevit import forms + +forms.alert( + 'Beam Analysis is not built yet.\n\n' + 'This row exists so there is somewhere on the ribbon for it. Nothing ' + 'has been read from the model and nothing has been calculated.', + title='Beam Analysis', exitscript=True) diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/bundle.yaml new file mode 100644 index 0000000..c7b7be5 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/bundle.yaml @@ -0,0 +1,12 @@ +title: Connection Analysis (WIP) +# No tooltip and no author on purpose. pyRevit renders both on hover; +# the owner wants nothing there. Guarded by +# WillTools.extension/tests/test_button_tooltips.py -- do not add them back. +# +# One line: this button is a row in a `.pulldown`, and a pulldown row renders +# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown +# grows to fit the longest row, so the title may say what it means. +# +# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the +# owner's own instruction, alongside BeamAnalysis; nothing behind this +# button is built yet, see script.py. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fae8230f4fc17f73e3053edcbe8f7bcf0b5c73ae GIT binary patch literal 3541 zcmV;`4Jz`9P)FD+CPKEFZOq6UV8sF*KCQLW<*AHBF=Z6**C8UDQ?%-H_Qa zCdP6tB_XofC~jRTjpz<;8#T&@E61!MZYZn~ws*aYvH37TO_u#+nUDA0y{CV?naKic z*xB8Ix63=y)BZ6tJM+%(+;h(TopawIOin13f>^oo0cvV$=gv-WCIf?+EtlPMDg{FX zp71}L$cLCg*=$NQH)r*gucgy>)Hfm_1~G`n8Km_=0F8}}*uMSv;FYhnwWVZdXLdry zS1kaPPCpAF__&DMr6R`$J#bXk#Ylb}LojY23_WH&PaV?i5uN7zk zU-^5Ux-)HA^nF0gDHOCaX*8qm5w@k@UYbl&if731+bC8ZRD~YsfYaZT$Gc z{X2L6HveHDjH$@BHpu2?=x;u`;(LjN{L;vX=Ms?$ExsDp#BMyUtYXm|F(cTr4nh!)B*=7 zp*@FJw`5LqGed|Hcf*$&#TrYAC9cZ|m0vUc>xeBCNNfRs#~xcJ#bb}H^HWiUPg#}_ zKCcl4+6#D+!iEv#8GvE8{vkMaK?a*TW{7T_u zt}y}3;R19`T`;CP8fl0DV3o~6Kl8OE4IJk?J&zFtdRYhwW`@=-fZ$Yjgs>ol0J8y4 zpz7rB3D`tz5ib)F&ZDg_lsy+wH5OhEQb&(Fo}Hsz!{u8aR7(^=LO_) z1BgWGz!S__gz)vPg5l}~wC5liYrtn7{T7xiy+4ooU;V#q8OO@u#2vC)77Ag69=LB&II_LP!(} zLs)mu=dt*spM=&fq_jXJ0+j*zmw=QOjByc<&%&lp?||nOCdJl^AXvYY7FMr)1k4N} z#57zR%}FLPB~`rT0GKF;QwAUwZyeYA*=UfEl?78M41pPlMv|2v&TQa!3(~Ct zz*KNJ1cGfRN2PdI2Rdd335nj`cX9k!79X5BiowBiU^Yl3=3?P(tFdbJ)(VZmDW$ zC8??6aLP1-?+U83Jo;TE9nW!2N(;6f2SDgJ`a=v1 zoWpU0tt&!eGX5deZQ-l(F8Pm&p1J@hoMxY0?7Ep zz_Oy~{Y?)p4*wsBM3tYfE)kh=R7BZo9Vdsu!E=z(s>+x9E>R($A4GrunJKTh`YVy( zc|{ELpMg^Lg#W(?_$iY~T5fHEu%8?d&Yyo1eHY$?Qqc+jP7!3j-Lj%cB<7ZaC{-Op zEIWZ@a^7Sf3UC#m#^{RMedXuh1cRq{MeJIERH+DVyZsNUuDcQmcwP~+XD`O=Ig0`C zRiF3M*y=8TDty;Z{;LINPXF(uIwJxNKsj+)z2-9zB&%{ILMS-SB{Z#m7(%Eq(@=lE zQr+dcd7OIxmw55}kK*vJUPLt3IO!Bu1s4DbW{r<5{sh+Eox;&0KR|u`LTK$=w|gz0 zAHc$et8mXJz6fS>S*BJF@UQ&$9-RB&4cK;a+!j30e-{1yr@+P_9&enI{Y78_C@)ZE zgFpV0R`mCu#;NyTM_t`~7^5q@;L#Q>ghW0+gm`=o9!c#jxd(HFkM@1`xewkz_d72m z7Hx!KZ(PrFkjjQ-#Y($z9pHk@l(s4|qOk`2*%$r=|B?Mmyz|b_pkxHfic}a^W&_vF z!*z04uy7?FN$th&Eo`b#8DoiUClHG@AQGvs!iSryfS=(j0F_NtG+K|(ec?qMKeivo zI`*Nr_XP5}K`;Q@j$_`uWw@(p3+}z|ONd00*HnHPG=C-ONd?gv55Vt0U=RUo?|u|( z?|u}+L+3Fta2CvF6eS`qSER1~YtyMLsV1)!y5X&i z6*_>8A5u%Sb~U_IP(TtBr3i#j$X)8g(9o|-fyvzXrYIpx#d$FH!QXZRB%f+L$K9v()mc1&0%mqP#{VT8DFIJMh9kZpDU;e}iQ!HX;@a))PZrH;;24ynzF+ zJcrY#4f;%P`}l^UjkR0yAX4kxfX;h^3qW@1-FC?NnL0HK5c zgaCvR0uTZaN(ev*Kqw&qApoI-0E7U9QY8R|A``$N7`Tz0-@d*@It@{XeDop+CzK+D zTrz4r@`|YFm`fJ)y8s}%xq{tu*b3TFt^Bmf)B66Ct3 zJKrUbGJ`5JWKt>L;#u?GJB}%m-|ZT%f+>bTDI#L=OeWjql!v;B@`%>9HprIDiEhv1 ze~ZPWU=CNoR9e4nOOY>l?kH=6wSpj7EJ97VktpnsO9m! zCyPx@ena_XqYT^I!A(t&U*6W+w|@QX;bcNR><@Dz8LDtneNtXLu2ep6vVWKE$@tT@ zyYz=nQ!P)=-u>kA{mF#-?5JU$p;X3MuA?~0EBU4vIn*}lrU4;(nd0EAUo z^s*=PL$R2=-F1x{u7s*BU)#2j3=FsqZ%t>q-+S;u(1s1y8E0EYXUCqm26)8U>^SB~ zGNCMHcEikQ{B9=_krIS~e9mnCdZy=STN~t9qs_A2ltpVdLaGNyH>OcNi9kWRV-@5 z%uM4w9d~Oeo^4x5DG9D;_6-cvwmo~g`^rN*KkS3o^y}^Il^J7VXS(mzht|(}nS}U= zlyrw>386I;z+Q=FEuXrgneSozqXq;v%tW?L!nUO_Y~FTU{MC-H_p}~5bRnNkLu}i2 z?c<~4R@P`F_w2s&j|B1GGSddz7K)kSPlyAInFhxtCzsO;K?G$Hq$D_w0W;nL^S5%Y z{6Qwu<)qUPS7tkt2B7Sc$}s`}p5482C6oEI(Yzjnk1?Y`DN!$GDv?1zv1sxP&JpN1 z5e`}wUcQ*``Bhmb*K;|O0-!9Z6l7Z)%xHw4d+zsRb}<65WM zh9A$I9KPH`q-1k*)>LbHlk(U8ig-XYHMMg!sLuqc+NkloBlC8wlvNP81mhL8o)xb#nK+r7+AP6d`42(G6%yjpA@7}Y2yq;kg z2AS#ZftT@}s$1PPZ>sCQbME>6)jfprh|ti)n6rmaUoY6RN07~0Ol%@FG%)7O3E#P) z!GcIi|FKd?gwWi~Sh~~64oXpMp8iOBgWn1aLEuXLUEps7ifdieAhIZwzmDi8$# z44Rr$-JWAti8b>`WC4I_;(PTDNJB(GL=%SlB9fDKsw=Z4V zTdE*N&!C1Th34?YPhas!9clR{KuZa@f?OA3Eg%930st~`?T^v`Gl&@y1Q281A);SP z;ID6c=+51Pf*8R%lrDfwMkAXIYL~B??7GSC5bQU|P1K3+LwrvFGJp^WfPgWPiO2#1 z2nhftrI_3VtXJ@e_?Lb@zq)$i`Hvd3p6;LKa$Y$*ytz3H(E47{zRhQX( z4lp(Vz@Q1v@KKm1fD8zllPOr=>xQxSwmtCO9~BQTzn@8>_jR<5jf8C0&iLLBGf=7# z0*L1YAgBpt-zy{vh!`+yu1Z0?d{h64u5b1{ob5nH7+D%o2FV1h zpEEE9V2YW4ro&9c5E1dcfRpJfHLDx+yoHY!9+;m)Cc|iHkD(TUWpT)Tl&)nBvy zy*@y?s0s*!~kHYNuZ@p*vT=qS=?==eOMoD*`T_4vXPJ%8HD zb+g+a%IF@LT4b?k>he$Sc`f+j6gK-IpllfH!dU)2-AE3!2VWY>~9qSu*3@m0+7Tp|M+PD zi|+weuBltICXJS?VNBmEVHAW5AU0;wxv-%`llo3j5`Zw8n@#QAt7`yUF1|nU>-p!~ zb{cERO{6Ay=8C~Y%vL1`B2LVqb4F~75hxFlj>LR9mm**(p(%fs*4k+`no30e6gHhA zv8RFG5mYMA)0N3m8NM;Fc6^+;DlsV!tUxe6drBw;JJ69l-va39%V9=M8m?BARe&P( z75p)m7NnRNYef~`lL*k--HR%%;b>Ks5Xu%nU@h)jvH)NH^v95L9DsmIr9=bup1*nQ zKe6S&A*fMSNSFzIIS;@1>sxU3MKcF$MHOlL0x(vvY2WL(?^m1A(U*r?h7fc)W~ZaC zADJZ!arceajjO+M6pz{F4rsuc>PF?#UtRT4TsV0WZr-r*%&qm&9F|hUS>G7UsHw(R zul)p!6&Py=>ok>GC+j^Z-3JV*6;*Qi#^N)VUW98uG7~2X9u(8L)c`AkgrnhVhGHrv zc&MncC6tG5q`|sT>k6uHy#_}hg9+yPS zieSfEt?15`$NaKO5h4=Sf1vRE8*dM8L@F3z2!NEMkQaEMV6r;w`@`aK^%`O?j}=Hfqw6$w!hG?0Ds zDP@X*8x;ZIst`dLg%r|`<#NAcM9U0|Xy*==hDiW$3(w4wQxJy6VOT=E%wxOOrEYau{nP!U`y zR0N-Pm**H)QLBu#n0w)LEMI&b3PFI?Pd|sw{ydmQ@9+jL-n@U)Rd$Ct1E z7>o@YDJRhqr7uE(&9V3g-cpLq!zwRI)i5k6UqzG5y+GZp5_eG}it01-$-VCzu)bEQuHq z-~|TX`=8C|%@^?fKU;{4r%r(_nzv}6B508Jj7)~R6Zu{NUL&m$g(+;zp%#4f!s+;?6rNLW(b0op3Shf?D(yJgnRN z0?vefG{wX9uiJLwwYGL76D|^358_KBfr=nla$E?}&f_@VkA?=tSX^rXKz#G8m|)b7 zc_=F4nf-4y(w4%w_l?PAtwXs`g_v+QtvuMJvF zM-ZG9&3dj;5lPoU-VgBLrma{qCrr);Op8bu_dmSlMYMMJqHa3wft zO0w!S-!R^L8pG*lo68+I*5__SCc{|TFl>(4oIL^&vcmU^SLlUORCcj@9LR{pROKz%&{VYGR&jMvg4*jumqH+-! zM;v8{Q>JIvtyuForzib|?d$bJpMc0s2ux@VM=A8=3xfdOh)q;Alt?NB{+qf_KKV{f zPuK7%+ozxIEn5Tt>wq^YD>qP8@=xIVJ}6q6Ktu?vMc$a`GT(8Ph;&lk`(o?5J734b zg@o&`Kg&4VqI9}ft?0}Lg`31HZ0B^f0}&q)1Nw!@S_GMIC7?u73D8Mi|4ql%EZ>2q zCNeX;){{hgr7Gr#n-hEvVagd zE^{Kmuy(f%ynk%_r`sEQw?7lkkzcmVR;HgYM3U3*edIZY7eQs@;tu5p;J24W^W>(%91) zOARqZ5KSq@C4!G>O=>q9qj|8SNOX}^U>HDAHfVwU;`JHp_J%=#3LE$h&ef=zIYZ%M*+hQDz!eDrhUUQCxV+I5+`kxi^ zA!blxEG%M;G2QoXiz1V&1VUn1ioOQ8fAQvneP0eYhh?#guTTIe5}75V(H7VHV#rh9 z{=y7bn3>S#d=QOhjRp`DgBe^Lz@@qF!9)aN-o-#W5jIFAU$#VQbXp4L8l8M!Us?cH({>8GyIM*?79A*$}Vi1VH3_%#igJ8f6CNMxk zN)om$kWQO40c%qU-22lXY~2}&Kt!9s1K9@}Sml7CQJCwa4YQS^<+e|qo=R$HV_bj) z0Rkgirbd({R}pA*D(Sjl z5D`fN2qSzDfe0i3u;UsHM$qS%mwxGjvGr4@)%{`FJs-y-k%(w%Y2iWu$O(VZ9aA3; zRH*Nz5?X7+AR-AW3;qaL2r$@njO!1oX=LN8Grm0TnIGNNoQOmqT3QZQNBs&iYecTU zW9q}fkXn%FavTsTlvjKKs)-$cP+94e*=V|)OCPv<#{tT)sh;l>{TbX?V@zKkX=n;n z=7evRgaN)1@zeUfu$mds&rjl9%<&{4O z6r`IrP9UWIX8O!AxaIo)UKef-%eGh>AFhHJ6lnq_12ZbS-Sj3UX_AxHhKNL2l7rDj zU=lD=k})p+cKPjFH$^xiq7*f~R8Vs)ETU1Eu2gbyARs0=Y3-JUe@y%aV#5%MEDduX z0H7(_#|X2>Yp#u)KH6v5+lgqDahb|&=a>#{*+RN0{nZtbZR@f`M*u*iX_f@QwzRMJ z`DLAU4VQ&~>^(Ljzz}^O465H2;|zBN7@$RqCfjC=ztvJS$#o2)tbUFQ3_?PiG|qe= z+PWjc5aM=@2mtKX_@*gE6J3W<7XERw$J(}~0+~wzVDEJ^q}qGka}t0N3V&x=Lb$H$ zl1DuY<-H@#(JI^?Gl-c1LFWNjwsYquRy%iY%IG4xfSEx>YJaU=WD@{9tVxYGKS8pH zMMuJQ7|b~5drPN_Ziil!Q>RzWdQl>en#laFNQE0HGli z0H_`>4oY=v?Sh%X%){CsGaC?zqKt6QA%rJyg<}`}%L#sXGa@>s^DFcHAs%G_$^euB zD32i=3Pa+;51p^Ft+r+TsYA>0k>L~s80-hT^kZT~HT{_6Z)_+#+hQd)>7I#3xJ zg&XHShPv9n&N)>hN(K4sAcBEv1bvk-$8!fzF7gE_6b^NLjH##3!ZqLeF-F&%QWC-+ zA|q^IjD}%ze0P8sWQQO^N7sH#pYk=#4=?kAXC!xjKR0@}E6+!W@1<8EAd&Q&mMK$*XdXg}b14=E%ZHx0uEbH8~nW}Y<%hIK~p zlvkv)3_#JsSLjY2LRDxq7JmB?OgMEqv~eNDz|$NmyC8;>@VnyeIPEiM;--0PFyYkc zFa-*K-zU2fPj-|6D3otXq0kvWfGLw^;--0PaLTyPXM~!+@He%-fEAC=ht_VAYZ0q*dUqi#^o2@ zh&i*DfWcrk12Ubgu_cmt^7$x!zu_?i{8doWKEBT3Z~;IF3D->{?Iba8&JQv3tZ%`v z&IncHrE(I^4mj5Zol zNW_yJSn>EZ*#7PY)KrdzVO{hIhY|ZtHO*6Ca+p~8YRyBVY zAMSq-HC5xFwKGzpp@U!f2RruRj7c-G=*mYC^jBq8bbd@n3WXi-ZNRGL#pvkX?|oi7 zWluPdLm(g`bjAAd=gaTN1=Qp~OebrUg~Wz8pTT3#-U8e5BjB%uHf}i-F)+>a z3~E>6ATFNy9sJ|pM{+D?V47(FA_$Ur_QiYg;`)c-_k}$6WHidLpuCw*0`sm|igRZ) z!k9dnPKJsaHhA*6CcO0OBdD$z0|uXPnt4(JAcaCQ9Y-ioh56xSIIDhk-b|+_&0P7! zb=dyytEj0Q2W@mo9-LRDc<9wj9gfD0a~~^+=@{ce2!%iI+ll}9#Vy$V!DdtjN0p4e zFL?l@P&m}NA5$iL1>Xt(7-MQbTQJSs{lQy!V9f$_bbo@1U~NeYzk~oF2qDlFZ^sww zXZK1S4lI06uS=n@;mv39_{IvRmls#Bmfvp>X6ywt_!Vu?dN#v*LT2DK1iW5 zIv-^Kj#uh1%XA396VEl_rS*@Xs-iBpBTzn?I55pjJBgxFhdr6js@P&|-tjwBSB`-( z=A^qKsD_N`Bs)+MtSLBkn0?>J?R)UE*iG26`*l=T)SVRiz9aw;LZK^u0QHm4$J}q+ zoylGm9#Fe~^*nTTe}d}Dx?C2k3_u>EE8dPN6K3M3tA7r^ucDyTp%e;lw!Mfo|8*O> z6CIg=+Q}>Y6AFNLvCZJZuUv^QHq6dtpYll^W@A6=H#KARFPFf!0`S>^lUD5v*$yBQ z`0}*NbAl~2b@;mtKSL-`liLv}U(i5NXG1{)Uy$fGbr`7ecEm6$hM?U9kJs(N;!2ntN}O~QimlE0JF(FhZn5LzS5umFSXR+(dLo+y`SCy87ct0 z8u~<(Ntz4zeH9tXn16}(PYYidJo$Xo3#e6&@s16iz@i`d&Z8%Z$U2YN&lY>0R25#3 zL%`JzV2oD?nGL!V2XX)Et1~~U_sXG+VZGv#TX5Pb(|T1KA@Xtn53aoq+uwT?6`@gZ z-Si2syhVA;qwjwH;KS{Z(t_ira<55g_1PuI5C9?p*G(bqBvDm421?qwf};$^pZ?P7 zU5e}Hb15f*r=PzQi>`VU0bgbA2kzVde|X}#2;Tp2JI2>d&gpv^qAFNNn@8VaSpig6 z)Mb1U0@&bS=SLtS`0P;LAgFwW?>I@+RFB7H7c4~mq;ui3gSpCB+3kC@os)Kwu$8}u zS|LPl9ya}rES^X&|fq%GYF>0&E z!_g@yQQR5&{*3C0x(IQioJb`tG=BXy?A-l2H0xYt+@b*Rit?pVQ#lR|lg|S)Lkats zvXkLdT}w#|%m!mepN0viegQjozlMOX5(c{X8#2rcOWCk2--s*DIv88x)xzNXt|+Bb zhK>eCwisV|6d3hAN*M(L3jh$22*g<5s7?^xmWsgraIi7}$EdV1g9Q;eY2B?s?*M>k zYXe22Ev^9VC+c0kl9{2TjrPuc*z=cG&(N@2mM)GQOk{Xz*FAgNU|W80pB7?7AY+gs zm)ikA)Yi@r8E{?~f-)WAdJ{%LgkP;&4hBOBE0-oLKfNCzBm@b(@bW`A*zqBhvis}= zHp(IhqRnH_77vFZMf=4Q4FDL&ysY)%rHc(4`0Nng{nI9_Tyrh{{L$OFkVN_E{T%H4 z2+c3tg+KgpH3I&s!-9!SU?B<2ygnL*sjZzMRC73H2!NXozCl)UpOj*(ak*Cwb!G#f zFNDoIevh5IU&H47dxK>kU%X~V|gHvrIn@kB!a z(A*rBjb7unRe^xKCXwjBhky_g#%QF{i6ObtS?YD|ZRLkjcE4eCW;T{B$w`|XAGMn4 z&CP8N5m*7hws5^y-bmv|PTG8%h^$YeOudZ^0Qh~Oa*LV%C{E_~{gK`v6$vM0?v2IT z(v7jO1Y(*FYhM_hx;hY&S0%b#mxxqZu*ZawdaK_xyTnJyGe?cuIf&qG5IZV~BeYO9>)Fx2{;RE7jHz&i1ghKKIi+U|B?b~Ta}b5mC}Z?#~RAca8~ea*-{Eg(5`vvK!>yN@q2cB(%0xB&z|xb zX5^h70V3juk=VL(&L8~}1bt3QFF$waog6WE$jCuA`yrO7q*#vs<{t1dLntlT~ohCL^m_@g|;n~VL&?uh5=)ScZ@<1 z1Q9`51R)7-+Q2YxWyViFbj+Gqv@IQpK=frhg9ae0lCm}e02bX(cn5Cz|p9hAQIcEl{v)-aX}L47!k+}zv7 gax4}r`hxQR19RfNUoz{35C8xG07*qoM6N<$f)fdKWdHyG literal 0 HcmV?d00001 diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/script.py b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/script.py new file mode 100644 index 0000000..b1680ef --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/LateralExtract.pushbutton/script.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +# +# Extract the lateral-resisting members and write an analysis input file. +# +# WHICH MEMBERS? The user says. Not this tool. A member is lateral-resisting +# when somebody has typed the marker into its identity description -- by +# default 'lateral-resistent' (the owner's spelling) or 'lateral-resistant'. +# Nothing here infers it from geometry, category or name. +# +# READ-ONLY. No Transaction is opened, nothing is written to the model, and +# the output files go beside the model, never into the repository. +# +# WHAT IT WRITES: a JSON intermediate model, two CSVs (nodes, members), and a +# RISA-3D draft that is deliberately named `.r3d.DRAFT.txt` so it cannot be +# loaded by accident. See vibe.latrisa for what is established about the RISA +# format and what is not. +# +# The selection idiom follows Ticket QC and Panel Lift Check: what is +# preselected is what is checked; nothing preselected scans the configured +# categories across the model. The difference from both is that this tool +# never asks the user to pick pieces from a list -- the marker IS the pick, +# and offering a second way to choose would let the two disagree. + +# pylint: disable=import-error,invalid-name,broad-except +# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead -- +# pyRevit reads it only `if not self.meta`. It is kept in step so that +# trimming a bundle file cannot bring an old name back. One line, no +# `\n`: this button is a row in a `.pulldown`, and a row has no second +# line to put one on. +__title__ = 'Lateral (WIP)' + +import io +import os +import traceback + +from pyrevit import revit, script, forms + +from vibe import designspec, knowledge, latextract, latmodel, latrisa +from vibe import runlog +from vibe import scope as scopelib + +doc = revit.doc +uidoc = revit.uidoc +output = script.get_output() +output.close_others() + +TITLE = 'Lateral Extract' + + +def die(message): + """The user has not given us something yet. Not a refusal to extract.""" + runlog.record('Lateral Extract', doc_title=doc.Title, outcome='refused', + report_text=message) + forms.alert(message, title=TITLE, exitscript=True) + + +def not_run(message): + """'Could not judge' -- a RESULT, and it must survive on the report. + + The distinction this whole tool turns on: NOT RUN is not the same as + 'ran and found nothing marked'. NOT RUN means the question could not be + asked at all, so the absence of marked members says nothing. A run that + asked and found nothing gets the ordinary report, with a nothing-found + banner and no files written. + """ + output.print_md('# Lateral Extract — NOT RUN') + output.print_md('**NOT RUN** — the extraction did not run.') + output.print_md('> {0}'.format(message)) + runlog.record('Lateral Extract', doc_title=doc.Title, outcome='not-run', + report_text=message) + script.exit() + + +# --------------------------------------------------------------------- main +# The main body is wrapped so an unexpected exception still leaves a run-log +# record (outcome='error') before pyRevit shows its own error dialog. +try: + # -------------------------------------------------------------- context + know = knowledge.load(doc) + for problem in knowledge.config_problems(doc, know): + runlog.record('Lateral Extract', doc_title=doc.Title, + outcome='refused', report_text=problem) + forms.alert(problem, title=TITLE, exitscript=True) + + selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()] + scope = ('the {0} element(s) you selected'.format(len(selection)) + if selection else 'the whole model, in the configured categories') + + # --------------------------------------------------------------------- run + with forms.ProgressBar(title='Reading identity descriptions...') as pb: + pb.update_progress(0, 2) + model, found, why = latextract.gather(doc, selection=selection or None, + know=know) + pb.update_progress(2, 2) + + cats = knowledge.get(know, 'lateral.categories', None) \ + or latextract.DEFAULT_CATEGORIES + + if why == latextract.NOT_RUN_UNMAPPED: + not_run( + 'The `parameters.{0}` role is not mapped, so this does not know ' + 'which parameter holds the identity description the marker is ' + 'typed into.\n\n' + 'It will not guess a name. A guessed name would work on the ' + 'template it was guessed from and silently find nothing on every ' + 'other one, and "found nothing" is indistinguishable from "nothing ' + 'is marked" once it is on the screen.\n\n' + 'Map it in the plant knowledge file and run again. Self Check lists ' + 'which layers are in play.'.format(latextract.DESC_ROLE)) + + if why == latextract.NOT_RUN_ABSENT: + # A literal role path, not a variable, so knowledge_drift can see it. + names = knowledge.param_names(know, 'member.identity_description') + not_run( + 'The `parameters.{0}` role is mapped to **{1}**, but not one ' + 'candidate element actually carries that parameter.\n\n' + 'That is not the same as "nothing is marked". The field is not ' + 'there, so nothing could have been marked, and this tool has no ' + 'opinion of its own about which members resist lateral load.\n\n' + 'Scope searched: {2}. Categories: `{3}`.\n\n' + 'Either add the parameter to these families, or point the role at ' + 'whatever field this office really uses.' + .format(latextract.DESC_ROLE, ', '.join(names), scope, + ', '.join(cats))) + + # ------------------------------------------------------------------ report + output.print_md('# Lateral extraction') + output.print_md( + 'Model **{0}** · scope: {1} · {2} candidate element(s) read' + .format(doc.Title, scope, found['candidates'])) + output.print_md( + 'Marker tokens accepted: {0}. Read from: {1}.' + .format(', '.join(['`' + t + '`' for t in found['tokens']]), + ', '.join(['`' + p + '`' for p in found['params_tried']]))) + + # ---- the declared scope, and why it is NOT applied here ------------------ + # + # Every other button on this panel narrows what it judges to the job's + # declared scope (`vibe.scope`). This one deliberately does not, and says so + # rather than leaving the reader to assume either way. + # + # The reason is that the marker IS the pick. A member is lateral-resisting + # because somebody typed it into the identity description, one element at a + # time -- that is a person choosing, not the tool choosing, and this whole + # tool's rule is that a filter never overrides what the user picked. Beyond + # that, a lateral system is columns, beams and shear walls by nature; scoping + # it to one discipline's prefix would delete most of the frame and produce an + # analysis model that looks complete and is not. The line below is printed + # every run so the difference is visible on the page. + _declared = scopelib.of(know) + output.print_md( + '> NOTHING WAS SET ASIDE FOR SCOPE. The declared scope is {0}, and it is ' + 'deliberately NOT applied here: a member is in this extraction because ' + 'somebody marked it, and a lateral system crosses every discipline on ' + 'the job. Every marked member is exported whatever its mark, and the ' + 'members another engineer owns are as much a part of the frame as ' + 'yours.'.format(_declared.label())) + + n_marked = len(found['marked']) + n_amb = len(found['ambiguous']) + + if not n_marked and not n_amb: + # Asked, and the answer was nothing. Distinct from NOT RUN above, and + # said in words rather than left as an empty table -- and critically, + # NO FILES ARE WRITTEN. An empty RISA file on disk looks like an answer. + output.print_md('## Nothing is marked') + output.print_md( + '> {0} element(s) were read and **not one** carries the marker. ' + 'The identity-description field exists, so the question was asked ' + 'properly and the answer is that no member has been marked as ' + 'lateral-resisting yet.\n\n' + '> **No files have been written.** An empty analysis file on disk ' + 'would look like a result.\n\n' + '> To mark a member, type `{1}` into its identity description ' + '(`{2}`) and run this again.' + .format(found['candidates'], found['tokens'][0], + found['params_tried'][0])) + runlog.record('Lateral Extract', doc_title=doc.Title, + outcome='refused', + report_text='Nothing is marked - no files written.') + script.exit() + + if n_amb: + output.print_md('## Ambiguous marks — not counted either way') + output.print_md( + 'The marker appears in these fields but is preceded by a negating ' + 'word, so it may mean the opposite. They are **excluded** from the ' + 'export and named here for you to settle.') + output.print_table( + [[str(r['element_id']), + r['mark'] or '', r['category'] or '', r['desc'] or ''] + for r in found['ambiguous']], + columns=['element id', 'mark', 'category', 'identity description']) + + # ---- what was selected, element by element ------------------------------- + output.print_md('## Marked as lateral-resisting ({0})'.format(n_marked)) + output.print_table( + [[str(r['element_id']), r['mark'] or '', r['category'] or '', + r['desc_param'] or '', r['token'] or '', r['desc'] or ''] + for r in found['marked']], + columns=['element id', 'mark', 'category', 'read from', 'matched', + 'identity description']) + + # ---- what was skipped, element by element, with the reason --------------- + output.print_md('## Skipped ({0})'.format(len(model['skipped']))) + if model['skipped']: + output.print_table( + [[str(s.get('element_id')), s.get('mark') or '', + s.get('category') or '', s.get('reason') or ''] + for s in model['skipped']], + columns=['element id', 'mark', 'category', 'why it is not a member']) + else: + output.print_md('> Nothing was skipped.') + + # ---- what became members ------------------------------------------------- + output.print_md('## Members written ({0})'.format(len(model['members']))) + if model['members']: + output.print_table( + [[m['label'], m['i_node'], m['j_node'], + '{0:.3f}'.format(m['length_ft']), + (m.get('section') or {}).get('name') or '(none)', + m.get('material') or '(none)', + m.get('axis_source') or '', + '; '.join(m['unresolved'])] + for m in model['members']], + columns=['member', 'i', 'j', 'length ft', 'section label', + 'material', 'axis from', 'NOT determined']) + else: + output.print_md( + '> **No member survived extraction.** Every marked element is in the ' + 'skipped table above with its reason. Nothing analysable came out of ' + 'this run.') + + # ---- what could not be determined ---------------------------------------- + rows = latmodel.unresolved_rows(model) + output.print_md('## Not determined ({0} item(s))'.format(len(rows))) + output.print_md( + 'Every one of these is **left empty** in the output. None of them is ' + 'given a default value. A member with no section is not quietly assigned ' + 'one; the draft file writes `{0}` for it, which no analysis program can ' + 'resolve, so it fails to analyse rather than analysing wrongly.' + .format(latrisa.UNRESOLVED_SHAPE)) + if rows: + output.print_table( + [[r['label'], str(r['element_id']), r['mark'] or '', r['missing']] + for r in rows], + columns=['member', 'element id', 'mark', 'what is missing']) + + # ---- connectivity -------------------------------------------------------- + conn = model['connectivity'] + output.print_md('## Connectivity') + output.print_md( + '{0} node(s), {1} member(s), welded at {2} ft. **{3}** node(s) have only ' + 'one member reaching them. This tool does not extract supports, so it ' + 'cannot tell a support from a disconnected member -- both look like this.' + .format(conn['node_count'], conn['member_count'], model['node_tol_ft'], + len(conn['free_ends']))) + if not conn.get('near_miss_checked'): + output.print_md( + '> The near-miss search was **not run** ({0} nodes is too many for an ' + 'O(n^2) scan on Revit\'s UI thread). That is not a finding of "no ' + 'near misses" -- nobody looked.'.format(conn['node_count'])) + elif conn['near_misses']: + output.print_md( + '**{0} pair(s) of nodes sit within 6 inches of each other but further ' + 'apart than the weld tolerance.** These are almost certainly meant to ' + 'be the same point. They are **not** merged here.' + .format(conn['near_miss_total'])) + output.print_table( + [[r['a'], r['b'], '{0:.4f}'.format(r['gap_ft'])] + for r in conn['near_misses'][:40]], + columns=['node', 'node', 'gap ft']) + output.print_md( + '> A member ending part-way along another member is **not** connected to ' + 'it and no node is inserted to make it so.') + + # ---- the standing caveats ------------------------------------------------ + output.print_md('## What is deliberately not in the output') + for note in model['notes']: + output.print_md('- ' + note) + + # ------------------------------------------------------------ where it goes + # + # Beside the MODEL, never in the repository. + start_dir = knowledge.get(know, 'lateral.output_dir', None) or \ + designspec.sub_dir(doc, 'lateral') + try: + if start_dir and not os.path.isdir(start_dir): + os.makedirs(start_dir) + except Exception: + start_dir = '' + + if not doc.PathName: + output.print_md( + '## Not written\n> This model has never been saved, so there is no ' + 'folder beside it to write to. Save the model and run again.') + runlog.record('Lateral Extract', doc_title=doc.Title, + outcome='refused', + report_text='Model has never been saved - nothing written.') + script.exit() + + stem = os.path.splitext(os.path.basename(doc.PathName))[0] + written = [] + for name, text in latrisa.file_set(model, stem): + path = os.path.join(start_dir, name) + try: + f = io.open(path, 'w', encoding='utf-8') + try: + f.write(text if isinstance(text, type(u'')) else text.decode('utf-8')) + finally: + f.close() + written.append(path) + except Exception as ex: + output.print_md('- **could not write** `{0}`: {1}'.format(name, ex)) + + output.print_md('## Files written') + for path in written: + output.print_md('- `{0}`'.format(path)) + output.print_md( + '> The `.json` and the two `.csv` files are complete statements of what ' + 'was extracted. The `.r3d.DRAFT.txt` is **not** a verified RISA input ' + 'file -- its section keywords follow RISA\'s published Appendix D but ' + 'the field order inside each record is unverified, so it is named so ' + 'that it cannot be opened into RISA by accident. Read its header before ' + 'renaming it.') + if not latmodel.is_analysable(model): + output.print_md( + '> **This model would not analyse correctly as written**, and that ' + 'is on purpose: it has unresolved sections, free ends, no supports ' + 'and no releases. Fix those in the analysis package, not here.') + + # The extraction ran and its files are on disk -- the record is 'ok' with + # the report text kept short, since record() caps at 20000 characters. + runlog.record('Lateral Extract', doc_title=doc.Title, outcome='ok', + report_path=written[0] if written else None, + report_text=('{0} marked member(s), {1} skipped, ' + '{2} node(s), {3} member(s) written; files: {4}' + .format(n_marked, len(model['skipped']), + conn['node_count'], + conn['member_count'], + '; '.join(written)))) + +except Exception as e: + # The deliberate exits above never reach here (script.exit() raises + # SystemExit, which Exception does not catch) -- this is the + # unexpected-exception path, and pyRevit shows its own dialog after + # the record is written. + runlog.record('Lateral Extract', doc_title=doc.Title, + outcome='error', error=str(e), + traceback_text=traceback.format_exc()) + raise diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/bundle.yaml new file mode 100644 index 0000000..4c6c467 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/bundle.yaml @@ -0,0 +1,17 @@ +title: Panel Analysis (WIP) +# No tooltip and no author on purpose. pyRevit renders both on hover; +# the owner wants nothing there. Guarded by +# WillTools.extension/tests/test_button_tooltips.py -- do not add them back. +# +# One line: this button is a row in a `.pulldown`, and a pulldown row renders +# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown +# grows to fit the longest row, so the title may say what it means. +# +# (WIP) is not decoration, same as its siblings in this pulldown. This button +# pops an Eriksson-Wall-shaped INPUT CONFIRMATION form -- one collapsed section +# per selected panel, pre-filled from the model where readable and blank +# otherwise, gated so a panel cannot run until its required fields (incl. >=2 +# supports) are filled -- then feeds the confirmed inputs to the Stage-0 +# self-weight solve. The in-place / wind / service engine behind the form is a +# later stage. See script.py's own comments for exactly what it does and does +# not do. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ca8c1919227cca94a1958227e766d13e2ec65b9f GIT binary patch literal 3110 zcmV+>4B7LEP)q%D<)Qi;(%44sW>n#S5R)<|PfdB@C7szF?6 zeYBdGwh67KW|k(lHK~spSZ!1S#8TZAD$7IhQ4n@vcXoE}z2A5G$IL7XEZd#C4|Zqo zA@`pp-<;g@`@Zw~eYuA)I3c9sys){Jn&!3$nrj8gq(Ny5As*+2wYAhVcdj5_XFw4D zKjue72#t-5x;lOKf7LvesB{guRIPt_MY8>zKc-R&&CR|xpH-aUK?~~>s)@)0ppi;W0L>7Q>k;!(?wTLVT1mqLo(I~n1L3P8L{Q%kkdP5YyX1kR;_tn*P4-~)<>xeW-iR4fAHcqc{l1FiRNcme>BNN|5HiTVw+_4R~g($s8P zmmrJl1%Po}j{pTCLLg^Dp^4d2(Fly=q`KR??mCkE)d@&~L4AXu&KGP^4?;3&W^G#Y zv{fFvPiL|kMuUg~(4T6u%ABCiT|phZn=Dl= zj%z^0XE{U!h~Q+l8#XIUDX%zPlhK$cx&Q)BbfuAYvM3LS5n>Bkj4uShY)C01+WT7T z{SWW%9h6U3!i_0LvQAUO>J_RiTJ3eFJtDG-)yG6|vtnGUz&U|Eaov( z^ycJH`*Cb*eGk9g`8rNy(x?bUiU)y+U<@c6vW@F*2CxYAXS*N~0ug3!y7wYY;rEDW zl6bmkN0>9d#)L=|Pkih9STJ*TNd(k`ZJ*(uzx@fjK04$Ngq&y&*~)m{^{w}9*j8}& zxoufruKOg5etKT${o@QlV&-}wO+%$yC^Xc#dNkumw0eEgbOH5psJcL%0d zPDHoog8c+&0YC)N<^hNhnro%EN2Z#`eiky>*t=}o)#AERtnXLMXv=ir$JcxV8=bPWgozMRP6C+V*@fDPX6lvGs^27IU*o%ZPLxcf{cFL|`+nOb= z3qOHZL|`*x+1#(><4)<63|6kb!jhUfm@#n@vReB}Qw5?q9JXvRHvz!B>31rg_w=0# z0F;>dY}+gZ4If#Lqcx_KSE72-6c7=(glaaT9OF_J3S;)0h3`$ zxRF1^De5446dFb0M;i(=Rz^V zNS<#Ujw2ws#;!I7Y1bK@<-kZcv$PmgM54H{W-dPMI0;1zCK{HCO$S~9ag&=(8v>ABx=GzdpLogzvu1#)Th?Xt@R7GRh z62Aj`KKT%7CkrOVQ0Ae6T*hd9>b%s7O*blb$bYK<+K^YuFH&4<6lq!D1V#1 zCkPLh%ge`@pb+clD$97javF3)0{{X50to;J00<;t!~#YP0V5VLVgVx-Fk%5C7BFG~ zBNi}X0V5VLVgVx-Fk%5C7BFH67_op63m7p3-OxY+00IC42>=KH2qa*{hL{oSSN{kw zYU7#_8;JhG`hXD|21e|>`ukXKhUt`!OGa!U`p;Z{GbTi00V6gPjF>hC)&TYQUjMsg zw+D>aP%vTy8kqjG*8lsH4{_D>%V3NN7_q@HVnjvg9|1;f+%aO>3|xQD`YF?K^|Z^* z;4=b7%r7HG1Ji%T`de;8EEFmY07eTV*1!I9*Z0&60V9SXVZ@5jZ-((g*SKZG2CBcX zzN8AV(Zq;daQ#6)GH6E!07!S1BmiSXr(qTB%}6A8W_P%Q1pgsR4Od$xkbEzOh{-r! zn#g)T2S7^;H6@dt5I)QG#e>gR4*SPjwa%W3SZx5n^OjU`zDKfym?7jp-y))tzu_~u zOI~Eiwg93%$Bv;3@wnpMRZ9#2h}N%()*v6TKty0M<9NCgM^3fnh98uWCle76fUM`@ zQ2TL&l;x{-5fUM#K#+eTnKbj3R52qSH~9~H?=p__Us8(vG-oVkbUNL5wRLZ9Y>Jc) z06Jd@@A>2iT8|$^#Ik(79|0_mE70;H0PL<>Vi-h%R9qccv+*PZFK{SCer5=0Bd7>R z@#KzeIC8QLHY>PB4{Mb>5*G$~_kbSU{zCpV_!(g$hHaaS>+TI_qPrkMsH@A*kcsE! znRv{bwBu%pitmgG1R+*v&vfCgzdVCu=?>VeK**hjvG@>9&q7=C(x0*8g9E4t`y1jT zfRJU85N&KpCY{1iH#%pC+s0K}tn#uSdg(4-W2_W2Iu4HzxFyag*lSGQc?yXt36~rcT7ftkWvU+ zb`Lq@FM~3kRTi_nbXVhnRgcugQ*qUppG@B8b}UTKu5MVfRh7qm$V4rbAP&5=`;?PpKfK4;L^;qBW609UYnnT@`lqmdcLbzLG-gCSqW z;=e&TF(aBowt}EdT=(0DS8i^_qD6$oi~ApETR`W7wGX#Bj#(;>v(qY%*&^ZweWHQ8 z9Tf>HK-zRBv-H5~hFwUdR9~ab``*<}O#(^q$?G1O5 z9>WWVY(+{zJny-#O#AYW*Zt;rVLJJF)AoLUx2Z`WLIAjJ{e#;sS~CA-fId&ksv+BA z(V7I}ksw#K$QbxS(vaLI5CCL^#E^g}WHX1u3~k;uuKV-W`!>{fyt*?tM}GNo^Z%~T z3`}|px~Bj9$Q-rLpO?+1w^>U7)($hs}fYYhrnc=3?N+~-UGtx5PrF} z!+NVHz~*bIVlI#sFR8dfJY{+#eCg&D(elveV%e^aT4q*&S9;dBED&4!0i4=@Px4eB z9>NrxDGqL{CK9|5;HJ4PQVi-PA!ok3@tdOm52>V8#D{MBWB>pF07*qoM6N<$f*PW) AasU7T literal 0 HcmV?d00001 diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/script.py b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/script.py new file mode 100644 index 0000000..8960f43 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton/script.py @@ -0,0 +1,4460 @@ +# -*- coding: utf-8 -*- +# pylint: disable=import-error,invalid-name,broad-except +__title__ = 'Panel Analysis (WIP)' + +# Panel Analysis -- Stage 1: an INPUT CONFIRMATION form, not a lifting check. +# +# Press with one or more structural-framing panels selected. Up pops a dialog +# with one COLLAPSED section per panel; each section replicates Eriksson Wall's +# own input blocks (Design Criteria / Concrete Extents / Structural Model / +# Handling / Reinforcement), PRE-FILLED with what the model could read off the +# framing element and BLANK where it could not. The user confirms/fills the +# fields and presses that panel's own "Run Analysis" button -- which stays +# refused until the panel's required fields (geometry, materials, and at least +# two support positions) are all filled. A bottom "Analyze All" runs every +# panel whose form is complete and names the ones it skipped and why. +# +# There is still NO clash detection and NO OST_SpecialtyEquipment scan here -- +# the owner ruled Stage 1 is purely an input-confirmation form (2026-09-02). +# `liftexport.export` IS now called, once per panel, but only to READ what +# Panel Lift already measures (support embeds, lifters, prestress) so the +# fields above pre-fill instead of asking the engineer to retype the model's +# own geometry -- the owner reversed the "no auto support detection yet" half +# of the 2026-09-02 ruling the next day (`pa_supports.py`'s own docstring, +# 2026-09-03). Nothing here runs the handling check itself or writes a job +# file; see `_reads_for` / `pa_supports` / `pa_lifters` / `pa_reinf`. All the +# gate / pre-fill / deck-build logic lives in vibe.panelanalysisform, +# host-tested off Revit; this file is the WPF shell and the live geometry +# read, which are owner-must-verify. + +import io +import json +import os + +import clr + +from Autodesk.Revit.DB import (AssemblyInstance, BuiltInCategory, ElementId, + FilteredElementCollector) + +from pyrevit import revit, script, forms + +from vibe import designspec, knowledge, liftexport, runlog, ticketqc +from vibe import pa_elevation, pa_lifters, pa_prefill, pa_reinf, pa_supports +from vibe import panelanalysisform as F +from vibe import panelsketch +from vibe.compat import as_text, string_types +from vibe.webhost import WebHost, WebHostError + +doc = revit.doc +uidoc = revit.uidoc +output = script.get_output() +output.close_others() + +TITLE = 'Panel Analysis' + +# Owner scope update, 2026-09-05: the WPF input form is retired in favour of +# an HTML page (front end) + local CPython server (tools/pa_web.py), shown in +# a WebView2 window (`vibe.webhost.WebHost`) -- see that module's own OWNER +# SCOPE UPDATE note and docs/pa-web-spec.md. The WPF renderer below (PanelForm +# / show_form / _analyze_all / _report_one, and the System.Windows import +# block right before PanelForm) is kept in the file, unreachable, only so the +# owner can compare the two side by side once; flip this to True to go back +# to it. Kept False, this button must not import WPF-only modules at load +# time -- see the guarded import block below. +_USE_WPF = False + + +def not_run(message, title=TITLE): + output.print_md('# {0} — NOT RUN'.format(TITLE)) + output.print_md('**NOT RUN** — no analysis was produced.') + output.print_md('> {0}'.format(message)) + runlog.record(TITLE, doc_title=doc.Title, outcome='not-run', + report_text=message) + script.exit() + + +# ---------------------------------------------------------- which pieces + +def _is_framing(el): + try: + cat = el.Category + return (cat is not None and + cat.Id.IntegerValue == int(BuiltInCategory.OST_StructuralFraming)) + except Exception: + try: + return cat.Id.Value == int(BuiltInCategory.OST_StructuralFraming) + except Exception: + return False + + +def _framing_of_assembly(asm): + """The first structural-framing member of an assembly, or None.""" + try: + for mid in asm.GetMemberIds(): + member = doc.GetElement(mid) + if member is not None and _is_framing(member): + return member + except Exception: + pass + return None + + +def _owning_assembly(el): + """The AssemblyInstance this element belongs to, or None.""" + try: + owner = el.Document.GetElement(el.AssemblyInstanceId) + except Exception: + return None + return owner if isinstance(owner, AssemblyInstance) else None + + +def _resolve_framing(): + """[(framing_element, assembly_or_None)] to build a form for. + + A framing element is taken as itself; an assembly (or any member of one) + resolves to that assembly's framing member -- but an assembly is NOT + required, a bare framing element is enough. De-duplicated, order kept. + + THE ASSEMBLY IS KEPT rather than discarded (2026-09-03). It used to be + collapsed away the moment the framing member was found, which threw away + the only handle on the panel's reinforcement: the strand, mesh and bar are + separate MEMBERS of the assembly, not parameters of the panel, so with the + assembly gone the whole Reinforcement section had nothing to pre-fill from. + A bare framing element still has None here, and reads its reinforcement off + the design spec instead (see `_read_reinforcement`). + """ + selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()] + found = [] + seen = set() + + def add(el, asm=None): + if el is None: + return + try: + key = el.Id.IntegerValue + except Exception: + key = id(el) + if key in seen: + return + seen.add(key) + found.append((el, asm if asm is not None else _owning_assembly(el))) + + for el in selection: + if el is None: + continue + if _is_framing(el): + add(el) + continue + if isinstance(el, AssemblyInstance): + add(_framing_of_assembly(el), el) + continue + owner = _owning_assembly(el) + if owner is not None: + add(_framing_of_assembly(owner), owner) + return found + + +# ------------------------------------------------------- read off the model + +def _read_geometry(el): + """{'length_ft','width_ft','thickness_in'} off the framing element, or {}. + + Reuses the panel-frame + concrete-extents readers Panel Lift Check uses -- + the local frame makes 'along the length' mean what it says on a rotated + wall panel -- and the same length/width/thickness axis split. Orientation + needs no assembly origin: the supports the user types define the statics, + so only the extents matter here. + """ + try: + frame = ticketqc._panel_frame(el) + if frame is None: + return {} + extents = ticketqc.concrete_extents(el, frame) + if not extents: + return {} + lo = [v * 12.0 for v in extents[0]] + hi = [v * 12.0 for v in extents[1]] + L, W, T = liftexport._axes(lo, hi) + return { + 'length_ft': round((hi[L] - lo[L]) / 12.0, 4), + 'width_ft': round((hi[W] - lo[W]) / 12.0, 4), + 'thickness_in': round(hi[T] - lo[T], 3), + } + except Exception: + return {} + + +def _read_weight_centroid(el, asm=None, know=None): + """{'weight_centroid_ft', 'weight_centroid_ft_basis'} off the concrete + solid's TRUE centroid, or {}. + + Handling's own weight/eccentricity arithmetic today treats the piece as + a solid rectangle and computes its centroid from the dimensions; this is + what the model actually knows instead -- `Solid.ComputeCentroid()` on + every concrete solid the body draws (`liftexport.panel_solids`, largest + first, same walk the weight/shape reads already share), volume-weighted + across all of them so a Sandwich panel's several wythes each pull the + centroid their own share rather than only the largest solid answering + for the whole piece (`liftexport.concrete_solid`'s own docstring warns + that picking just the largest solid understated an insulated piece's + weight by a third; the same bias would understate its centroid). + + Expressed in the SAME as-drawn (length, width, thickness) frame every + other Panel Lift read here uses -- `pa_reinf._member_axes` (the same + helper `mild_bar_rows_prefill` uses for its own `from_ft`/`to_ft`) gives + the axis sort and the erected x/y flip, so `x`/`y` land at feet from the + panel's own length/width zero exactly the way `job['panel']['lifters']`' + x/y and `strand_x_in` already do, and `z` at feet up from the FORM FACE + (the low end of the thickness axis -- a panel is cast face down on the + bed, the same convention strand/mild-bar depth use). `asm`/`know` are + optional and passed straight through to `_member_axes`; when they are + not enough to resolve the erected orientation (no assembly, or the + origin rule cannot tell which end is up), the centroid still comes back + -- in the PANEL'S OWN RAW LOCAL FRAME instead, named as such in the + basis rather than silently asserting an orientation nobody established. + + {} when the frame or every solid fails to solve, and defensively on any + exception -- never raises, never guesses a centroid off the bounding box + when the real one could not be computed. + """ + try: + frame = ticketqc._panel_frame(el) + if frame is None: + return {} + solids = liftexport.panel_solids(el) + if not solids: + return {} + inv = frame[0] + total_v = 0.0 + s0 = s1 = s2 = 0.0 + used = 0 + for solid in solids: + try: + v = solid.Volume + c = solid.ComputeCentroid() + except Exception: + continue + if v is None or v <= 0 or c is None: + continue + local = inv.OfPoint(c) + total_v += v + s0 += local.X * v + s1 += local.Y * v + s2 += local.Z * v + used += 1 + if total_v <= 0: + return {} + avg = (s0 / total_v, s1 / total_v, s2 / total_v) # feet, raw local + + axes = None + try: + axes = pa_reinf._member_axes(el, asm, know) + except Exception: + axes = None + if axes is not None: + to_x, to_y = axes['to_x'], axes['to_y'] + lo, L, W, T = axes['lo'], axes['L'], axes['W'], axes['T'] + x_ft = to_x(avg[L] * 12.0) + y_ft = to_y(avg[W] * 12.0) + z_ft = (avg[T] * 12.0 - lo[T]) / 12.0 + basis = ( + 'volume-weighted Solid.ComputeCentroid() over {0} concrete ' + 'solid(s) (liftexport.panel_solids), in the as-drawn ' + '(length, width, thickness) frame liftexport uses ' + 'everywhere else ({1}): x/y are feet from the panel\'s own ' + 'length/width zero, z is feet up from the FORM FACE.' + .format(used, axes['basis'])) + else: + x_ft, y_ft, z_ft = avg + basis = ( + 'volume-weighted Solid.ComputeCentroid() over {0} concrete ' + "solid(s), in the panel's own RAW LOCAL FRAME " + '(ticketqc._panel_frame) -- the length/width/thickness axis ' + 'sort could not be established for this piece.' + .format(used)) + return { + 'weight_centroid_ft': {'x': round(x_ft, 4), 'y': round(y_ft, 4), + 'z': round(z_ft, 4)}, + 'weight_centroid_ft_basis': basis, + } + except Exception: + return {} + + +def _read_elevations(el, length_ft=None): + """{'top_elevation_ft', 'bottom_elevation_ft', 'height_ft', + 'top_elevation_ft_basis'} off the element's own WORLD-space bounding box, + or {}. + + Deliberately NOT the local panel frame `_read_geometry` uses -- that frame + answers "how long/wide/thick is this piece" independent of how it sits in + the model, which is exactly wrong here: Eriksson's Top/Bottom Elevation + are where the piece actually SITS, in project elevation. A wall panel + modelled in place (this tool's precast jobs place panels in the real + building model, not just on a casting-bed sheet) already carries that as + its own bounding box Z extent -- `el.get_BoundingBox(None)`, world + coordinates, decimal feet, the same datum every Z coordinate in the model + already uses. See `pa_elevation.elevations_from_bbox` for the arithmetic + and for what the `length_ft` cross-check means. + + {} on any failure or a bounding box Revit could not compute -- never a + fabricated elevation. + """ + try: + bbox = el.get_BoundingBox(None) + except Exception: + bbox = None + if bbox is None: + return {} + try: + min_z = bbox.Min.Z + max_z = bbox.Max.Z + except Exception: + return {} + top, bottom, height, basis = pa_elevation.elevations_from_bbox( + min_z, max_z, length_ft=length_ft) + if top is None: + return {} + return { + 'top_elevation_ft': round(top, 4), + 'bottom_elevation_ft': round(bottom, 4), + 'height_ft': round(height, 4), + 'top_elevation_ft_basis': basis, + 'bottom_elevation_ft_basis': basis, + } + + +def _read_restraints(reads): + """{'restraints', 'restraints_basis'} off `supports_ft` + + `bottom_elevation_ft`, already in `reads`, or `{'restraints_basis': why}` + alone when there is nothing to convert -- the field stays blank but the + reason is never lost. See `pa_elevation.restraints_from_supports`: this + is the SAME embeds `pa_supports.read_supports` already measured, reported + as elevations instead of a distance up from the erected-lower end, never + a second measurement. + """ + text, why = pa_elevation.restraints_from_supports( + reads.get('supports_ft'), reads.get('bottom_elevation_ft'), + height_ft=reads.get('height_ft'), length_ft=reads.get('length_ft')) + out = {'restraints_basis': why} + if text: + out['restraints'] = text + return out + + +def _read_section_type(el, know): + """'Sandwich' + wythe thicknesses, or {} (Solid / could-not-tell). + + MEASURED, not read off bounding boxes: a line along the piece's own + THICKNESS axis at each of a grid of interior points, intersected with + every solid of the body (`liftexport.section_profile`); the most common + non-empty profile is the section -- a line through a window hits nothing + and does not vote. A per-solid extent cannot do this: an outer wythe + drawn with edge returns spans the whole thickness in its bounding box, + so two wythes read as one (measured 2026-09-03: the layup said + 0-11 / 0-7 in, the probe says 0-3 and 7-11). `section_from_profile` + then merges touching intervals and calls the largest gap of 0.5 in or + more the insulation. + + Extents are the UNION over every solid so z = 0 is the envelope floor + whichever solid is largest. Anything unreadable returns {} and the + caller's 'Solid' default stands (never a fabricated section). + """ + try: + frame = ticketqc._panel_frame(el) + if frame is None: + return {} + solids = liftexport.panel_solids(el) + lo = None + hi = None + for s in solids: + ext = ticketqc.extents_of_solid(s, frame) + if ext is None: + continue + l = [v * 12.0 for v in ext[0]] + h = [v * 12.0 for v in ext[1]] + lo = l if lo is None else [min(a, b) for a, b in zip(lo, l)] + hi = h if hi is None else [max(a, b) for a, b in zip(hi, h)] + if lo is None: + return {} + axes = liftexport._axes(lo, hi) + profile, hits, samples = liftexport.section_profile( + solids, frame, axes, lo, hi) + return liftexport.section_from_profile(profile) + except Exception: + return {} + + +def _read_openings(el): + """[row, ...] of opening/reveal dicts off the piece's geometry, or None. + + The Concrete Extents page renders `values['openings_rows']` -- one row per + opening / reveal, the same row shape `panelanalysisform.parse_openings` + produces from typed text -- but until this reader existed nothing ever + FILLED that key, so the grouped table and the sketches showed nothing or + garbage. This reads the holes the way Panel Lift Check does: the panel's + concrete solid, its side faces clustered into rectangles + (`liftexport._openings`), each probed through the thickness to tell a + through opening from a blind recess (`liftexport.opening_penetration`). + + Distances are decimal FEET in the panel's own frame -- what the table + (`pa_extents._feet_str`) and `panelsketch` both expect. `pos_along` is + from the START of the length, `length` the along-length extent, `pos_vert` + from the BOTTOM edge (up the width axis) and `height` its extent there. + An opening goes through the FULL thickness, so its `pos_cross`/`width` + are None -- there is nothing to position between the faces; a reveal is + measured from one face inward: `pos_cross = 0.0` from 'Face', `width` its + depth in feet. + + None -- never an empty list -- is the could-not-read answer: the geometry + (frame / concrete solid / extents) could not be solved, so the caller + cannot honestly claim "no openings" either. + """ + try: + frame = ticketqc._panel_frame(el) + if frame is None: + return None + solids = liftexport.panel_solids(el) + solid = liftexport.concrete_solid(el, solids) + if solid is None: + return None + ext = ticketqc.extents_of_solid(solid, frame) + if ext is None: + return None + lo = [v * 12.0 for v in ext[0]] + hi = [v * 12.0 for v in ext[1]] + L, W, T = liftexport._axes(lo, hi) + thickness_in = hi[T] - lo[T] + rects = liftexport._openings(solid, frame, lo, hi, (L, W, T)) + except Exception: + return None + + # Drop the degenerate rectangles FIRST -- a face that is a line in one + # direction (24 of 39 face rects on one member were such) is not a hole, + # and keeping one would draw a phantom opening and wrong the counts. + kept = [r for r in rects + if (r[1] - r[0]) > 1e-6 and (r[3] - r[2]) > 1e-6] + + try: + penetration = liftexport.opening_penetration( + el, frame, solid, kept, (L, W, T), lo, hi) + except Exception: + penetration = [None] * len(kept) + + rows = [] + n_open = 0 + n_reveal = 0 + for rect, p in zip(kept, penetration): + x0, x1, y0, y1 = rect + through = p.get('through') if p else None + if through is True or through is None: + # Through, or unjudged -- carried as a through opening, the + # conservative reading `liftexport.split_openings` makes ("could + # not tell" is not "blind": the through reading takes section + # away, and standing it down on a failed solve would quietly make + # a piece pass). + n_open += 1 + rows.append({ + 'type': 'Opening', 'pos_along': round(x0, 4), + 'pos_along_from': 'start', 'length': round(x1 - x0, 4), + 'pos_cross': None, 'pos_cross_from': None, 'width': None, + 'pos_vert': round(y0, 4), 'height': round(y1 - y0, 4), + 'label': 'OPENING {0}'.format(n_open), + }) + else: + concrete_in = p.get('concrete_in') + depth_in = (None if concrete_in is None + else round(thickness_in - concrete_in, 2)) + n_reveal += 1 + rows.append({ + 'type': 'Reveal', 'pos_along': round(x0, 4), + 'pos_along_from': 'start', 'length': round(x1 - x0, 4), + 'pos_cross': 0.0, 'pos_cross_from': 'Face', + 'width': None if depth_in is None else depth_in / 12.0, + 'pos_vert': round(y0, 4), 'height': round(y1 - y0, 4), + 'label': 'RECESS {0}'.format(n_reveal), + }) + rows.sort(key=lambda r: (r['pos_along'] + if r['pos_along'] is not None else 0.0)) + return rows + + +def _param_sources(el): + """The element, then the TYPE it is an instance of. + + Some roles name a TYPE parameter, not an instance one -- the plant's + thickness role is mapped to a type parameter and says so in its own + comment. `LookupParameter` on the instance simply does not see those, so + reading only the instance left `thickness_in` blank on every piece while + the value sat on the symbol the whole time (measured live 2026-09-02 on + a real 8" horizontal wall: the role resolved to its parameter name and + the instance read `None`). + + Instance FIRST: where a value exists in both places the instance is the + one that was set for this piece, and a type value is the fallback the + piece inherited. + """ + out = [el] + try: + type_el = el.Document.GetElement(el.GetTypeId()) + if type_el is not None: + out.append(type_el) + except Exception: + pass + return out + + +def _num_from_param(el, name): + for owner in _param_sources(el): + value = _num_from_one(owner, name) + if value is not None: + return value + return None + + +def _num_from_one(el, name): + try: + p = el.LookupParameter(name) + except Exception: + p = None + if p is None: + return None + for reader in ('AsValueString', 'AsString'): + try: + txt = getattr(p, reader)() + except Exception: + txt = None + if txt: + head = '' + for ch in str(txt).replace(',', ''): + if ch.isdigit() or ch in '.-': + head += ch + elif head: + break + try: + return float(head) + except Exception: + continue + return None + + +def _str_from_param(el, name): + """One parameter's value as text, searching the instance then its type.""" + for owner in _param_sources(el): + value = _str_from_one(owner, name) + if value is not None: + return value + return None + + +def _str_from_one(el, name): + """`AsString` first: on a text parameter `AsValueString` can return the + formatted display of an empty value rather than nothing at all, and a + blank that looks like a name is worse than no name.""" + try: + p = el.LookupParameter(name) + except Exception: + p = None + if p is None: + return None + for reader in ('AsString', 'AsValueString'): + try: + txt = getattr(p, reader)() + except Exception: + txt = None + if txt and str(txt).strip(): + return str(txt).strip() + return None + + +def _param_num(p): + """One Parameter object's value as a float, or None. AsValueString first + (it carries the unit-formatted display), AsString as the fallback; the + leading numeric run is parsed off, commas stripped -- the same reader + `_num_from_one` uses, factored out so the name-pattern search can reuse it + on a Parameter it already holds.""" + for reader in ('AsValueString', 'AsString'): + try: + txt = getattr(p, reader)() + except Exception: + txt = None + if txt: + head = '' + for ch in str(txt).replace(',', ''): + if ch.isdigit() or ch in '.-': + head += ch + elif head: + break + try: + return float(head) + except Exception: + continue + return None + + +def _param_str(p): + """One Parameter object's value as trimmed text, or None.""" + for reader in ('AsString', 'AsValueString'): + try: + txt = getattr(p, reader)() + except Exception: + txt = None + if txt and str(txt).strip(): + return str(txt).strip() + return None + + +def _iter_named_params(el): + """(param, normalized_name) over the instance's params then its type's. + + The name is lower-cased and every underscore turned into a space, so a + parameter whose name is upper-case-with-underscores (the common precast + convention) still matches the plain-English fragments the pattern readers + below search for. Generic on purpose: this keys on common engineering NAME + fragments, never on one job's literal parameter name (owner, 2026-09-03 -- + pre-fill must work on the next job with no code change).""" + for owner in _param_sources(el): + try: + params = owner.Parameters + except Exception: + params = [] + for p in params: + try: + name = p.Definition.Name.lower().replace('_', ' ').strip() + except Exception: + continue + yield p, name + + +def _pattern_num(el, patterns, exclude=()): + """First numeric parameter whose normalized name contains one of `patterns` + and none of `exclude`. None when nothing matches -- the could-not-read + answer, left blank for the user, never a guessed value.""" + for p, name in _iter_named_params(el): + if any(x in name for x in exclude): + continue + if any(x in name for x in patterns): + v = _param_num(p) + if v is not None: + return v + return None + + +def _pattern_str(el, patterns, exclude=()): + """First text parameter whose normalized name contains one of `patterns` + and none of `exclude`, or None.""" + for p, name in _iter_named_params(el): + if any(x in name for x in exclude): + continue + if any(x in name for x in patterns): + v = _param_str(p) + if v: + return v + return None + + +def _role_str(el, know, role): + """The first non-empty value among the parameter names a plant maps to + `role`. Empty when the plant mapped none -- unmapped is not an error + here, it is the field staying blank for the user to fill.""" + if know is None: + return None + for name in knowledge.param_names(know, role): + value = _str_from_param(el, name) + if value: + return value + return None + + +def _read_materials(el, know): + """Best-effort f'c / f'ci / unit weight / mark off parameters, else blank. + + Material strengths are usually a per-design/sheet fact rather than a panel + parameter, so this reads whatever role the plant mapped and otherwise + leaves the field blank for the user -- the owner's rule, not an invented + default. + """ + out = {} + if know is not None: + for key, roles in (('fc_psi', ('sheet.fc',)), + ('fci_psi', ('sheet.fci',)), + ('unit_weight_pcf', + ('panel.unit_weight', 'sheet.unit_weight'))): + for role in roles: + for name in knowledge.param_names(know, role): + v = _num_from_param(el, name) + if v is not None: + out[key] = v + break + if key in out: + break + for name in knowledge.param_names(know, 'panel.design_number') + \ + knowledge.param_names(know, 'sheet.design_number'): + try: + dn = el.LookupParameter(name) + dn = dn.AsString() if dn is not None else None + except Exception: + dn = None + if dn: + out['design_number'] = dn + break + + # GENERIC, job-independent fallback (owner, 2026-09-03: "很多的预填充应该是 + # 通用的代码,避免换一个活儿就得重来" -- pre-fill must be generic, not keyed + # to one job's parameter names). Where the plant mapped no role -- or the + # mapped role read blank -- fall back to case-insensitive NAME-PATTERN + # matching over the instance and type parameters, on common precast + # engineering terms only. Validated live on an MC job with no roles wired: + # these recovered the 28-day and release strengths, the unit weight, the + # cast weight, the piece mark and the design number off that job's own + # parameters -- every value found by a generic term match, not one + # job-specific literal named anywhere in this file. + if 'fci_psi' not in out: + v = _pattern_num(el, ("f'ci", u"f′ci", 'fci', 'release strength', + 'transfer strength', 'initial strength', + 'strength at release', 'release')) + if v is not None: + out['fci_psi'] = v + if 'fc_psi' not in out: + v = _pattern_num( + el, ("f'c", u"f′c", '28 day', '28-day', 'final strength', + 'design strength', 'specified strength'), + exclude=('release', 'transfer', 'initial', 'fci', "f'ci", + u"f′ci", 'at release')) + if v is not None: + out['fc_psi'] = v + if 'unit_weight_pcf' not in out: + v = _pattern_num(el, ('unit weight', 'weight per', 'density', 'pcf')) + if v is not None: + out['unit_weight_pcf'] = v + if 'weight_lb' not in out: + v = _pattern_num(el, ('member weight', 'weight cast', 'panel weight', + 'piece weight', 'total weight'), + exclude=('per', 'unit')) + if v is not None: + out['weight_lb'] = v + if 'concrete_type' not in out: + s = _pattern_str(el, ('concrete type', 'lightweight', 'normalweight', + 'weight class')) + if s: + out['concrete_type'] = s + if 'design_number' not in out: + s = _pattern_str(el, ('design number', 'design sheet', 'sheet number')) + if s: + out['design_number'] = s + + # No concrete has f'c / f'ci below 100 psi, so a value that small was read + # off a parameter carrying ksi (e.g. 8 for 8 ksi); normalize it to psi so + # the psi-keyed field is consistent. A safe, code-based unit fix, not an + # invented value. + for k in ('fc_psi', 'fci_psi'): + v = out.get(k) + if isinstance(v, (int, float)) and 0 < v < 100: + out[k] = v * 1000.0 + + # The piece mark, through the same role the rest of the tool reads it by + # (`ticketqc._member_mark`), NOT the built-in `Mark` alone. On this + # plant's template the built-in is empty and the mark lives in the + # parameter the plant maps to `member.mark` -- read only the built-in and + # every MC panel comes up nameless, which is what put `panel 1 (id + # 123456)` in the form's header where `MWH116` belonged. + # Built-in `Mark` is the universal name (owner, 2026-09-03); the plant + # `member.mark` role is only an accelerator where it is mapped. When both + # are empty -- some jobs store the mark in a plant parameter and leave the + # built-in blank -- fall back to the generic 'mark' name-pattern, which + # finds any parameter whose name carries 'mark' without naming a specific + # one; excludes the unrelated 'type mark' and a handful of other + # '...mark' words. + mark = (_role_str(el, know, 'member.mark') + or _str_from_param(el, 'Mark') + or _pattern_str(el, ('mark',), + exclude=('type mark', 'benchmark', 'remark', + 'watermark', 'bookmark'))) + if mark: + out['mark'] = mark + + # A CONTROL NUMBER is the second name a piece has: the mark says which + # design it is, the control number says which physical piece. A plant + # that records one maps `member.control_number`; one that does not gets + # no parenthetical rather than a guessed one. Suppressed when it is the + # same string as the mark -- a plant whose mark parameter IS its control + # number (this one maps `member.mark` to the plant's mark parameter) should see the + # name once, not printed twice with a bracket round the copy. + control = (_role_str(el, know, 'member.control_number') + or _pattern_str(el, ('control number', 'control no', + 'piece number'))) + if control and control != mark: + out['control_number'] = control + return out + + +# Revit's own factory template ships these ProjectInformation built-ins +# carrying their OWN LABEL as placeholder text until an engineer actually +# fills them in -- 'Project Status' reads back the literal text "Project +# Status", 'Project Issue Date' reads back "Issue Date". Generic (a Revit +# template default, not one job's data): a value equal to its field's own +# known placeholder is treated the same as blank, never shown as though it +# were real project data. +_PROJECT_INFO_PLACEHOLDERS = { + 'Project Status': 'Project Status', + 'Project Issue Date': 'Issue Date', +} + + +def _read_project_info(el, know=None): + """Project name / client / design date / location / description / + engineer-of-record for the Design Criteria header. + + Project name and client come off the model's Project Information (the same + header Eriksson's page 1 carries); the design date defaults to today, as + the model has no reliable design-date parameter. Doc-level -- identical for + every panel -- and blank where the model does not carry it. + + `project_location` / `project_description` / `engineer_initials` are new + (2026-09-04): the owner's list of fields that came up blank on WPA655's + morning run even though the reads had been wired the night before. Each + carries its own `_basis` sentence -- which built-in (or plant role) + actually supplied it -- so a blank field on a job whose template carries + none of these still says why rather than looking like an oversight. + """ + out = {} + try: + pinfo = el.Document.ProjectInformation + except Exception: + pinfo = None + if pinfo is not None: + def _pi(name): + try: + p = pinfo.LookupParameter(name) + v = p.AsString() if p is not None else None + v = v.strip() if v and v.strip() else None + except Exception: + return None + if v is not None and _PROJECT_INFO_PLACEHOLDERS.get(name) == v: + # Unedited Revit template default -- not a real value. + return None + return v + pn = _pi('Project Name') + if pn: + out['project_name'] = pn + # The "client" on a precast shop job is the PLANT this design is + # produced for -- the precast manufacturer / producing plant named on + # the model's Project Information -- not the building owner in the + # built-in Client Name (owner, 2026-09-03: "client name = plant name"). + # Read it generically by NAME PATTERN so it works on any job's + # template, falling back to the built-in only when no plant field is + # present. The plant name is live model data shown at run time, never + # written into this file (scrub). + cn = None + try: + for p in pinfo.Parameters: + try: + nm = p.Definition.Name.lower().replace('_', ' ') + except Exception: + continue + if ('precast manufacturer' in nm or 'producing plant' in nm + or 'precast plant' in nm or 'manufacturer' in nm): + v = p.AsString() if p is not None else None + if v and v.strip(): + cn = v.strip() + break + except Exception: + cn = None + if not cn: + cn = _pi('Client Name') + if cn: + out['client_name'] = cn + + # project_location -- the built-in 'Project Address' first (the + # standard Revit ProjectInformation field; live-verified non-empty on + # sample_job), else a generic NAME-PATTERN fallback for a template + # that calls it something else. + loc = _pi('Project Address') + loc_basis = "ProjectInformation's 'Project Address'" + if not loc: + loc, loc_basis = None, None + try: + for p in pinfo.Parameters: + try: + nm = p.Definition.Name.lower().replace('_', ' ') + except Exception: + continue + if ('address' in nm or 'location' in nm + or 'site' in nm): + v = p.AsString() if p is not None else None + if v and v.strip(): + loc = v.strip() + loc_basis = ("name-pattern match on '{0}'" + .format(p.Definition.Name)) + break + except Exception: + loc = None + if loc: + out['project_location'] = loc + out['project_location_basis'] = loc_basis + else: + out['project_location_basis'] = ( + 'no Project Address and no address/location/site-named ' + 'parameter on ProjectInformation') + + # project_description -- 'Project Status' first (Eriksson's own + # header carries a status line), else 'Building Name'; either is + # blank once the template-default placeholder is filtered out + # above. Named which one supplied it, or that neither did. + status = _pi('Project Status') + if status: + out['project_description'] = status + out['project_description_basis'] = ( + "ProjectInformation's 'Project Status'") + else: + bname = _pi('Building Name') + if bname: + out['project_description'] = bname + out['project_description_basis'] = ( + "ProjectInformation's 'Building Name' ('Project " + "Status' was blank or its unedited template default)") + else: + out['project_description_basis'] = ( + "'Project Status' and 'Building Name' are both blank " + 'or carry only their unedited template defaults') + + # engineer_initials -- the plant's own role, when mapped; a + # person's identity is never guessed at by name pattern (the same + # rule CLAUDE.md states for client / job names), so there is no + # generic fallback here -- blank means the plant has not named one. + initials = None + if know is not None: + try: + initials = knowledge.get( + know, 'panelanalysis.engineer_initials') + except Exception: + initials = None + if initials is not None: + initials = str(initials).strip() or None + if initials: + out['engineer_initials'] = initials + out['engineer_initials_basis'] = ( + 'plant knowledge panelanalysis.engineer_initials') + else: + out['engineer_initials_basis'] = ( + 'no panelanalysis.engineer_initials in the merged ' + 'knowledge -- an identity is never guessed at') + try: + import System + out['design_date'] = System.DateTime.Now.ToString('yyyy-MM-dd') + except Exception: + pass + return out + + +_STRAND_RAIL_LABELS = { + 'left_rail': 'Left edge', + 'right_rail': 'Right edge', + 'centerline': 'Centerline', +} + + +def _read_strand_rail_location(know): + """{'strand_rail_location', 'strand_rail_location_basis'} off the + plant's `rails.strand_datum`, or `{'strand_rail_location_basis': why}` + alone when the plant has not named one. + + Which rail is fixed is plant knowledge, never inferred from geometry + (see the strand-layout note this mirrors) -- a plant that has not stated + `rails.strand_datum` leaves this blank rather than guessing which edge. + A sibling `pa_defaults` module may fill the same field from the same + plant key as a DEFAULT rather than a read; harmless either way, since + `panelanalysisform.prefill` keeps the first non-blank value. + """ + if know is None: + return {'strand_rail_location_basis': + 'no knowledge layer loaded for this document'} + try: + datum = knowledge.get(know, 'rails.strand_datum') + except Exception: + datum = None + if datum is None: + return {'strand_rail_location_basis': + 'no rails.strand_datum in the merged knowledge -- which ' + 'rail is fixed is plant knowledge, never inferred'} + label = _STRAND_RAIL_LABELS.get(str(datum).strip().lower()) + if not label: + return {'strand_rail_location_basis': + 'rails.strand_datum is {0!r}, which is not one of ' + 'left_rail / right_rail / centerline'.format(datum)} + return {'strand_rail_location': label, + 'strand_rail_location_basis': 'plant knowledge rails.strand_datum'} + + +def _spec_reinforcement(el, design_number): + """PATH A -- reinforcement off this design's SPEC file, or {}. + + A bare structural-framing element carries no reinforcement of its own: the + bar and strand are separate elements, and on a piece selected without its + assembly there is nothing to walk. What there IS, once Design Extract has + run for this design, is a spec file keyed by the design number, carrying + the strand groups, the mesh and the mild grade exactly as they were read + off the design sheet. That is the same number a person would read. + + Everything is optional and everything is defensive: no design number, no + spec file, or a spec that does not carry a field, and that field simply + stays blank. Nothing is defaulted to zero -- a zero strand count is a + statement that the panel has no strand, which is not what "no spec" means. + """ + if not design_number: + return {} + try: + from vibe import specformat + spec = designspec.load_spec(el.Document, design_number) + except Exception: + return {} + if not spec: + return {} + + out = {} + + def _get(path, default=None): + try: + return specformat.get_path(spec, path, default) + except Exception: + return default + + groups = _get('reinforcing.strand_groups', []) or [] + total = 0 + dia = None + grade = None + pull_lb = None + for group in groups: + if not isinstance(group, dict): + continue + qty = group.get('qty') + try: + qty = int(qty) + except (TypeError, ValueError): + qty = None + if qty and qty > 0: + total += qty + if dia is None and group.get('dia_in') is not None: + dia = group.get('dia_in') + if grade is None and group.get('grade_ksi') is not None: + grade = group.get('grade_ksi') + # `it_lb` is an initial tension in POUNDS -- a force, not a percentage. + # It fills the per-strand pull directly. The pull PERCENT is left blank + # rather than back-computed: that needs fpu AND the strand area, and a + # percentage derived from a guessed area is a fabricated jacking force. + if pull_lb is None and group.get('it_lb') is not None: + pull_lb = group.get('it_lb') + if total > 0: + out['ps_count'] = total + if dia is not None: + out['ps_dia_in'] = dia + if grade is not None: + out['ps_fpu_ksi'] = grade + if pull_lb is not None: + out['ps_pull_lb'] = pull_lb + + mesh = _get('reinforcing.wwf.mesh') + layers = _get('reinforcing.wwf.layers') + per_face = _get('reinforcing.wwf.per_face') + if mesh: + text = str(mesh) + extra = [] + if layers is not None: + extra.append('{0} layer(s)'.format(layers)) + if per_face is not None: + extra.append('{0} per face'.format(per_face)) + if extra: + text += ', ' + ', '.join(extra) + out['mesh'] = text + + mild = _get('reinforcing.mild_grade_ksi') + if mild is not None: + out['rebar_fy_ksi'] = mild + + for key, path in (('fc_psi', 'concrete.fc_psi'), + ('fci_psi', 'concrete.fci_psi'), + ('unit_weight_pcf', 'concrete.unit_weight_pcf')): + value = _get(path) + if value is not None: + out[key] = value + return out + + +def _assembly_reinforcement(el, asm, know): + """PATH B -- reinforcement counted off the ASSEMBLY's members, or {}. + + When the piece was selected with its assembly, the strand, mesh and bar are + real elements sitting in it, and counting them is a direct measurement + rather than a document read. What the assembly canNOT say is how hard the + strand was pulled or what grade it is -- that is a design decision, not a + geometric fact -- so those come from the spec (Path A) and are never + invented here. + + `reinforcing_verdict` returning None means "nothing here can judge whether + that member is reinforcing", which is NOT "it is not reinforcing"; such a + member is left out of the count and the field is left blank rather than + reported as a smaller number that would look measured. + """ + if asm is None: + return {} + try: + from vibe import ticketqc as tq + from vibe import knowledge as K + except Exception: + return {} + + try: + frame = tq._panel_frame(el) + boxes = tq.member_boxes(el.Document, asm, el, frame, know) or [] + except Exception: + return {} + if not boxes: + return {} + + try: + wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF') + except Exception: + wwf_prefix = 'WWF' + wwf_prefix = wwf_prefix.upper() + + strands = [] + mesh = [] + rebar = [] + for b in boxes: + family = b.get('family') or '' + try: + if K.is_strand(know, family): + strands.append(b) + continue + except Exception: + pass + if family.upper().startswith(wwf_prefix): + mesh.append(b) + continue + try: + verdict = K.reinforcing_verdict(know, family)[0] + except Exception: + verdict = None + if verdict is True: + rebar.append(b) + + out = {} + if strands: + out['ps_count'] = len(strands) + for b in strands: + try: + dia = tq.strand_diameter(b.get('family'), b.get('mark')) + except Exception: + dia = None + if dia: + out['ps_dia_in'] = dia + break + if mesh: + names = [] + for b in mesh: + name = b.get('family') + if name and name not in names: + names.append(name) + if names: + out['mesh'] = ', '.join(names) + if rebar: + marks = [] + for b in rebar: + mark = b.get('mark') or b.get('family') + if mark and mark not in marks: + marks.append(mark) + if marks: + out['mild_as'] = ', '.join(marks) + return out + + +def _read_reinforcement(el, asm, know, design_number): + """The Reinforcement pre-fill, both paths, spec first then the assembly. + + The two do not compete for the same facts: the spec knows the design + intent (grade, tension, mesh callout) and the assembly knows what was + actually modelled (how many strands there really are). Where both answer, + the ASSEMBLY wins on counts and sizes -- it is the measurement -- and the + spec keeps the fields it is the only source for. Either path failing + entirely leaves the other's answer standing; both failing leaves the whole + section blank, which is the honest result and what the user then types. + """ + out = {} + try: + out.update(_spec_reinforcement(el, design_number)) + except Exception: + pass + try: + measured = _assembly_reinforcement(el, asm, know) + except Exception: + measured = {} + for key, value in measured.items(): + if value is not None: + out[key] = value + return out + + +# The name fragments a job number hides behind, normalized (lower case, +# underscores turned into spaces) the way `_iter_named_params` normalizes. NO +# job's literal parameter name appears here -- these are the generic English +# terms a title block uses, so the next job pre-fills with no code change +# (owner, 2026-09-03). +_JOB_NUMBER_PATTERNS = ('job number', 'job no', 'designer job', 'job #') + +# Which sheet carries it. A precast job's number is printed on the Design +# Summary sheet's title block; the sheet is found by a generic NAME fragment, +# never by a sheet number this job happens to use. +_DESIGN_SUMMARY_SHEET = 'design summary' + + +def _read_job_number(el): + """The job's tracking number, or None. Never raises. + + The FIRST source is the model's own Project Number -- ProjectInformation's + built-in `Project Number` parameter (owner, 2026-09-03: job_number = the + model's Project Number). Only where that is empty does the read fall back + to the existing reads, unchanged: the Design Summary sheet's title block, + then name-pattern matches on Project Information. Nothing is guessed: no + value anywhere, and the field simply comes up blank for the user to type. + """ + try: + document = el.Document + except Exception: + return None + + # FIRST SOURCE -- the model's built-in Project Number (owner, 2026-09-03). + try: + pinfo = document.ProjectInformation + except Exception: + pinfo = None + if pinfo is not None: + try: + p = pinfo.LookupParameter('Project Number') + value = p.AsString() if p is not None else None + except Exception: + value = None + if value and str(value).strip(): + return str(value).strip() + + # FALLBACK -- the Design Summary sheet's title block, then name-pattern + # matches on Project Information. Both unchanged from before the ruling. + try: + blocks = (FilteredElementCollector(document) + .OfCategory(BuiltInCategory.OST_TitleBlocks) + .WhereElementIsNotElementType() + .ToElements()) + except Exception: + blocks = [] + + for tb in blocks: + try: + sheet_name = (_str_from_one(tb, 'Sheet Name') or '') + except Exception: + sheet_name = '' + if _DESIGN_SUMMARY_SHEET not in sheet_name.lower(): + continue + value = _pattern_str(tb, _JOB_NUMBER_PATTERNS) + if value: + return value + + if pinfo is not None: + value = _pattern_str(pinfo, _JOB_NUMBER_PATTERNS) + if value: + return value + return None + + +# `ticketqc.collect_context` / `assemblies_by_design` each walk every sheet / +# assembly in the document -- fine once, wasteful once PER PANEL when several +# are opened together. Cached here, keyed on the document itself, so a +# multi-panel Panel Analysis run pays for the scan once no matter how many +# panels' `_reads_for` calls follow. `liftexport.export` (the actual geometry +# read) is NOT cached here -- it runs once per panel by construction, because +# `_reads_for` itself is only ever called once per panel (see `PanelForm. +# __init__` / the `forms_list` comprehension at the bottom of this file). +_LIFTEXPORT_CTX = {'doc': None, 'sheets': {}, 'groups': {}} + + +def _sheets_and_design_groups(doc, know): + """(sheets, groups) from `ticketqc`, cached per document. + + `sheets` is `collect_context`'s second return (sheet name -> [ViewSheet], + see that function's docstring for the summaries-vs-design-number + convention it does NOT apply here). `groups` is `assemblies_by_design`'s + design key -> [AssemblyInstance] map. Either failing leaves that half + empty (`{}`), never raises -- a caller that cannot find sheets or design + groups just gets no `design` to hand `liftexport.export`, which already + treats a missing design as "unknown" rather than a fatal input. + """ + if _LIFTEXPORT_CTX['doc'] is doc: + return _LIFTEXPORT_CTX['sheets'], _LIFTEXPORT_CTX['groups'] + try: + _summaries, sheets = ticketqc.collect_context(doc) + except Exception: + sheets = {} + try: + groups = ticketqc.assemblies_by_design(doc, know) or {} + except Exception: + groups = {} + _LIFTEXPORT_CTX['doc'] = doc + _LIFTEXPORT_CTX['sheets'] = sheets + _LIFTEXPORT_CTX['groups'] = groups + return sheets, groups + + +def _design_key_for(asm, groups): + """The design key `asm` belongs to in `groups`, or None -- same lookup + `PanelLiftCheck`'s single-piece path uses (find the group whose members + include this assembly's id), not `reads['design_number']`: that field is + the RAW parameter string (e.g. 'WP1.0'), while `assemblies_by_design`'s + keys are already reduced through `designspec.design_key` (e.g. 'WP1'), + and `liftexport.export` expects the reduced form. + """ + try: + for key, group in groups.items(): + for member in group: + if member.Id == asm.Id: + return key + except Exception: + pass + return None + + +def _liftexport_job_for(el, asm, know): + """The Panel Lift `job` dict for `asm`, or None -- one `liftexport.export` + call, the single expensive geometry read `pa_lifters` / `pa_reinf`'s + prestress half both need. Never raises: any failure (no python_exe + configured, export's own `problems`, an exception in the measurement + itself) comes back as None, and every caller already treats a None job + as "nothing to pre-fill from Panel Lift" rather than an error to surface + here -- the Run Analysis button, not this pre-fill pass, is where a real + export failure belongs on screen. + """ + try: + document = el.Document + sheets, groups = _sheets_and_design_groups(document, know) + design = _design_key_for(asm, groups) + job, _problems = liftexport.export(document, asm, know, sheets, + design) + except Exception: + return None + return job + + +def _reads_for(el, index, know, asm=None): + reads = {} + reads.update(_read_geometry(el)) + reads.update(_read_materials(el, know)) + reads.update(_read_project_info(el, know)) + try: + reads.update(_read_elevations(el, reads.get('length_ft'))) + except Exception: + pass + reads.update(_read_strand_rail_location(know)) + # The plant's own stress limits and factors, when its knowledge layer + # pins them (`panelanalysis.settings.*`); the generic code values in + # `PREFILL_DEFAULTS` stand in for whatever it leaves out. + try: + reads.update(F.settings_from_plant( + knowledge.get(know, 'panelanalysis.settings'))) + except Exception: + pass + # Reinforcement, both paths. Applied with `setdefault` so a value the + # materials read already recovered off the panel's own parameters is not + # replaced by the spec's copy of it -- the piece wins over the sheet. + try: + reinf = _read_reinforcement(el, asm, know, + reads.get('design_number')) + except Exception: + reinf = {} + for key, value in (reinf or {}).items(): + if value is not None and reads.get(key) is None: + reads[key] = value + # Model-measured pre-fill: support embeds, face/edge lifters, and the + # strand + rebar summary Panel Lift's own read produces -- one + # `liftexport.export` call (the expensive geometry read) shared by the + # lifters and prestress halves, exactly the way the button's own single- + # panel run shares it. Each of the three reads is independently + # defensive ({} on anything it cannot measure) and this call site adds no + # exception handling on top that would hide which one failed; the merge + # itself follows `pa_prefill.merge_model_reads`'s rule -- a value already + # in `reads` wins, except `supports_ft` / `face_lifters` / `edge_lifters` + # / `ps_rows` / `mild_as`, where the new measurement always wins (see + # that module's docstring for why `mild_as` needs the override: it is + # replacing `_assembly_reinforcement`'s cruder marks-list under the same + # key). Only runs at all when there is an assembly to measure. + job = None + if asm is not None: + try: + job = _liftexport_job_for(el, asm, know) + except Exception: + job = None + try: + supports = pa_supports.read_supports(el, asm, know) + except Exception: + supports = {} + try: + lifters = pa_lifters.read_lifters(job, know) + except Exception: + lifters = {} + try: + dunnage = pa_lifters.read_dunnage(job) + except Exception: + dunnage = {} + try: + prestress = (job or {}).get('panel', {}).get('prestress') \ + if job else None + reinf2 = pa_reinf.read_reinforcement_prefill( + el, asm, know, prestress=prestress, + fpu_ksi=reads.get('ps_fpu_ksi')) + except Exception: + reinf2 = {} + try: + centroid = _read_weight_centroid(el, asm, know) + except Exception: + centroid = {} + pa_prefill.merge_model_reads( + reads, (supports, lifters, dunnage, reinf2, centroid)) + # Restraints -- the SAME support embeds `supports_ft` above just + # measured, re-expressed as elevations off this panel's own + # `bottom_elevation_ft` (see `pa_elevation.restraints_from_supports`). + # Runs after the `supports_ft` merge above, so a plant that names no + # support families -- `supports_ft` stays blank -- gets nothing to + # convert and `restraints_basis` says so, rather than this section + # inventing an elevation. + try: + reads.update(_read_restraints(reads)) + except Exception: + pass + job_number = None + try: + job_number = _read_job_number(el) + except Exception: + job_number = None + if job_number: + reads['job_number'] = job_number + if not reads.get('mark'): + # Nothing named this piece. An element id is a poor name but it is a + # TRUE one, and it is the only handle that still points at the right + # piece when two panels are equally nameless -- so it stays as the + # last resort, and the header says outright that no mark was read + # rather than letting a number look like a mark. + try: + reads['mark'] = 'unnamed panel {0} (element id {1})'.format( + index + 1, el.Id.IntegerValue) + except Exception: + reads['mark'] = 'unnamed panel {0}'.format(index + 1) + reads.setdefault('section_type', 'Solid') + # A Sandwich (insulated) piece needs its Concrete Extents given per wythe + # and carries wythe-2 materials; detection runs Panel Lift's own concrete + # decision and only flips the section to Sandwich when that decision says + # two+ concrete layers exist. On any failure it returns {} and the + # 'Solid' default above stands (never an invented sandwich). + try: + section = _read_section_type(el, know) + except Exception: + section = {} + if section: + reads['section_type'] = section.get('section_type') or 'Solid' + for key in ('wythe_top_in', 'insulation_in', 'wythe_bottom_in'): + if section.get(key) is not None: + reads[key] = section[key] + # The Concrete Extents page's grouped openings table and the sketches read + # `values['openings_rows']`; nothing filled it until `_read_openings`. + # None = the geometry could not be read, and the key then stays ABSENT so + # the caller cannot mistake "unreadable" for "no openings". The legacy + # free-text `openings` field (the sketch's note still reads it) is set to a + # real summary sentence only when there is something to summarise -- + # NEVER fed into the form's `openings` editor, because `validate` re-parses + # that field as typed CSV rows and a summary sentence would hard-block the + # Run button. + rows = None + try: + rows = _read_openings(el) + except Exception: + rows = None + if rows is not None: + reads['openings_rows'] = rows + n_open = len([r for r in rows if r['type'] == 'Opening']) + n_reveal = len([r for r in rows if r['type'] == 'Reveal']) + if n_open or n_reveal: + reads['openings'] = '{0} opening(s), {1} reveal(s)'.format( + n_open, n_reveal) + return reads + + +def panel_title(reads): + """The line that heads one panel's section: `MARK (CONTROL)`. + + Kept apart from `reads['mark']`, which names the run's output files and + must stay a single stable token. + """ + mark = reads.get('mark') or '' + control = reads.get('control_number') + if mark and control: + return u'{0} ({1})'.format(mark, control) + return mark + + +# ---------------------------------------------------------------- running + +REPO_ROOT = os.path.dirname(os.path.dirname(liftexport.panellift_src())) +PANELANALYSIS_PY = os.path.join(REPO_ROOT, 'tools', 'panelanalysis.py') + +# What the CPython side of this button has to be able to import before it is +# worth starting: tools/panelanalysis.py and tools/deckcheck.py (which +# tools/pa_web.py imports first) both `import yaml` at module scope and +# `sys.exit(2)` without it -- before pa_web has printed its URL, so the only +# symptom was "did not report a URL in time" (Gitea #16). find_python_report +# runs each candidate interpreter against this list and takes the first that +# passes, not the first that exists. +PA_NEEDS = ('yaml',) + + +def _runner_script(exe, script_path, job, out, log): + return (u'@echo off\r\n' + u'chcp 65001 >nul\r\n' + u'set "PYTHONIOENCODING=utf-8"\r\n' + u'"{exe}" "{script}" "{job}" -o "{out}" > "{log}" 2>&1\r\n' + u'exit /b %ERRORLEVEL%\r\n').format( + exe=exe, script=script_path, job=job, out=out, log=log) + + +def run_panelanalysis(exe, script_path, job, out, timeout_ms=120000): + """(exit_code, log_text). One-line cmd wrapper redirecting to a file -- + reading one pipe while the child blocks on the other is a deadlock, not a + slow run (see liftexport.run()'s docstring for the incident).""" + from System.Diagnostics import Process, ProcessStartInfo + + base = os.path.splitext(out)[0] + log_path = base + '.log' + runner = base + '.run.cmd' + with io.open(runner, 'w', encoding='utf-8') as fh: + fh.write(_runner_script(exe, script_path, job, out, log_path)) + + info = ProcessStartInfo() + info.FileName = 'cmd.exe' + info.Arguments = '/c "{0}"'.format(runner) + info.UseShellExecute = False + info.CreateNoWindow = True + + process = Process.Start(info) + waited = 0 + while not process.WaitForExit(250): + waited += 250 + if waited >= timeout_ms: + try: + process.Kill() + except Exception: + pass + return -1, ('panel analysis did not finish in {0} seconds' + .format(timeout_ms // 1000)) + + def _read(path): + try: + with io.open(path, 'r', encoding='utf-8', errors='replace') as fh: + return fh.read() + except Exception: + return '' + + log_text = _read(log_path) + for path in (runner, log_path): + try: + os.remove(path) + except Exception: + pass + return process.ExitCode, log_text + + +def _safe_name(mark): + out = '' + for ch in str(mark): + out += ch if (ch.isalnum() or ch in '-_.') else '_' + return out or 'panel' + + +def _run_one(mark, deck): + """(ok, message). Builds the deck file, solves it, opens the page.""" + python_exe, tried = liftexport.find_python_report(KNOW, PA_NEEDS) + if not python_exe: + return False, ('The page renders outside Revit, and ' + + liftexport.describe_python_search(tried, PA_NEEDS)) + if not os.path.isfile(PANELANALYSIS_PY): + return False, 'tools/panelanalysis.py was not found at {0}.'.format( + PANELANALYSIS_PY) + + start_dir = designspec.sub_dir(doc, 'panelanalysis') + stem = _safe_name(mark) + job_path = os.path.join(start_dir, '{0}.pa.json'.format(stem)) + out_path = os.path.join(start_dir, '{0}.panelanalysis.html'.format(stem)) + blob = json.dumps(deck, indent=1, sort_keys=True) + with io.open(job_path, 'w', encoding='utf-8') as fh: + fh.write(as_text(blob)) + + code, log_text = run_panelanalysis(python_exe, PANELANALYSIS_PY, job_path, + out_path) + if code != 0 or not os.path.isfile(out_path): + return False, ('the page could not be rendered (exit {0}).\n\n{1}\n\n' + 'Deck kept at:\n{2}'.format(code, log_text or 'no ' + 'output', job_path)) + liftexport.open_file(out_path) + return True, out_path + + +# ------------------------------------------------------------- pa_web (live) +# +# Owner scope update, 2026-09-05: Revit is only an optional PREFILL plug-in +# now. The actual input form (前端) and results page (后端) are HTML, served +# by `tools/pa_web.py` (a local CPython HTTP server) and shown in a WebView2 +# window (`vibe.webhost.WebHost`) -- see `docs/pa-web-spec.md`. This button's +# whole job per panel is: build `reads`, write them to a JSON file, launch the +# server, pop the window, and clean the server up when the window closes. + +PA_WEB_PY = os.path.join(REPO_ROOT, 'tools', 'pa_web.py') + + +def _launch_pa_web(python_exe, reads_path, timeout_s=20): + """(process, url_or_None, err_lines). Starts `tools/pa_web.py --reads + reads_path --port 0` detached and reads its stdout ASYNCHRONOUSLY -- + .NET's OutputDataReceived/BeginOutputReadLine, not a second blocking + read after this one -- which is exactly the deadlock + `liftexport.run()`'s own docstring warns about (the child blocks + writing one stream while this waits on the other and neither ever + moves). That pattern is safe here because pa_web.py is a long-running + SERVER, not a one-shot subprocess this can wait to exit before reading + its output -- the port has to be read WHILE it keeps running. + + Polls `out_lines` for the `PA_WEB_URL=` line up to `timeout_s`; `url` is + None on timeout or an early exit, and the caller decides what to do with + the still-live `process` in that case (kill it; nothing else can). + """ + from System.Diagnostics import Process, ProcessStartInfo + from System.Threading import Thread + + info = ProcessStartInfo() + info.FileName = python_exe + info.Arguments = u'"{0}" --reads "{1}" --port 0'.format( + PA_WEB_PY, reads_path) + info.UseShellExecute = False + info.CreateNoWindow = True + info.RedirectStandardOutput = True + info.RedirectStandardError = True + + out_lines = [] + err_lines = [] + + def _on_out(sender, e): + if e.Data: + out_lines.append(e.Data) + + def _on_err(sender, e): + if e.Data: + err_lines.append(e.Data) + + process = Process() + process.StartInfo = info + process.OutputDataReceived += _on_out + process.ErrorDataReceived += _on_err + process.Start() + process.BeginOutputReadLine() + process.BeginErrorReadLine() + + url = None + waited = 0 + step_ms = 100 + while waited < timeout_s * 1000: + for line in out_lines: + if line.startswith('PA_WEB_URL='): + url = line[len('PA_WEB_URL='):].strip() + break + if url: + break + try: + if process.HasExited: + break + except Exception: + break + Thread.Sleep(step_ms) + waited += step_ms + return process, url, err_lines + + +def _post_close(url): + """Tells the server to shut itself down. Best-effort: the caller kills + the process outright a few seconds later regardless (see + `_run_panel_web`), so a failed POST here (a transient connection error, + the server already gone) is never fatal.""" + from System.Net import WebClient + + close_url = url.rstrip('/') + '/api/close' + client = WebClient() + client.Headers.Add('Content-Type', 'application/json') + client.UploadString(close_url, 'POST', '{}') + + +def _on_page_message(host, obj): + """The cheap postMessage channel `vibe.webhost` keeps alive for exactly + this (see that module's own OWNER SCOPE UPDATE note): `{'type': + 'close'}` closes the window from inside the page itself, and + `{'type': 'log', 'text': ...}` is free-form page-side diagnostic text, + surfaced the way any other tool logs a client-side problem -- never + raised, never blocked on. + """ + try: + if not isinstance(obj, dict): + return + kind = obj.get('type') + if kind == 'close': + host.close() + elif kind == 'log': + output.print_md('- pa_web page: {0}'.format(obj.get('text'))) + except Exception: + pass + + +def _run_panel_web(el, index, know, asm=None): + """Builds one panel's reads, launches its own `tools/pa_web.py` server, + and shows the page. Returns the mark that ran (for the caller's + run-log), or None when nothing could be started at all. + + One server PER PANEL, shown modally (`show_dialog` blocks) one after + the other when more than one panel is selected -- the simplest faithful + reading of the new architecture; the old "Analyze All" batch UX was a + WPF-form convenience and is not reproduced here. + """ + reads = _reads_for(el, index, know, asm) + mark = reads.get('mark') or 'unnamed panel {0}'.format(index + 1) + + reads['job_root'] = designspec.root_dir(doc) + pa_dir = designspec.sub_dir(doc, 'panelanalysis') + stem = _safe_name(mark) + reads_path = os.path.join(pa_dir, '{0}.reads.json'.format(stem)) + blob = json.dumps(reads, indent=1, sort_keys=True) + with io.open(reads_path, 'w', encoding='utf-8') as fh: + fh.write(as_text(blob)) + + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='opened', + values=reads) + + python_exe, tried = liftexport.find_python_report(know, PA_NEEDS) + if not python_exe: + msg = ('The page renders outside Revit, and ' + + liftexport.describe_python_search(tried, PA_NEEDS)) + output.print_md('- **{0}** -- NOT RUN: {1}'.format(mark, msg)) + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], + outcome='error', report_text=msg) + return None + if not os.path.isfile(PA_WEB_PY): + msg = 'tools/pa_web.py was not found at {0}.'.format(PA_WEB_PY) + output.print_md('- **{0}** -- NOT RUN: {1}'.format(mark, msg)) + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], + outcome='error', report_text=msg) + return None + + process, url, err_lines = _launch_pa_web(python_exe, reads_path) + if not url: + msg = 'pa_web.py did not report a URL in time.' + if err_lines: + msg += ' Last output: ' + ' | '.join(err_lines[-10:]) + output.print_md('- **{0}** -- NOT RUN: {1}'.format(mark, msg)) + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], + outcome='error', report_text=msg) + try: + if not process.HasExited: + process.Kill() + except Exception: + pass + return None + + fallback_reason = None + try: + host = WebHost(u'Panel Analysis – {0}'.format(mark), url, + _on_page_message) + if host.init_error: + fallback_reason = host.init_error + else: + host.show_dialog() + except WebHostError as exc: + fallback_reason = str(exc) + + if fallback_reason: + liftexport.open_file(url) # UseShellExecute=True opens a URL fine + forms.alert( + 'WebView2 could not be started ({0}). Opened {1} in your ' + 'default browser instead -- the server keeps running until ' + 'you press Close on the page.'.format(fallback_reason, url), + title=TITLE) + output.print_md('- **{0}** -- opened in the default browser ' + '(WebView2 unavailable): {1}'.format(mark, url)) + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], + outcome='ok', report_text='opened in browser: ' + url) + return mark + + # The window closed. Tell the server to shut down, then give it a few + # seconds before killing it outright -- the same "best-effort, then + # force" shape `run_panelanalysis` uses for the one-shot CLI above. + from System.Threading import Thread + + try: + _post_close(url) + except Exception: + pass + waited = 0 + while waited < 3000: + try: + if process.HasExited: + break + except Exception: + break + Thread.Sleep(100) + waited += 100 + try: + if not process.HasExited: + process.Kill() + except Exception: + pass + + output.print_md('- **{0}** -- closed.'.format(mark)) + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='closed') + return mark + + +# ------------------------------------------------------------- WPF (retired) +# +# Everything below, down to `show_form`, is the retired WPF renderer -- kept +# so the owner can compare it against the HTML page once, never imported or +# called while `_USE_WPF` is False (see that constant's own note above). The +# import block itself is the one part that MUST be gated: these names are +# only referenced inside function/method BODIES below, which Python does not +# evaluate until called, but `clr.AddReference` + the imports themselves run +# at load time regardless of whether anything calls into them afterwards. + +if _USE_WPF: + clr.AddReference('PresentationFramework') + clr.AddReference('PresentationCore') + clr.AddReference('WindowsBase') + + from System.Windows import (Window, Thickness, FontWeights, FontStyles, + TextWrapping, TextDecorations, + SizeToContent, WindowStartupLocation, + HorizontalAlignment, VerticalAlignment, + Visibility) + from System.Windows.Controls import (StackPanel, ScrollViewer, Expander, + TextBox, TextBlock, Button, Border, + Orientation, ScrollBarVisibility, + Canvas, Grid, ColumnDefinition, + RowDefinition, TabControl, TabItem, + CheckBox, ComboBox, Viewbox, + TextChangedEventHandler) + from System.Windows.Controls import Label as WpfLabel + from System.Windows.Controls.Primitives import TextBoxBase + from System.Windows.Documents import Run + from System.Windows.Controls import StretchDirection + from System.Windows.Media import (Brushes, RotateTransform, + ScaleTransform, Stretch, + DoubleCollection, ColorConverter, + SolidColorBrush) + from System.Windows.Input import Keyboard, ModifierKeys, Cursors + from System.Windows.Shapes import Line, Polygon, Rectangle, Ellipse + from System.Windows import (Point, BaselineAlignment, GridLength, + GridUnitType) + from System.Windows.Media import PointCollection + from System.Windows.Threading import DispatcherPriority + from System import Action + + + def _label_block(text, bold=False, size=12, brush=None): + tb = TextBlock() + tb.Text = text + tb.TextWrapping = TextWrapping.Wrap + tb.FontSize = size + if bold: + tb.FontWeight = FontWeights.Bold + if brush is not None: + tb.Foreground = brush + tb.Margin = Thickness(0, 2, 0, 2) + return tb + + + # A prime is the only thing in these labels that a subscript follows, so the + # rule is narrow enough to be safe: f′c, f′ci. Everything else that needs a + # subscript (Aₛ, Cₛ, fₚᵤ) carries a real Unicode one in the label itself. + _PRIME = u'′' + + + def _math_block(text, bold=False, size=12, brush=None): + """A label whose primed symbols render as real notation. + + `f′ci` becomes an f, a prime, and a SUBSCRIPT ci -- three inline Runs, not + a string that hopes the reader knows what `sqrt f'ci` meant. WPF does the + baseline shift; nothing here draws glyphs. + """ + tb = TextBlock() + tb.TextWrapping = TextWrapping.Wrap + tb.FontSize = size + if bold: + tb.FontWeight = FontWeights.Bold + if brush is not None: + tb.Foreground = brush + tb.Margin = Thickness(0, 2, 0, 2) + + if _PRIME not in text: + tb.Text = text + return tb + + i = 0 + while i < len(text): + j = text.find(_PRIME, i) + if j < 0: + tb.Inlines.Add(Run(text[i:])) + break + tb.Inlines.Add(Run(text[i:j + 1])) # up to and incl. the prime + k = j + 1 + while k < len(text) and text[k].isalpha() and text[k].islower(): + k += 1 + if k > j + 1: + sub = Run(text[j + 1:k]) + sub.BaselineAlignment = BaselineAlignment.Subscript + sub.FontSize = max(7, size - 3) + tb.Inlines.Add(sub) + i = k + return tb + + + # ------------------------------------------------------------- the drawing + # + # `vibe.panelsketch` is Revit-free and host-tested; every entry point in it + # (`sketch`, `elevation`, `plan`, `section`, `weight_diagram`, and whatever a + # sibling agent adds after this lands) returns the SAME flat primitive + # vocabulary -- rect / line / support / text / marker / swatch / dot, plain + # dicts, pixel coordinates. This is the only place that vocabulary gets + # turned into WPF shapes, so a new `source` needs no new code here as long + # as it stays inside that vocabulary; a `kind` this dict has never seen is + # simply skipped (the drawing degrades one primitive short, not to a crash -- + # unlike a block `kind`, an unrecognised drawing PRIMITIVE has no owner- + # visible obligation of its own, and `problems` already carries the sentence + # that explains what is missing). + + SKETCH_W = 380 + SKETCH_H = 190 + + _ROLE_BRUSH = { + 'panel': Brushes.SteelBlue, 'panel-plan': Brushes.SteelBlue, + 'panel-side': Brushes.SteelBlue, 'panel-section': Brushes.SteelBlue, + 'panel-crosssection': Brushes.SteelBlue, + 'panel-elevation-thin': Brushes.SteelBlue, + 'support': Brushes.Firebrick, 'support-label': Brushes.Firebrick, + 'support-fraction': Brushes.Firebrick, + 'dimension': Brushes.DimGray, 'annotation': Brushes.DimGray, + 'openings-note': Brushes.DarkGoldenrod, 'note': Brushes.DarkGoldenrod, + 'lifters-note': Brushes.DarkGoldenrod, + 'title': Brushes.Black, 'caption': Brushes.Black, + 'opening': Brushes.DarkGoldenrod, 'opening-label': Brushes.DarkGoldenrod, + 'reveal': Brushes.DarkOrange, 'reveal-label': Brushes.DarkOrange, + 'solid-zone': Brushes.DimGray, 'solid-zone-label': Brushes.DimGray, + 'section-property': Brushes.Black, + 'legend-heading': Brushes.Black, 'legend-glyph': Brushes.DimGray, + 'legend-fill-label': Brushes.DimGray, + 'strand': Brushes.SteelBlue, 'strand-label': Brushes.Black, + 'strand-line': Brushes.SteelBlue, + 'centroid': Brushes.Firebrick, 'cg': Brushes.Firebrick, + 'cg-lead': Brushes.Firebrick, + 'moment-arm': Brushes.DarkGoldenrod, + 'moment-arm-label': Brushes.DarkGoldenrod, + 'beam': Brushes.Black, + 'wythe': Brushes.SteelBlue, 'insulation': Brushes.DarkGoldenrod, + 'layer-label': Brushes.DimGray, + } + + # Fills for rect primitives by role; anything not listed is AliceBlue. + _RECT_FILL = { + 'opening': Brushes.Transparent, 'reveal': Brushes.Transparent, + 'solid-zone': Brushes.Transparent, + 'wythe': Brushes.LightSteelBlue, 'insulation': Brushes.LemonChiffon, + } + + _FILL_BRUSH = { + 'Not Specified': Brushes.Gainsboro, + 'Stripping': Brushes.LightSkyBlue, + 'Erection': Brushes.LightGreen, + 'Stripping + Erection': Brushes.Khaki, + 'Transportation': Brushes.LightSalmon, + } + + + def _role_brush(role): + return _ROLE_BRUSH.get(role, Brushes.DimGray) + + + def _prim_brush(value, default_hex): + """A `'#rgb'` / `'#rrggbb'` colour-STRING hint (the convention + `pa_sketch_freebody`'s restraint/arrowhead primitives use for their + `'stroke'` / `'fill'` keys) -> a `SolidColorBrush`. + + `default_hex` is what stands in when `value` is missing, not text, or + fails to parse -- `None` means "transparent" (the fill default: an + un-hinted polygon/circle is meant to be hollow, not solid), any other + string is itself parsed as a colour. Never raises -- an unparseable hex + string is exactly as survivable as no hint at all, the same discipline + `_resolve_drawing_fn` and friends already follow. + """ + text = value if (isinstance(value, string_types) and value) else default_hex + if text is None: + return Brushes.Transparent + try: + return SolidColorBrush(ColorConverter.ConvertFromString(text)) + except Exception: + return Brushes.Transparent if default_hex is None else Brushes.DimGray + + + def _sketch_text(prim): + tb = TextBlock() + tb.Text = prim.get('text', '') + # 'font_size' (pa_sketch_extents / pa_sketch_freebody) and 'font_px' + # (pa_sketch_handling) are the same hint under two names; a primitive + # normally carries only one, but 'font_px' wins when both are present. + tb.FontSize = prim.get('font_px', prim.get('font_size', 10)) + tb.Foreground = _role_brush(prim.get('role')) + x = prim.get('x', 0) + if prim.get('anchor') == 'middle': + # Estimated, not measured: a Measure() pass here would need a layout + # that does not exist yet at build time, and half a character of drift + # on a label is invisible where a crash is not. + x -= len(tb.Text) * 2.6 + if prim.get('orientation') == 'vertical': + tb.RenderTransform = RotateTransform(-90) + Canvas.SetLeft(tb, x) + Canvas.SetTop(tb, prim.get('y', 0)) + return tb + + + def _draw_primitive(canvas, prim): + """One `panelsketch` primitive -> WPF shape, appended onto `canvas`. + + A `kind` this function does not recognise is skipped, never raised on -- + a future primitive kind lands here the day this function is taught it, + and until then the rest of the drawing still renders. + """ + kind = prim.get('kind') + role = prim.get('role') + flagged = bool(prim.get('flagged')) + brush = Brushes.Red if flagged else _role_brush(role) + + if kind == 'rect': + r = Rectangle() + r.Width = max(1, prim.get('w', 1)) + r.Height = max(1, prim.get('h', 1)) + # 'stroke'/'fill'/'width' are the same colour/thickness hints + # `circle`/`polygon` read below; a rect with none of them keeps the + # original role-brush-and-fill behaviour exactly (no primitive today + # sets them), flagged still wins over an explicit 'stroke' the way + # every other kind here treats flagged as the louder signal. + r.Stroke = brush if (flagged or 'stroke' not in prim) \ + else _prim_brush(prim.get('stroke'), '#333') + r.StrokeThickness = prim.get('width', 1.5) + r.Fill = _RECT_FILL.get(role, Brushes.AliceBlue) if 'fill' not in prim \ + else _prim_brush(prim.get('fill'), None) + Canvas.SetLeft(r, prim.get('x', 0)) + Canvas.SetTop(r, prim.get('y', 0)) + canvas.Children.Add(r) + elif kind == 'line': + ln = Line() + ln.X1, ln.Y1 = prim.get('x1', 0), prim.get('y1', 0) + ln.X2, ln.Y2 = prim.get('x2', 0), prim.get('y2', 0) + ln.Stroke = brush + # 'width' is a stroke-thickness hint (pa_sketch_freebody's member + # line: 'width': 3.0); default 1 keeps every other line unchanged. + ln.StrokeThickness = prim.get('width', 1.0) + # Dash hint: 'dash' is an explicit [on, off, ...] array; 'dashed' + # (pa_sketch_handling's dunnage lines, pa_sketch_reinf's mesh) is + # the plain boolean flag those two modules actually emit -- both + # read here, an explicit 'dash' array wins if a caller ever gives + # one, 'dashed': True falls back to a fixed pattern (same + # [4.0, 2.0] the cutline itself already draws with). + dash = prim.get('dash') + if dash: + ln.StrokeDashArray = DoubleCollection(dash) + elif prim.get('dashed'): + ln.StrokeDashArray = DoubleCollection([4.0, 2.0]) + canvas.Children.Add(ln) + elif kind == 'support': + # The support triangle points UP at the soffit it holds, and a + # support the user typed outside the panel is drawn hollow at the + # end it was clamped to -- present and visibly wrong, never gone. + # + # `edge_kind` (pa_sketch_handling's plan-view edge lifters) is + # 'side' for a lifter on one of the panel's two LONG edges -- the + # default upward-pointing glyph above already reads correctly + # there -- or 'end' for one on the panel's LEFT/RIGHT end, where + # "pointing up at the soffit" makes no sense on a plan view: it + # has to point INWARD, horizontally. `edge` names which end + # ('bottom' = the panel's start, x_ft near 0, drawn at the + # CANVAS's left -- see `_end_side_from_x` -- so it points RIGHT, + # into the panel; 'top' = the finish end, drawn at the canvas's + # right, so it points LEFT). + x, y = prim.get('x', 0), prim.get('y', 0) + tri = Polygon() + pts = PointCollection() + if prim.get('edge_kind') == 'end': + if prim.get('edge') == 'top': + pts.Add(Point(x, y)) + pts.Add(Point(x + 11, y - 6)) + pts.Add(Point(x + 11, y + 6)) + else: + pts.Add(Point(x, y)) + pts.Add(Point(x - 11, y - 6)) + pts.Add(Point(x - 11, y + 6)) + else: + pts.Add(Point(x, y)) + pts.Add(Point(x - 6, y + 11)) + pts.Add(Point(x + 6, y + 11)) + tri.Points = pts + tri.Stroke = brush + tri.StrokeThickness = 1.5 + tri.Fill = Brushes.Transparent if flagged else brush + canvas.Children.Add(tri) + elif kind == 'text': + canvas.Children.Add(_sketch_text(prim)) + elif kind == 'marker': + x, y = prim.get('x', 0), prim.get('y', 0) + glyph = prim.get('glyph') + if glyph == 'diamond': + poly = Polygon() + pts = PointCollection() + for dx, dy in ((0, -6), (6, 0), (0, 6), (-6, 0)): + pts.Add(Point(x + dx, y + dy)) + poly.Points = pts + poly.Stroke = brush + poly.StrokeThickness = 1.5 + poly.Fill = brush + canvas.Children.Add(poly) + else: # 'cross', or unnamed + for x1, y1, x2, y2 in ((x - 6, y, x + 6, y), + (x, y - 6, x, y + 6)): + ln = Line() + ln.X1, ln.Y1, ln.X2, ln.Y2 = x1, y1, x2, y2 + ln.Stroke = brush + ln.StrokeThickness = 2.0 + canvas.Children.Add(ln) + elif kind == 'swatch': + rect = Rectangle() + rect.Width = 12 + rect.Height = 12 + rect.Stroke = Brushes.Gray + rect.StrokeThickness = 0.5 + rect.Fill = _FILL_BRUSH.get(prim.get('fill'), Brushes.Gainsboro) + Canvas.SetLeft(rect, prim.get('x', 0)) + Canvas.SetTop(rect, prim.get('y', 0)) + canvas.Children.Add(rect) + elif kind == 'dot': + dot = Ellipse() + dot.Width = 5 + dot.Height = 5 + dot.Stroke = brush + dot.StrokeThickness = 1.0 + dot.Fill = brush + Canvas.SetLeft(dot, prim.get('x', 0) - 2.5) + Canvas.SetTop(dot, prim.get('y', 0) - 2.5) + canvas.Children.Add(dot) + elif kind == 'circle': + # `pa_sketch_freebody`'s "Free"/unrecognised restraint glyph -- + # a hollow, positioned dot rather than the Pin/Floor-tie triangle + # (there is no restraint SYMBOL for a type that restrains nothing). + # `'cx'/'cy'/'r'` in pixels; `'stroke'/'fill'` are the same + # `'#rgb'`/`'#rrggbb'` colour-string hints the module docstring + # names (default stroke '#333', fill transparent -- an un-hinted + # circle is hollow, not solid); `'width'` is the outline thickness + # (default 1). `flagged` keeps the same "hollow, red outline" + # convention `support` above uses, overriding any explicit colours. + cx, cy = prim.get('cx', 0), prim.get('cy', 0) + r = max(0.5, prim.get('r', 4)) + dot = Ellipse() + dot.Width = r * 2.0 + dot.Height = r * 2.0 + dot.Stroke = brush if flagged else _prim_brush(prim.get('stroke'), '#333') + dot.StrokeThickness = prim.get('width', 1.0) + dot.Fill = Brushes.Transparent if flagged \ + else _prim_brush(prim.get('fill'), None) + Canvas.SetLeft(dot, cx - r) + Canvas.SetTop(dot, cy - r) + canvas.Children.Add(dot) + elif kind == 'polygon': + # `pa_sketch_freebody`'s restraint triangles (Pin / Floor-tie) and + # every arrowhead (loads, wind-zone arrows). `'points'` (or + # `'pts'`) is a list of `(x, y)` pairs or `{'x':, 'y':}` dicts, in + # pixels; `'stroke'/'fill'/'width'` are the same hints `circle` + # reads above -- an arrowhead carries none of them (see + # `pa_sketch_freebody`'s load/wind-zone arrowheads), so the + # default stroke '#333' width 1, transparent fill has to be a + # sensible LOOK on its own, not just a placeholder. + points = prim.get('points') or prim.get('pts') or [] + pts = PointCollection() + for pt in points: + if isinstance(pt, dict): + px, py = pt.get('x', 0), pt.get('y', 0) + else: + px, py = pt[0], pt[1] + pts.Add(Point(px, py)) + poly = Polygon() + poly.Points = pts + poly.Stroke = brush if flagged else _prim_brush(prim.get('stroke'), '#333') + poly.StrokeThickness = prim.get('width', 1.0) + poly.Fill = Brushes.Transparent if flagged \ + else _prim_brush(prim.get('fill'), None) + canvas.Children.Add(poly) + # else: an unrecognised primitive kind. Skipped, not raised on. + + + def _resolve_drawing_fn(source, modname=None): + """(fn, None) or (None, error_sentence) -- never raises. + + `modname` present -> `getattr(vibe., source)`, the new + `pa_sketch_*` seam the sibling drawing modules land in (block contract: + `'module': 'pa_sketch_extents'`); `modname` absent -> the original + `vibe.panelsketch` lookup, unchanged. A module that does not exist yet + (not landed) or a `source` it does not define degrades to a named + sentence, exactly like the old bare `getattr(panelsketch, source, None)` + did -- never an exception that takes the drawing, or the tab, down. + """ + if not source: + return None, 'drawing has no source.' + if not modname: + fn = getattr(panelsketch, source, None) + if fn is None: + return None, "drawing source '{0}' is not available.".format( + source) + return fn, None + mod, err = _load_section_module(modname) + if mod is None: + return None, ("drawing module 'vibe.{0}' could not be loaded " + "({1}).").format(modname, err) + fn = getattr(mod, source, None) + if fn is None: + return None, ("drawing source '{0}' is not available in " + "vibe.{1}.").format(source, modname) + return fn, None + + + def _drawing_canvas(source, values, w, h, modname=None): + """A Border-wrapped drawing for one `drawing` block. + + `source` names an entry point in `vibe.panelsketch` (or, with `modname`, + in `vibe.` -- see `_resolve_drawing_fn`); an unknown name, or + one that raises, becomes a visible note in place of the picture -- never + a crash, never a blank box indistinguishable from an empty drawing. + + THE OVERLAP A FIXED-SIZE DRAWING USED TO CAUSE (owner, 2026-09-03: the + drawings in Concrete Extents and Structural Model printed on top of the + neighbouring column's text) is two causes, neither fixable by moving a + number: + + * the problem NOTES used to be laid into the sketch `Canvas` itself with + `Canvas.SetLeft/SetTop` at a fixed 24 px pitch. A Canvas does not + measure its children, so a note that wrapped to three lines simply + drew over whatever was under it, and the canvas never grew. They are + now real `TextBlock`s in a `StackPanel` BELOW the drawing, in their + own Auto grid row, so the block's height is the drawing plus however + much text there actually is. + * the drawings declare fixed pixel sizes (up to 900x260) inside equal + star columns that are ~360 px wide on the standard window, and neither + `Grid` nor `StackPanel` clips. Both the canvas and the outer border + clip (`ClipToBounds`), so nothing can paint outside its own cell even + if a primitive is drawn past the declared extent -- and the canvas is + pre-shrunk to fit a normal column (see below), so the clip is a + backstop, not the mechanism. + + WHY THE SHRINK IS A FIXED `ScaleTransform`, NOT A `Viewbox` (owner, + 2026-09-03, second complaint the same day: "the two elevation drawings + don't zoom with the rest"). This USED to be a `Viewbox` with + `StretchDirection.DownOnly`, which fits itself to whatever space WPF + offers at measure time -- and `show_form`'s Ctrl+wheel zoom is a + `ScaleTransform` set as the page `stack`'s `LayoutTransform`, which works + by DEFLATING the space it hands its children by `1 / scale` before + measuring them, then INFLATING the result back by `scale` before + reporting it upward. For an element whose own size is a function of + "how much space am I given" -- exactly what a `Viewbox` is -- that + deflate and that inflate cancel EXACTLY: at zoom=2 the `Viewbox` is + handed HALF the column width, shrinks the drawing to fit that halved + width, and the ancestor transform blows the (now half-sized) result back + up by 2 -- net: the same pixel size as zoom=1, every time. Ordinary text + has no such cancellation because a `TextBlock`'s glyph size does not + depend on the space it is offered (it just wraps), which is why the rest + of the page visibly zoomed and the drawings alone did not. + `panelsketch.drawing_display_scale(w, h)` computes the shrink factor + instead as plain arithmetic off the drawing's OWN declared size -- + a fixed number, unrelated to whatever space WPF offers -- and + `canvas.LayoutTransform = ScaleTransform(scale, scale)` applies it as an + ordinary, non-adaptive transform. A fixed transform on a descendant + composes MULTIPLICATIVELY with an ancestor's `LayoutTransform` (like any + two nested transforms) instead of being cancelled by it, so the drawing + now zooms exactly like the text around it. + """ + canvas = Canvas() + canvas.Width = w + canvas.Height = h + canvas.Background = Brushes.WhiteSmoke + + fn, resolve_err = _resolve_drawing_fn(source, modname) + if fn is None: + primitives, problems = [], [resolve_err] + else: + try: + primitives, problems = fn(values, w, h) + except Exception as exc: # never take the form down + primitives, problems = [], ['drawing failed: {0}'.format(exc)] + + for prim in primitives: + try: + _draw_primitive(canvas, prim) + except Exception: + pass # one bad primitive is never worth the whole page + + canvas.ClipToBounds = True + + scale = panelsketch.drawing_display_scale(w, h) + if scale < 1.0: + canvas.LayoutTransform = ScaleTransform(scale, scale) + canvas.HorizontalAlignment = HorizontalAlignment.Left + + notes = StackPanel() + notes.Margin = Thickness(4, 2, 4, 2) + for problem in problems[:4]: + note = TextBlock() + note.Text = u'• ' + problem + note.FontSize = 10 + note.Foreground = Brushes.Firebrick + note.TextWrapping = TextWrapping.Wrap + note.Margin = Thickness(0, 0, 0, 3) + notes.Children.Add(note) + + outer = Grid() + outer.ColumnDefinitions.Add(ColumnDefinition()) + for _ in range(2): + row = RowDefinition() + row.Height = GridLength.Auto + outer.RowDefinitions.Add(row) + Grid.SetRow(canvas, 0) + Grid.SetColumn(canvas, 0) + outer.Children.Add(canvas) + Grid.SetRow(notes, 1) + Grid.SetColumn(notes, 0) + outer.Children.Add(notes) + + border = Border() + border.BorderBrush = Brushes.Gainsboro + border.BorderThickness = Thickness(1) + border.Margin = Thickness(0, 4, 0, 4) + border.ClipToBounds = True + border.HorizontalAlignment = HorizontalAlignment.Left + border.Child = outer + return border + + + # --------------------------------------------------------------- the chrome + # + # Eriksson prints one BLOCK per idea, titled, with its inputs in two columns + # under the title. A person reading it never has to hold fifty rows in their + # head at once, which is exactly what this form used to ask of them (the + # owner, twice: "太丑了", "你这么输入用户上哪儿能看懂"). These three widgets + # are the whole of the answer: a titled box, a two-column row, and a plain + # sentence under a field saying what it is for. + + _INK = Brushes.Black + _QUIET = Brushes.Gray + _RULE = Brushes.Gainsboro + _ZEBRA = Brushes.GhostWhite + + + class _RowCount(object): + """A field-row counter, so alternate rows get a faint background band. + + ONE PER CONTAINER, never global: a shared counter would make the stripe + depend on how many rows some unrelated column happened to draw first, so + two side-by-side columns would band out of step and the form would look + like the bands meant something. A fresh counter per column in + `_render_columns` and one per section tab keeps every visual group starting + on white, which is what makes the banding read as "these rows go together" + rather than as a highlight. + """ + + def __init__(self): + self.n = 0 + + def next_is_zebra(self): + self.n += 1 + return (self.n % 2) == 0 + + + def _hint_block(text): + """The plain sentence under a field. Grey, small, and never a warning -- + a warning that looks like help is read as neither.""" + tb = _math_block(text, size=10, brush=_QUIET) + tb.Margin = Thickness(0, 0, 0, 4) + return tb + + + # The label column takes the slack (star) instead of a fixed width; the + # symbol and editor columns stay narrow and fixed. The old layout gave all + # three a FIXED width (210 + 92 + 150 = 452px) and packed three of these rows + # side by side inside `columns` blocks whose star-sized columns are only + # ~360-380px wide on the 1180px window -- so every row was ~80px wider than the + # column that held it and its editor printed ON TOP of the next column's text. + # That was the overlap the owner reported. A star label column can never make + # the row wider than its container, so the row always fits and nothing lands on + # top of anything, at any window width. + SYMBOL_COL = 66 + EDITOR_COL = 138 + # A FOURTH fixed column, to the right of the editor, for a per-field read-out + # (today only the enclosure classification's GCpi). It used to be packed into + # the editor cell inside a horizontal StackPanel, which handed `_two_col_row` a + # 366px widget for a 138px column -- so on that one row the star label column + # was squeezed and its editor's left edge sat in from every other row's. That + # was the misalignment the owner reported. Every row now reserves this column + # whether it has an annotation or not, so no row's editor can be pushed. + ANNOT_COL = 150 + + # Editor `kind`s whose text box carries more than one number or line -- + # `make_editor` stretches these instead of pinning them to the fixed + # `EDITOR_COL`. See `make_editor`'s own comment for the incident this fixes. + _MULTI_VALUE_EDITOR_KINDS = ('supports', 'openings') + + + def _two_col_row(label_widget, editor, symbol=None, hint=None, annot=None): + """One input, laid out `name | symbol | box | note` with the plain-English + name first and the notation BESIDE it rather than instead of it. + + The symbol column is what lets a label say `Tension allowed while + handling` and still show `n·√f′ci` to the engineer who wants it. Neither + reader has to decode the other's version. `hint` is accepted but no longer + rendered here -- field help now lives in the one collapsed Notes panel at + the top of the form (owner, 2026-09-03), collected by `_render_field`. + `annot` is an optional widget for the fourth column; the column is present + and fixed-width on EVERY row regardless, so an annotated row lines up with + an unannotated one. + """ + grid = Grid() + lab_col = ColumnDefinition() + lab_col.Width = GridLength(1, GridUnitType.Star) + lab_col.MinWidth = 110 + grid.ColumnDefinitions.Add(lab_col) + sym_col = ColumnDefinition() + sym_col.Width = GridLength(SYMBOL_COL) + grid.ColumnDefinitions.Add(sym_col) + ed_col = ColumnDefinition() + # FIXED, not Auto: an Auto editor column sizes to that row's own editor, so + # a wider editor (the enclosure dropdown carries a GCpi read-out beside it) + # shrank the star label column on THAT row and shifted its editor's left + # edge in from every other row's -- the misalignment the owner reported. + # A fixed width puts every editor's left edge at the same x, at any width. + ed_col.Width = GridLength(EDITOR_COL) + grid.ColumnDefinitions.Add(ed_col) + annot_col = ColumnDefinition() + annot_col.Width = GridLength(ANNOT_COL) + grid.ColumnDefinitions.Add(annot_col) + + # A single Auto-height row: a star-height row (the WPF default for a bare + # RowDefinition) vertically centres wrapped label text and lets a two-line + # label ride up over its neighbours; Auto sizes the row to its tallest cell + # so a long label pushes the row taller instead of overlapping. + row = RowDefinition() + row.Height = GridLength.Auto + grid.RowDefinitions.Add(row) + + label_widget.Margin = Thickness(0, 3, 8, 3) + label_widget.VerticalAlignment = VerticalAlignment.Center + Grid.SetRow(label_widget, 0) + Grid.SetColumn(label_widget, 0) + grid.Children.Add(label_widget) + + sym = _math_block(symbol or '', size=11, brush=_QUIET) + sym.Margin = Thickness(0, 3, 8, 3) + sym.VerticalAlignment = VerticalAlignment.Center + Grid.SetRow(sym, 0) + Grid.SetColumn(sym, 1) + grid.Children.Add(sym) + + editor.VerticalAlignment = VerticalAlignment.Center + Grid.SetRow(editor, 0) + Grid.SetColumn(editor, 2) + grid.Children.Add(editor) + + note = annot if annot is not None else TextBlock() + note.VerticalAlignment = VerticalAlignment.Center + Grid.SetRow(note, 0) + Grid.SetColumn(note, 3) + grid.Children.Add(note) + + # Zero, not 2: `_render_field` wraps this grid in the zebra Border, and a + # margin here would leave an unpainted gap between bands so the striping + # read as separate boxes rather than a continuous table. + grid.Margin = Thickness(0) + return grid + + + def _tab(title, child): + item = TabItem() + item.Header = title + scroller = ScrollViewer() + scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto + # Disabled, not Auto: an Auto horizontal scrollbar measures the content at + # INFINITE width, so a `columns` grid's star columns are measured against a + # width they are never arranged at -- measure and arrange disagree, and the + # drawings inside a column paint over the neighbouring column's text. With + # the horizontal scroller off the content is measured at the viewport's own + # width and the star columns agree in both passes. + scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled + scroller.Content = child + item.Content = scroller + return item + + + # ------------------------------------------------------- the generic renderer + # + # Everything below turns the block vocabulary five sibling agents build + # against (pa_criteria / pa_extents / pa_structural / pa_handling / + # pa_reinforcement, each a `page(values, reads=None) -> (blocks, problems)`) + # into WPF. This is the ONLY place that vocabulary is interpreted -- nothing + # any of those five modules built is visible to the owner until it passes + # through here. An unknown block `kind` renders as a visible grey note naming + # it (never vanishes); a block that raises while rendering is caught right + # where it is dispatched and becomes a small red note instead of taking the + # whole tab down. + + SECTION_MODULES = ( + ('Design Criteria', 'pa_criteria'), + ('Concrete Extents', 'pa_extents'), + ('Structural Model', 'pa_structural'), + ('Handling', 'pa_handling'), + ('Reinforcement', 'pa_reinforcement'), + ) + + + def _load_section_module(modname): + """(module, None) or (None, exception) -- never raises. + + A sibling agent's file can be mid-edit (syntax error) or simply not + landed yet (ImportError); both degrade the same way, to a tab that names + the section and says why it is not available, never a crash that takes + every OTHER section down with it. + """ + try: + mod = __import__('vibe.' + modname, fromlist=[modname]) + return mod, None + except Exception as exc: + return None, exc + + + def _missing_section_note(title, modname, reason): + box = StackPanel() + box.Margin = Thickness(4) + msg = (u"The '{0}' section is not available yet -- vibe.{1} could not " + u"be loaded ({2}). Its inputs cannot be shown or edited on this " + u"panel until it lands; nothing else on this form is affected." + ).format(title, modname, reason) + tb = _math_block(msg, size=11, brush=Brushes.Firebrick) + tb.TextWrapping = TextWrapping.Wrap + box.Children.Add(tb) + return box + + + def _grid_star_columns(grid, n): + for _ in range(n): + col = ColumnDefinition() + col.Width = GridLength(1, GridUnitType.Star) + grid.ColumnDefinitions.Add(col) + + + def _cell_border(child, header=False, zebra=False): + b = Border() + b.BorderBrush = _RULE + b.BorderThickness = Thickness(0.6) + b.Padding = Thickness(4, 2, 4, 2) + if header: + b.Background = Brushes.WhiteSmoke + elif zebra: + b.Background = Brushes.GhostWhite + else: + b.Background = Brushes.White + b.Child = child + return b + + + def _grid_text_cell(text, bold=False, header=False, zebra=False): + tb = _math_block(u'' if text is None else str(text), bold=bold, size=11) + tb.TextWrapping = TextWrapping.Wrap + return _cell_border(tb, header=header, zebra=zebra) + + + def _em_dash_cell(zebra=False): + """A matrix cell that does NOT apply to this stage -- an em dash, never + blank. Blank means 'applies, not filled in'; this means 'does not apply' + -- the two must never look the same.""" + tb = TextBlock() + tb.Text = u'—' + tb.HorizontalAlignment = HorizontalAlignment.Center + tb.Foreground = _QUIET + return _cell_border(tb, zebra=zebra) + + + def _render_heading(block): + level = block.get('level') or 1 + text = block.get('text', '') + panel = StackPanel() + if level == 1: + head = _math_block(text, bold=True, size=15) + panel.Children.Add(head) + rule = Border() + rule.BorderBrush = _RULE + rule.BorderThickness = Thickness(0, 0, 0, 2) + rule.Margin = Thickness(0, 2, 0, 8) + panel.Children.Add(rule) + elif level == 2: + head = _math_block(text, bold=True, size=13) + panel.Children.Add(head) + rule = Border() + rule.BorderBrush = _RULE + rule.BorderThickness = Thickness(0, 0, 0, 1) + rule.Width = 170 + rule.HorizontalAlignment = HorizontalAlignment.Left + rule.Margin = Thickness(0, 2, 0, 6) + panel.Children.Add(rule) + else: + head = _math_block(text, bold=True, size=11.5) + head.Margin = Thickness(0, 4, 0, 2) + panel.Children.Add(head) + return panel + + + def _render_columns(block, form): + cols = block.get('columns') or [] + if not cols: + return StackPanel() + grid = Grid() + _grid_star_columns(grid, len(cols)) + # Auto, not the default star: a star row hands every column the full height + # whether it needs it or not, and a column whose content is taller than the + # row is simply drawn past it. Auto sizes the row to the tallest column. + row_def = RowDefinition() + row_def.Height = GridLength.Auto + grid.RowDefinitions.Add(row_def) + for i, col_blocks in enumerate(cols): + panel = StackPanel() + panel.Margin = Thickness(0) + panel.VerticalAlignment = VerticalAlignment.Top + rows = _RowCount() + for b in (col_blocks or []): + w = _render_block(b, form, rows=rows) + if w is not None: + panel.Children.Add(w) + # A clipping Border per column is what actually stops a fixed-size + # drawing painting over the next column's text: neither Grid nor + # StackPanel clips its children, so without this the overflow is drawn, + # not cut. The 6px side margin moved off the panel and onto the border + # so the clip boundary is the visible column edge. + cell = Border() + cell.ClipToBounds = True + cell.Margin = Thickness(6, 0, 6, 0) + cell.Child = panel + Grid.SetColumn(cell, i) + Grid.SetRow(cell, 0) + grid.Children.Add(cell) + return grid + + + def _render_field(block, form, rows=None): + key = block.get('key') + label = block.get('label') or key or '' + shown = label + u' *' if block.get('required') else label + lab = _math_block(shown, size=11) + + editor_kind = block.get('editor') + choices = block.get('choices') + # Owner, 2026-09-03: pick-list fields render as a dropdown, not a text box. + # A section module that already declared this field a 'choice' keeps its + # own options; a plain text/number field whose key is a known enumeration + # (F.choices_for) is upgraded here to an editable ComboBox over the generic + # options -- so the sibling section modules need no edit for this. + if editor_kind in (None, 'text', 'num'): + registry_choices = F.choices_for(key) + if registry_choices: + editor_kind = 'choice' + if not choices: + choices = list(registry_choices) + + # Field help no longer renders inline under the box. Every explanation is + # collected into the one collapsed Notes panel at the top of the form + # (owner, 2026-09-03: keep the help, just tuck it into the notes) -- the + # field row itself carries none. + hint = block.get('hint') + if hint and key: + form.add_note(key, label, block.get('symbol'), hint) + + # A MULTI-VALUE editor ('supports', 'openings' -- a comma list or + # several lines) never fits `_two_col_row`'s fixed 138px `EDITOR_COL`: + # a `Grid`'s Fixed-width column arranges its child AT that pixel width + # regardless of the child's own `MinWidth` or `HorizontalAlignment` + # (only an Auto/Star column grows), so packing one into that row just + # truncates it again under a different name (owner, 2026-09-04: + # "Support positions" was still cut off). These fields get their own + # stacked layout instead -- label above, editor below, `Stretch`ed to + # the row's REAL width -- so `make_editor`'s stretch actually has + # somewhere to grow into. + # + # A page module's OWN block is not the only source asked here: + # `pa_structural.py`'s `_field()` helper draws `supports_ft` as plain + # 'text' (never 'supports'), so trusting only `editor_kind` misses the + # exact field the owner named. `F._spec(key)['kind']` -- the field + # MODEL's own registry (`_F`'s docstring: `'kind'` is "what parses it") -- + # is the more durable source of "this key holds more than one value" + # than whichever string kind one page's block happens to spell; it wins + # when it disagrees with the block. + resolved_kind = editor_kind + if resolved_kind not in _MULTI_VALUE_EDITOR_KINDS: + try: + spec = F._spec(key) if key else None + except Exception: + spec = None + canonical = (spec or {}).get('kind') + if canonical in _MULTI_VALUE_EDITOR_KINDS: + resolved_kind = canonical + if resolved_kind in _MULTI_VALUE_EDITOR_KINDS: + return _render_multi_value_field(block, form, lab, resolved_kind, + choices, rows) + + editor = form.make_editor(editor_kind, key, choices, width=EDITOR_COL) + row = _two_col_row(lab, editor, symbol=block.get('symbol'), + annot=form.annotation_for(key)) + # A faint band on every second row, so the eye can follow a label across to + # its own box on a page this wide (owner, 2026-09-03). No `rows` counter + # means the field is being rendered outside a counted container -- it gets + # the plain background rather than a stripe whose parity means nothing. + band = Border() + band.Background = (_ZEBRA if (rows is not None and rows.next_is_zebra()) + else Brushes.White) + band.Padding = Thickness(4, 1, 4, 1) + band.Child = row + return band + + + def _render_multi_value_field(block, form, lab, editor_kind, choices, rows): + """The stacked layout `_render_field` uses for a multi-value editor: the + label (+ symbol) on their own line, the editor on the next, `Stretch`ed + to the full row width -- see `_render_field`'s comment for why the + ordinary fixed-column row cannot hold one of these without truncating + it. Same zebra banding and annotation slot as an ordinary field row. + """ + key = block.get('key') + editor = form.make_editor(editor_kind, key, choices, width=EDITOR_COL) + # Belt and suspenders: `make_editor` already stretches a MULTI-VALUE + # `kind`, but this layout's whole point is a full-width box, so force it + # regardless of which `kind` string actually reached `make_editor`. + try: + editor.HorizontalAlignment = HorizontalAlignment.Stretch + editor.ClearValue(TextBox.WidthProperty) + except Exception: + pass + + head = StackPanel() + head.Orientation = Orientation.Horizontal + lab.Margin = Thickness(0, 0, 8, 0) + head.Children.Add(lab) + symbol = block.get('symbol') + if symbol: + sym = _math_block(symbol, size=11, brush=_QUIET) + head.Children.Add(sym) + + stack = StackPanel() + stack.Children.Add(head) + editor.Margin = Thickness(0, 3, 0, 3) + stack.Children.Add(editor) + annot = form.annotation_for(key) + if annot is not None: + stack.Children.Add(annot) + + band = Border() + band.Background = (_ZEBRA if (rows is not None and rows.next_is_zebra()) + else Brushes.White) + band.Padding = Thickness(4, 3, 4, 3) + band.Child = stack + return band + + + def _render_kv(block): + rows = block.get('rows') or [] + grid = Grid() + c0 = ColumnDefinition() + c0.Width = GridLength(200) + grid.ColumnDefinitions.Add(c0) + c1 = ColumnDefinition() + c1.Width = GridLength(1, GridUnitType.Star) + grid.ColumnDefinitions.Add(c1) + for _ in rows: + grid.RowDefinitions.Add(RowDefinition()) + for i, pair in enumerate(rows): + label, value = (pair[0], pair[1]) if len(pair) >= 2 else (pair[0], '') + lab = _math_block(str(label), size=11, brush=_QUIET) + lab.Margin = Thickness(0, 1, 8, 1) + val = _math_block(u'' if value is None else str(value), size=11) + val.Margin = Thickness(0, 1, 0, 1) + Grid.SetRow(lab, i) + Grid.SetColumn(lab, 0) + grid.Children.Add(lab) + Grid.SetRow(val, i) + Grid.SetColumn(val, 1) + grid.Children.Add(val) + return grid + + + def _render_sentence(block): + tb = _math_block(block.get('text', ''), size=11, brush=_QUIET) + tb.FontStyle = FontStyles.Italic + tb.Margin = Thickness(0, 2, 0, 6) + return tb + + + def _render_table(block, form): + headers = block.get('headers') or [] + header_groups = block.get('header_groups') + rows = block.get('rows') or [] + ncols = len(headers) + if ncols == 0: + return StackPanel() + + grid = Grid() + for _ in range(ncols): + grid.ColumnDefinitions.Add(ColumnDefinition()) + header_rows = 2 if header_groups else 1 + for _ in range(header_rows + len(rows)): + grid.RowDefinitions.Add(RowDefinition()) + + r = 0 + if header_groups: + c = 0 + for label, span in header_groups: + span = max(1, min(span, ncols - c)) + cell = _grid_text_cell(label, bold=True, header=True) + Grid.SetRow(cell, r) + Grid.SetColumn(cell, c) + Grid.SetColumnSpan(cell, span) + grid.Children.Add(cell) + c += span + r += 1 + for c, h in enumerate(headers): + cell = _grid_text_cell(h, bold=True, header=True) + Grid.SetRow(cell, r) + Grid.SetColumn(cell, c) + grid.Children.Add(cell) + r += 1 + + for ridx, row in enumerate(rows): + zebra = (ridx % 2 == 1) + for cidx in range(ncols): + value = row[cidx] if cidx < len(row) else '' + if isinstance(value, dict) and value.get('editor'): + # ANY editor kind, not just 'check': the load-combination + # factors are live numeric boxes inside a grid cell, which is + # the whole reason `lc_table` exists. + widget = form.make_editor(value.get('editor'), + value.get('key'), + value.get('choices'), + width=value.get('width')) + if value.get('editor') == 'check': + widget.HorizontalAlignment = HorizontalAlignment.Center + cell = _cell_border(widget, zebra=zebra) + else: + cell = _grid_text_cell(value, zebra=zebra) + Grid.SetRow(cell, r) + Grid.SetColumn(cell, cidx) + grid.Children.Add(cell) + r += 1 + return grid + + + def _render_matrix(block, form): + columns = block.get('columns') or [] + rows = block.get('rows') or [] + if not columns or not rows: + return StackPanel() + + grid = Grid() + c0 = ColumnDefinition() + c0.Width = GridLength(220) + grid.ColumnDefinitions.Add(c0) + for _ in columns: + col = ColumnDefinition() + col.Width = GridLength(1, GridUnitType.Star) + grid.ColumnDefinitions.Add(col) + for _ in range(1 + len(rows)): + grid.RowDefinitions.Add(RowDefinition()) + + hdr = _grid_text_cell('', bold=True, header=True) + Grid.SetRow(hdr, 0) + Grid.SetColumn(hdr, 0) + grid.Children.Add(hdr) + for ci, colname in enumerate(columns): + cell = _grid_text_cell(colname, bold=True, header=True) + Grid.SetRow(cell, 0) + Grid.SetColumn(cell, ci + 1) + grid.Children.Add(cell) + + for ridx, row in enumerate(rows): + r = ridx + 1 + zebra = (ridx % 2 == 1) + lbl_panel = StackPanel() + lbl_panel.Children.Add(_math_block(row.get('label', ''), size=11)) + # Owner, 2026-09-04, naming this table directly: "Handling: 小灰字太 + # 多了,去掉放到 notes 里面,默认折叠" -- a matrix row's quiet grey + # hint moves into the SAME collapsed Notes panel a field's `hint` + # does, instead of printing under every row label. Prefixed with the + # row's own label so it still reads on its own once it is out of + # context. + if row.get('hint'): + form.add_quiet_sentence( + u'{0}: {1}'.format(row.get('label', ''), row['hint'])) + lbl_cell = _cell_border(lbl_panel, zebra=zebra) + Grid.SetRow(lbl_cell, r) + Grid.SetColumn(lbl_cell, 0) + grid.Children.Add(lbl_cell) + + cells = row.get('cells') or [] + for ci in range(len(columns)): + cellval = cells[ci] if ci < len(cells) else None + if cellval is None: + wrapped = _cell_border(TextBlock(), zebra=zebra) + elif isinstance(cellval, dict) and cellval.get('na'): + wrapped = _em_dash_cell(zebra=zebra) + elif isinstance(cellval, dict): + widget = form.make_editor(cellval.get('editor'), + cellval.get('key'), + cellval.get('choices')) + wrapped = _cell_border(widget, zebra=zebra) + else: + wrapped = _grid_text_cell(cellval, zebra=zebra) + Grid.SetRow(wrapped, r) + Grid.SetColumn(wrapped, ci + 1) + grid.Children.Add(wrapped) + return grid + + + def _render_legend(block): + groups = block.get('groups') or [] + panel = StackPanel() + for name, items in groups: + panel.Children.Add(_math_block(name, bold=True, size=11)) + for symbol, desc in (items or []): + row = StackPanel() + row.Orientation = Orientation.Horizontal + row.Margin = Thickness(8, 0, 0, 2) + sym = _math_block(symbol, size=11, brush=_QUIET) + sym.Width = 60 + row.Children.Add(sym) + row.Children.Add(_math_block(desc, size=11)) + panel.Children.Add(row) + return panel + + + def _render_rule(): + b = Border() + b.BorderBrush = _RULE + b.BorderThickness = Thickness(0, 0, 0, 1) + b.Margin = Thickness(0, 6, 0, 6) + return b + + + def _render_space(block): + b = Border() + b.Height = max(2, (block.get('h') or 1) * 6) + return b + + + def _render_section(block, form): + """`{'kind': 'section', 'title', 'collapsed', 'blocks': [...]}` -- an + Expander holding its own child blocks, collapsed when the page asks for + that (owner, 2026-09-04: "seismic, temp, soil 这几个板块先折叠掉" -- fold + those sections away by default). `collapsed` is read with `bool(...)`, + defaulting False (expanded) when a page module omits it -- an existing + page that never emits `section` blocks is unaffected, and a page that + starts using them without an explicit `collapsed` gets the ordinary + always-visible behaviour rather than silently hiding content nobody + asked to hide. + + A fresh `_RowCount` per section, same reasoning as `_render_columns`'s + one-per-column: a section's own field rows band from white, independent + of whatever came before it on the page. + """ + title = block.get('title') or '' + collapsed = bool(block.get('collapsed')) + child_blocks = block.get('blocks') or [] + + exp = Expander() + exp.Header = title + exp.IsExpanded = not collapsed + exp.Margin = Thickness(0, 2, 0, 8) + + inner = StackPanel() + inner.Margin = Thickness(6, 4, 6, 4) + rows = _RowCount() + for b in child_blocks: + w = _render_block(b, form, rows=rows) + if w is not None: + inner.Children.Add(w) + exp.Content = inner + return exp + + + # ---------------------------------------------------- editable-table rows + # + # `{'kind': 'editable_table', 'table': NAME, 'columns': [...], 'add_label', + # 'min_rows'}` -- pre-filled, user-editable rows over the field model's row + # keys `..`, present rows being those whose `
.. + # present` reads 'yes' (owner, 2026-09-04: "只是让你预先输入,不是让你直接锁 + # 死" -- a pre-filled table must stay editable, with a way to add a row). + # + # `panelanalysisform` (F) is meant to grow `row_ids` / `row_key` / `add_row` + # / `remove_row` with this exact contract; until it does, these four do the + # same key arithmetic locally under the SAME names, so picking up F's real + # versions later is a one-line change at `_row_ids` etc. below, never a + # rewrite of the widget code that calls them. + + def _local_row_ids(table, values): + """Sorted row numbers `n` where `
..present` reads 'yes'.""" + prefix = table + '.' + ids = set() + for key in values.keys(): + if not key.startswith(prefix): + continue + parts = key[len(prefix):].split('.') + if len(parts) != 2 or parts[1] != 'present': + continue + try: + n = int(parts[0]) + except (TypeError, ValueError): + continue + if str(values.get(key, '') or '').strip().lower() == 'yes': + ids.add(n) + return sorted(ids) + + + def _local_row_key(table, n, col): + return '{0}.{1}.{2}'.format(table, n, col) + + + def _local_add_row(table, values, cols, defaults=None): + """Appends one new row (its `present` flag set to 'yes') and returns its + row number. The next number is one past the highest `n` this table has + EVER used, present or not -- a removed row's number is never reissued, + so a row a user just deleted never comes back under a stale key some + editor is still bound to.""" + prefix = table + '.' + used = set() + for key in values.keys(): + if not key.startswith(prefix): + continue + parts = key[len(prefix):].split('.') + if len(parts) != 2: + continue + try: + used.add(int(parts[0])) + except (TypeError, ValueError): + pass + n = (max(used) + 1) if used else 1 + values[_local_row_key(table, n, 'present')] = 'yes' + defaults = defaults or {} + for col in cols: + values.setdefault(_local_row_key(table, n, col), defaults.get(col, '')) + return n + + + def _local_remove_row(table, values, n): + values[_local_row_key(table, n, 'present')] = '' + + + def _row_ids(table, values): + return getattr(F, 'row_ids', _local_row_ids)(table, values) + + + def _row_key(table, n, col): + return getattr(F, 'row_key', _local_row_key)(table, n, col) + + + def _add_row(table, values, cols, defaults=None): + return getattr(F, 'add_row', _local_add_row)(table, values, cols, defaults) + + + def _remove_row(table, values, n): + return getattr(F, 'remove_row', _local_remove_row)(table, values, n) + + + def _render_unknown(block): + """A block `kind` this renderer has never seen. Named and visible, + never silently dropped -- a block that vanishes is a section of the + form nobody knows is missing.""" + tb = TextBlock() + tb.Text = u"[unrendered block: kind={0!r}]".format(block.get('kind') + if isinstance(block, dict) + else block) + tb.FontStyle = FontStyles.Italic + tb.Foreground = Brushes.Gray + tb.TextWrapping = TextWrapping.Wrap + tb.Margin = Thickness(0, 2, 0, 2) + return tb + + + def _render_block(block, form, rows=None): + """One block dict -> one WPF element. Never raises -- a block that fails + to render becomes a small red note in its own place, and the rest of the + tab keeps building. + + `rows` is the calling container's own `_RowCount` (see that class for why + it is per-container); it is threaded down to `field` blocks so their zebra + bands alternate within their own column, and ignored by every other kind. + """ + if not isinstance(block, dict): + return _render_unknown(block) + kind = block.get('kind') + try: + if kind == 'heading': + return _render_heading(block) + if kind == 'columns': + return _render_columns(block, form) + if kind == 'field': + return _render_field(block, form, rows=rows) + if kind == 'kv': + return _render_kv(block) + if kind == 'sentence': + # Owner, 2026-09-04: quiet helper prose is clutter inline -- + # route it into the page's collapsed Notes panel instead of + # printing it in the flow. A non-quiet sentence is unchanged. + if block.get('quiet'): + form.add_quiet_sentence(block.get('text', '')) + return None + return _render_sentence(block) + if kind == 'table': + return _render_table(block, form) + if kind == 'lc_table': + return form.lc_table_widget(block) + if kind == 'editable_table': + return form.editable_table_widget(block) + if kind == 'matrix': + return _render_matrix(block, form) + if kind == 'legend': + return _render_legend(block) + if kind == 'drawing': + return form.drawing_widget(block) + if kind == 'section': + return _render_section(block, form) + if kind == 'rule': + return _render_rule() + if kind == 'space': + return _render_space(block) + return _render_unknown(block) + except Exception as exc: + note = TextBlock() + note.Text = u'[block failed to render: kind={0} -- {1}]'.format( + kind, exc) + note.Foreground = Brushes.Firebrick + note.TextWrapping = TextWrapping.Wrap + note.FontSize = 10 + return note + + + class PanelForm(object): + """One selected panel's collapsed section: a tab per Eriksson section, + built from the five sibling agents' `page()` block lists, plus that + panel's own Run button. + """ + + def __init__(self, el, index, know, asm=None): + self.el = el + self.index = index + self.asm = asm + self.reads = _reads_for(el, index, know, asm) + try: + self.values0 = F.prefill(self.reads, know=know) + except TypeError: + self.values0 = F.prefill(self.reads) + self.mark = self.reads.get('mark') or 'panel {0}'.format(index + 1) + self.title = panel_title(self.reads) or self.mark + self.editors = {} # key -> (widget, 'text'|'check'|'choice') + # Every registered drawing (fixed-size or full-width), as a dict -- + # see `_fixed_drawing_widget` / `_full_width_drawing_widget` for the + # shape. `_redraw_drawings` walks this list; `depends_on` is what + # lets a cutline drag redraw only the entries that actually read the + # key it just changed, instead of every drawing on the page. + self.drawings = [] + self.run_btn = None + self.status = None + self.gcpi_label = None + self.tabctrl = None + self.annotations = {} # key -> the widget in the 4th row column + self.notes = [] # [{'kind': 'field'|'sentence', ...}] + self._note_keys = set() # de-dupes a field/sentence seen twice + # One pending flag per cutline key, so a burst of MouseMove events + # while dragging schedules AT MOST one redraw of that key's + # dependent drawings per dispatcher pass -- see `_throttled_redraw`. + self._cutline_pending = {} + # Live derived-field wiring (see `_editor_changed`). `_suspend` guards + # against a programmatic write re-entering the change handler; + # `_overridden` remembers which derived fields the user has typed into, + # so their value stands from then on; `_handlers` keeps every Python + # delegate handed to WPF alive -- an event handler that is only + # referenced by the CLR side is a collection away from a dead callback. + self._suspend = False + self._overridden = set() + self._handlers = [] + # The Sandwich-only groups (per-wythe Concrete Extents thicknesses + # and the wythe-2 material trio) are rendered collapsed and shown + # only while this panel's `section_type` says Sandwich. Each entry + # is the top-level WPF element to flip (a Border or StackPanel). + self._sandwich_widgets = [] + self.expander = self._build() + + # -- sandwich-only groups ---------------------------------------------- + def _sandwich_only_group(self, title, keys): + """One titled group of editors for the Sandwich-only `keys`. + + Field text (label / symbol / hint) comes from + `panelanalysisform.FIELDS` through the same `_render_block` the + section modules use, so these rows behave exactly like any other + field row -- a live editor, a Notes entry, a `validate` participant. + The group is returned COLLAPSED; `_sync_sandwich_groups` shows it + only while the panel's Section type is Sandwich. + """ + cols = [[], []] + for i, key in enumerate(keys): + if F._spec(key) is None: + continue + block = {'kind': 'field', 'key': key, + 'label': F.label_of(key), + 'symbol': F.symbol_of(key), + 'hint': F.help_of(key), + 'required': False, 'editor': 'num', 'choices': None} + cols[i % 2].append(block) + blocks = [{'kind': 'heading', 'text': title, 'level': 2}, + {'kind': 'columns', 'columns': cols}, + {'kind': 'space', 'h': 1}] + panel = StackPanel() + panel.Margin = Thickness(0) + rows = _RowCount() + for b in blocks: + w = _render_block(b, self, rows=rows) + if w is not None: + panel.Children.Add(w) + return panel + + def _append_sandwich_groups(self, content, title): + """Append the Sandwich-only groups belonging to tab `title`. + + Only the groups whose FIELDS rows sit in that tab's section are + appended (Design Criteria -> the wythe-2 material trio; Concrete + Extents -> the Sandwich Wythes thicknesses). Each is collapsed to + start; `_editor_changed` re-syncs them when `section_type` changes. + """ + by_section = {} + for key in F.SANDWICH_ONLY_KEYS: + spec = F._spec(key) + if spec is None: + continue + by_section.setdefault(spec['section'], []).append(key) + for section, keys in by_section.items(): + if section != title or not keys: + continue + group_title = ('Sandwich Wythes' if section == 'Concrete Extents' + else 'Wythe 2 Concrete') + widget = self._sandwich_only_group(group_title, keys) + widget.Visibility = Visibility.Collapsed + self._sandwich_widgets.append(widget) + content.Children.Add(widget) + + def _sync_sandwich_groups(self): + """Flip every Sandwich-only group to match the current Section type. + + Reads the section_type editor's text directly (not + `current_values()`), so this stays cheap on every keystroke; a + blank / unreadable value reads as not-Sandwich and the groups stay + hidden unless the section is explicitly Sandwich. + """ + is_sandwich = False + pair = self.editors.get('section_type') + if pair is not None: + try: + text = pair[0].Text or '' + except Exception: + text = '' + is_sandwich = str(text).strip().lower() == 'sandwich' + shown = Visibility.Visible if is_sandwich else Visibility.Collapsed + for widget in self._sandwich_widgets: + try: + widget.Visibility = shown + except Exception: + pass + + # -- annotations ------------------------------------------------------- + def annotation_for(self, key): + """The widget for a field row's fourth (annotation) column, or None. + + Only `wind_enclosure` has one today: the owner's requirement that + choosing an enclosure classification shows its matching GCpi (ASCE + Table 26.13-1). It lives in its OWN grid column rather than beside the + editor inside a StackPanel, which is what used to make that one row + wider than the column holding it and knock its editor out of line with + every other row (see `ANNOT_COL`). + """ + if key != 'wind_enclosure': + return None + label = _label_block('', size=11, brush=Brushes.Gray) + label.TextWrapping = TextWrapping.Wrap + label.Margin = Thickness(8, 0, 0, 0) + self.gcpi_label = label + self.annotations[key] = label + return label + + # -- notes ------------------------------------------------------------- + def add_note(self, key, label, symbol, hint): + """Record one field's plain-language help for the collapsed Notes panel + at the top of the form. Called by `_render_field` in place of drawing + the help inline under the box. First writer per key wins; a field that + happens to render in two places is noted once.""" + if not key or key in self._note_keys: + return + self._note_keys.add(key) + self.notes.append({'kind': 'field', 'label': label, + 'symbol': symbol or '', 'hint': hint}) + + def add_quiet_sentence(self, text): + """Record one `{'kind': 'sentence', 'quiet': True}` block's text for + the same collapsed Notes panel `add_note` feeds -- owner, 2026-09-04: + "小灰字太多了,去掉放到 notes 里面,默认折叠" (too much quiet grey + prose inline; move it into Notes, collapsed by default). De-duped on + the exact text, the same way a field note de-dupes on its key: a + sentence a page emits from two sibling blocks is listed once.""" + text = text or '' + if not text or text in self._note_keys: + return + self._note_keys.add(text) + self.notes.append({'kind': 'sentence', 'text': text}) + + def _build_notes_expander(self): + """One collapsed Expander listing every field's explanation and every + quiet sentence -- the owner's ruling (2026-09-03, extended + 2026-09-04): keep the help texts and the quiet helper prose, but move + both out of the page's flow into a single Notes section, closed by + default so the form reads as inputs, not prose.""" + exp = Expander() + exp.Header = u'Notes ({0})'.format(len(self.notes)) + exp.IsExpanded = False + exp.Margin = Thickness(0, 0, 0, 8) + scr = ScrollViewer() + scr.VerticalScrollBarVisibility = ScrollBarVisibility.Auto + scr.MaxHeight = 300 + inner = StackPanel() + inner.Margin = Thickness(8, 4, 8, 6) + for note in self.notes: + if note.get('kind') == 'sentence': + row = _hint_block(note.get('text', '')) + row.Margin = Thickness(0, 0, 0, 6) + inner.Children.Add(row) + continue + label = note.get('label', '') + symbol = note.get('symbol', '') + hint = note.get('hint', '') + head = label if not symbol else u'{0} ({1})'.format(label, symbol) + row = StackPanel() + row.Margin = Thickness(0, 0, 0, 6) + row.Children.Add(_math_block(head, bold=True, size=11)) + row.Children.Add(_hint_block(hint)) + inner.Children.Add(row) + scr.Content = inner + exp.Content = scr + return exp + + # -- values ------------------------------------------------------------ + def current_values(self): + vals = dict(self.values0) + for key, pair in self.editors.items(): + widget, kind = pair + if kind == 'check': + vals[key] = 'yes' if widget.IsChecked else '' + else: + try: + vals[key] = widget.Text or '' + except Exception: + vals[key] = '' + return vals + + # -- editors ------------------------------------------------------------- + def make_editor(self, kind, key, choices=None, width=None): + """One live input widget for `key`, registered in `self.editors` so + `current_values()` / the gate / the drawings can read it back. + + `check` -> CheckBox, `choice` -> an EDITABLE ComboBox over `choices` + that never pre-selects a value the user did not choose (its `.Text` + is set, never its `SelectedItem`), everything else -> TextBox. + """ + initial = self.values0.get(key, '') if key else '' + if kind == 'check': + w = CheckBox() + w.IsChecked = (initial or '').strip().lower() in ( + 'yes', 'y', 'true', '1', 'on') + w.VerticalAlignment = VerticalAlignment.Center + ekind = 'check' + elif kind == 'choice': + w = ComboBox() + w.IsEditable = True + for c in (choices or []): + w.Items.Add(c) + w.Text = initial or '' + if width: + w.Width = width + ekind = 'choice' + else: # 'text' / 'num' / other + w = TextBox() + w.Text = initial or '' + # A sensible floor so a value ordinary in its own field is never + # clipped inside the row's fixed editor column (owner, + # 2026-09-04: "Support positions" -- a comma list of numbers -- + # rendered truncated at the old blanket 138px). `num` values are + # short by nature; `text` gets a wider floor. A MULTI-VALUE kind + # ('supports', 'openings' -- anything holding more than one + # number or line) additionally STRETCHES to whatever room its + # row actually offers instead of being pinned to an explicit + # `Width` -- the per-column `Border.ClipToBounds` in + # `_render_columns` is what keeps a box that grows past a narrow + # column's own width from painting over its neighbour. + if kind == 'num': + w.MinWidth = 90 + if width: + w.Width = width + else: + w.MinWidth = 240 + if kind in _MULTI_VALUE_EDITOR_KINDS: + w.HorizontalAlignment = HorizontalAlignment.Stretch + elif width: + w.Width = width + ekind = 'text' + if key: + self.editors[key] = (w, ekind) + # Wire AFTER the initial value is set, so pre-filling a box does not + # itself count as the user editing it -- that is what would mark a + # derived field overridden before the form is even on screen. + self._bind(w, key, ekind) + # The enclosure GCpi read-out no longer wraps the editor in a + # StackPanel here: it is a separate grid column (`annotation_for`), so + # this row's editor is exactly as wide as every other row's. + return w + + def _bind(self, widget, key, kind): + """Wire one editor's change event to `_editor_changed(key)`. + + Every delegate is appended to `self._handlers` first: a Python function + handed to a CLR event is not kept alive by the event itself, and a + collected handler is a box that silently stops updating anything. + """ + if not key: + return + handler = lambda s, e: self._editor_changed(key) + self._handlers.append(handler) + try: + if kind == 'check': + widget.Checked += handler + widget.Unchecked += handler + elif kind == 'choice': + # An editable ComboBox raises SelectionChanged only for a + # PICKED item; text typed into its box raises neither that nor + # a TextChanged of its own -- the TextBox is inside its + # template. LostFocus catches the typed value on the way out, + # and the routed TextChangedEvent catches it as it is typed + # where the template exposes one. + widget.SelectionChanged += handler + widget.LostFocus += handler + try: + widget.AddHandler(TextBoxBase.TextChangedEvent, + TextChangedEventHandler(handler)) + except Exception: + pass + else: + widget.TextChanged += handler + except Exception: + pass + + def _set_editor_text(self, key, text): + """Write a value INTO an editor without it counting as a user edit. + + `_suspend` is what makes that true: the change event still fires, and + `_editor_changed` returns immediately while it is set, so a derived + field being refreshed never marks itself overridden and two derived + fields can never chase each other. + """ + pair = self.editors.get(key) + if not pair: + return + widget, kind = pair + if kind == 'check': + return + was = self._suspend + self._suspend = True + try: + widget.Text = text or '' + except Exception: + pass + finally: + self._suspend = was + + def _editor_changed(self, key): + """One editor changed. Owner's rule (2026-09-03): E follows f'c live, + a manual edit of E locks it, and nothing ever writes back to f'c. + + So: editing a DERIVED field marks it overridden and recomputes nothing + (one-directional -- the source is never rewritten from the derived + value); editing anything else refreshes the derived fields that read + it, skipping any the user has taken over. + """ + if self._suspend: + return + if key == 'section_type': + self._sync_sandwich_groups() + if key in F.DERIVED_FIELDS: + self._overridden.add(key) + elif key is not None: + self._recompute_derived(key) + self.refresh_gate() + self._redraw_drawings() + + def _recompute_derived(self, source_key): + values = self.current_values() + for dkey in F.derived_dependents(source_key): + if dkey in self._overridden: + continue + v = F.derived_value(dkey, values) + self._set_editor_text( + dkey, '' if v is None else F.format_number(round(v))) + + # -- load combinations --------------------------------------------------- + def lc_table_widget(self, block): + """One editable load-combination table plus its `Add LC` button. + + The table itself is an ordinary `_render_table` grid -- every factor + cell is a live editor because `_render_table` now honours any `editor` + kind, not only `check`. What is added here is the button, and the state + it needs to append a row to the grid IN PLACE. + + In place, not by re-rendering the tab: rebuilding would construct a + second set of widgets for every field in the section and re-register + them in `self.editors` / `self.drawings`, orphaning the ones actually + on screen -- the gate would then read boxes nobody can see. + """ + grid = _render_table(block, self) + rows = block.get('rows') or [] + used = [] + for row in rows: + rid = row[0] if row else '' + if isinstance(rid, str) and rid.startswith('u'): + try: + used.append(int(rid[1:])) + except (TypeError, ValueError): + pass + state = { + 'grid': grid, + 'name': block.get('name') or '', + 'factors': list(block.get('factors') or []), + # header row + the rows already drawn + 'next_row': 1 + len(rows), + 'body_rows': len(rows), + 'next_id': (max(used) + 1) if used else 1, + } + + panel = StackPanel() + panel.Children.Add(grid) + btn = Button() + btn.Content = 'Add LC' + btn.Width = 90 + btn.Margin = Thickness(0, 4, 0, 8) + btn.HorizontalAlignment = HorizontalAlignment.Left + handler = lambda s, e: self._add_lc_row(state) + self._handlers.append(handler) + btn.Click += handler + panel.Children.Add(btn) + return panel + + def _add_lc_row(self, state): + """Append one blank user combination to a live table grid. + + The new row's id is `u1`, `u2`, ... -- never a number, so it can never + collide with a code row and a reader can always tell which rows are + ACI's and which are the user's. Its factors start BLANK rather than at + zero: a zero factor is a real instruction ("this load is excluded"), + and seeding one would put a decision in the deck that nobody made. + """ + grid = state['grid'] + name = state['name'] + rid = 'u{0}'.format(state['next_id']) + state['next_id'] += 1 + + row_def = RowDefinition() + row_def.Height = GridLength.Auto + grid.RowDefinitions.Add(row_def) + r = state['next_row'] + state['next_row'] += 1 + zebra = (state['body_rows'] % 2 == 1) + state['body_rows'] += 1 + + cell = _grid_text_cell(rid, zebra=zebra) + Grid.SetRow(cell, r) + Grid.SetColumn(cell, 0) + grid.Children.Add(cell) + + c = 1 + for col in state['factors']: + key = F.load_combo_factor_key(name, rid, col) + self.values0[key] = '' + wrapped = _cell_border( + self.make_editor('num', key, None, width=44), zebra=zebra) + Grid.SetRow(wrapped, r) + Grid.SetColumn(wrapped, c) + grid.Children.Add(wrapped) + c += 1 + + include_key = F.load_combo_include_key(name, rid) + # Pre-ticked: a row the user just asked for is a row they mean to use. + self.values0[include_key] = 'yes' + check = self.make_editor('check', include_key) + check.HorizontalAlignment = HorizontalAlignment.Center + wrapped = _cell_border(check, zebra=zebra) + Grid.SetRow(wrapped, r) + Grid.SetColumn(wrapped, c) + grid.Children.Add(wrapped) + + self.refresh_gate() + + # -- editable tables (owner, 2026-09-04) --------------------------------- + def editable_table_widget(self, block): + """`{'kind': 'editable_table', 'table', 'columns', 'add_label', + 'min_rows'}` -- a pre-filled, user-editable grid over the field + model's `
..` row keys (`_row_ids` / `_row_key` / + `_add_row` / `_remove_row` above). Owner, 2026-09-04: "restrain 现在 + 为啥变成了不可编辑了?...只是让你预先输入,不是让你直接锁死" -- a + pre-filled table is a STARTING POINT, never a lock. + + Every cell is a live editor registered in `self.editors` under its + row key exactly the way `lc_table_widget`'s factor cells are, so + `current_values()`, the gate, and `depends_on` drawings all see an + edit the same way they see any other field. + """ + table = block.get('table') + colspecs = block.get('columns') or [] + add_label = block.get('add_label') or 'Add row' + min_rows = block.get('min_rows') or 0 + cols = [c.get('col') for c in colspecs] + if not table or not cols: + return StackPanel() + + grid = Grid() + for _ in colspecs: + grid.ColumnDefinitions.Add(ColumnDefinition()) + rm_col = ColumnDefinition() + rm_col.Width = GridLength(28) + grid.ColumnDefinitions.Add(rm_col) + + header_row = RowDefinition() + header_row.Height = GridLength.Auto + grid.RowDefinitions.Add(header_row) + for c, spec in enumerate(colspecs): + cell = _grid_text_cell(spec.get('label') or spec.get('col') or '', + bold=True, header=True) + Grid.SetRow(cell, 0) + Grid.SetColumn(cell, c) + grid.Children.Add(cell) + blank_hdr = _cell_border(TextBlock(), header=True) + Grid.SetRow(blank_hdr, 0) + Grid.SetColumn(blank_hdr, len(colspecs)) + grid.Children.Add(blank_hdr) + + state = {'grid': grid, 'table': table, 'colspecs': colspecs, + 'cols': cols, 'next_row': 1, 'body_rows': 0, 'rows': {}} + + panel = StackPanel() + panel.Children.Add(grid) + + ids = _row_ids(table, self.values0) + for n in ids: + self._add_editable_row(state, n) + for _ in range(max(0, min_rows - len(ids))): + n = _add_row(table, self.values0, cols, {}) + self._add_editable_row(state, n) + + btn = Button() + btn.Content = add_label + btn.Margin = Thickness(0, 4, 0, 8) + btn.Padding = Thickness(8, 2, 8, 2) + btn.HorizontalAlignment = HorizontalAlignment.Left + handler = lambda s, e: self._append_editable_row(state) + self._handlers.append(handler) + btn.Click += handler + panel.Children.Add(btn) + return panel + + def _append_editable_row(self, state): + n = _add_row(state['table'], self.values0, state['cols'], {}) + self._add_editable_row(state, n) + self.refresh_gate() + self._redraw_drawings(keys=[state['table']]) + + def _add_editable_row(self, state, n): + """Append one live row (row number `n`) of `state['table']` to its + grid, plus a per-row "x" remove button. Every cell wrapper is kept in + `state['rows'][n]` so `_remove_editable_row` can collapse exactly + this row without disturbing any other row's grid position.""" + grid = state['grid'] + row_def = RowDefinition() + row_def.Height = GridLength.Auto + grid.RowDefinitions.Add(row_def) + r = state['next_row'] + state['next_row'] += 1 + zebra = (state['body_rows'] % 2 == 1) + state['body_rows'] += 1 + + table = state['table'] + row_widgets = [] + for c, spec in enumerate(state['colspecs']): + col = spec.get('col') + key = _row_key(table, n, col) + editor = self.make_editor(spec.get('editor'), key, + spec.get('choices'), + width=spec.get('width')) + if spec.get('editor') == 'check': + editor.HorizontalAlignment = HorizontalAlignment.Center + wrapped = _cell_border(editor, zebra=zebra) + Grid.SetRow(wrapped, r) + Grid.SetColumn(wrapped, c) + grid.Children.Add(wrapped) + row_widgets.append(wrapped) + + rm_btn = Button() + rm_btn.Content = u'×' + rm_btn.Width = 22 + rm_btn.Height = 22 + rm_btn.Padding = Thickness(0) + handler = lambda s, e: self._remove_editable_row(state, n) + self._handlers.append(handler) + rm_btn.Click += handler + wrapped_rm = _cell_border(rm_btn, zebra=zebra) + Grid.SetRow(wrapped_rm, r) + Grid.SetColumn(wrapped_rm, len(state['colspecs'])) + grid.Children.Add(wrapped_rm) + row_widgets.append(wrapped_rm) + + state['rows'][n] = row_widgets + + def _remove_editable_row(self, state, n): + """Flip row `n`'s `present` flag off and collapse its cells. The + editors stay registered (a stray `current_values()` read of a + collapsed row's key is harmless) -- what actually drops the row from + the deck / gate / any `_row_ids` walk is `present` going blank, the + SAME mechanism `_add_editable_row` sets it through.""" + _remove_row(state['table'], self.values0, n) + for widget in state.get('rows', {}).get(n, []): + try: + widget.Visibility = Visibility.Collapsed + except Exception: + pass + self.refresh_gate() + self._redraw_drawings(keys=[state['table']]) + + # -- drawings ------------------------------------------------------------ + def drawing_widget(self, block): + """One `drawing` block -> its widget. `full_width: True` routes to + the tab-width, aspect-ratio drawing (owner, 2026-09-04: "panel 的长度 + 总要占满整个显示窗口的宽度"); everything else is the original + fixed-pixel-size drawing, unchanged.""" + if block.get('full_width'): + return self._full_width_drawing_widget(block) + return self._fixed_drawing_widget(block) + + # -- fixed-size drawings (unchanged behaviour, `module`-aware) ---------- + def _fixed_drawing_widget(self, block): + source = block.get('source') + modname = block.get('module') + caption = block.get('caption') + w = block.get('w') or SKETCH_W + h = block.get('h') or SKETCH_H + container = StackPanel() + # Left-aligned and clipping for the same reason the border inside + # `_drawing_canvas` is: a fixed-size drawing inside a star column must + # never be able to paint into its neighbour. + container.HorizontalAlignment = HorizontalAlignment.Left + container.ClipToBounds = True + entry = {'kind': 'fixed', 'container': container, 'source': source, + 'modname': modname, 'caption': caption, 'w': w, 'h': h, + 'depends_on': block.get('depends_on') or []} + self.drawings.append(entry) + self._redraw_fixed_entry(entry, self.values0) + return container + + def _redraw_fixed_entry(self, entry, values): + container = entry['container'] + container.Children.Clear() + container.Children.Add(_drawing_canvas( + entry['source'], values, entry['w'], entry['h'], + modname=entry.get('modname'))) + if entry.get('caption'): + cap = TextBlock() + cap.Text = entry['caption'] + cap.TextDecorations = TextDecorations.Underline + cap.HorizontalAlignment = HorizontalAlignment.Center + cap.FontSize = 11 + cap.Margin = Thickness(0, 0, 0, 10) + container.Children.Add(cap) + + # -- full-width drawings (owner, 2026-09-04) ----------------------------- + def _full_width_drawing_widget(self, block): + """`{'kind': 'drawing', 'full_width': True, 'aspect': h/w, ...}` -- + a drawing whose Canvas is resized to the tab content's own usable + width on every layout pass, height = width * aspect. Optionally + carries a draggable `cutline` (see `_add_cutline`). + + WHY `border` (not `container`) IS LEFT ALIGNMENT-UNSET: every other + drawing in this file (`_fixed_drawing_widget`, `_drawing_canvas`) is + explicitly `HorizontalAlignment.Left` so a fixed-pixel drawing never + stretches wider than its own declared size. This one is the + opposite: `FrameworkElement`'s default `HorizontalAlignment` is + `Stretch`, and this block's parent (`_section_tab`'s `content` + StackPanel, itself inside a `ScrollViewer` whose horizontal + scrollbar is DISABLED -- see `_tab`) hands a `Stretch`-aligned child + its own full available width. That width is exactly "the tab + content's usable width" the contract asks for, with no need to walk + up to the `TabControl` / `ScrollViewer` by hand. + """ + source = block.get('source') + modname = block.get('module') + caption = block.get('caption') + try: + aspect = float(block.get('aspect') or 0.28) + except (TypeError, ValueError): + aspect = 0.28 + if aspect <= 0: + aspect = 0.28 + cutline_spec = block.get('cutline') + depends_on = block.get('depends_on') or [] + + canvas = Canvas() + canvas.Background = Brushes.WhiteSmoke + canvas.ClipToBounds = True + canvas.HorizontalAlignment = HorizontalAlignment.Left + + notes = StackPanel() + notes.Margin = Thickness(4, 2, 4, 2) + + outer = StackPanel() + outer.Children.Add(canvas) + outer.Children.Add(notes) + if caption: + cap = TextBlock() + cap.Text = caption + cap.TextDecorations = TextDecorations.Underline + cap.HorizontalAlignment = HorizontalAlignment.Center + cap.FontSize = 11 + cap.Margin = Thickness(0, 4, 0, 4) + outer.Children.Add(cap) + + border = Border() + border.BorderBrush = _RULE + border.BorderThickness = Thickness(1) + border.Margin = Thickness(0, 4, 0, 4) + border.Child = outer + # No explicit HorizontalAlignment: Stretch (the WPF default) is what + # makes `border.ActualWidth` track the tab content's own width. + + entry = {'kind': 'full_width', 'container': canvas, 'source': source, + 'modname': modname, 'aspect': aspect, 'w': 1100.0, + 'h': 1100.0 * aspect, 'depends_on': depends_on, + 'cutline': cutline_spec, 'notes': notes, + 'cutline_line': None, 'cutline_hit': None, + 'cutline_key': None, + # Optional block key naming a geometry function in this + # drawing's own module (e.g. 'outline_geometry' in + # `pa_sketch_extents`) -- see `_cutline_geometry`. + 'geometry': block.get('geometry')} + self.drawings.append(entry) + + def _resize(sender, args): + try: + new_w = border.ActualWidth + except Exception: + new_w = 0.0 + if not new_w or new_w <= 1.0: + new_w = 1100.0 + # Ignore sub-pixel layout jitter -- redrawing on a fraction of a + # pixel of movement is wasted work and can thrash during a + # window resize. + if abs(new_w - entry['w']) < 2.0: + return + entry['w'] = new_w + entry['h'] = new_w * entry['aspect'] + self._redraw_full_width_entry(entry, self.current_values()) + + self._handlers.append(_resize) + border.SizeChanged += _resize + + # First paint at the fallback width (1100, the contract's own + # number); `SizeChanged` fires once real layout happens and repaints + # at the tab's true content width -- so the very first frame is + # already close, and the real one follows within the same pass. + self._redraw_full_width_entry(entry, self.values0) + return border + + def _redraw_full_width_entry(self, entry, values): + canvas = entry['container'] + w, h = entry['w'], entry['h'] + canvas.Width = w + canvas.Height = h + canvas.Children.Clear() + fn, resolve_err = _resolve_drawing_fn(entry['source'], entry.get('modname')) + if fn is None: + primitives, problems = [], [resolve_err] + else: + try: + primitives, problems = fn(values, int(w), int(h)) + except Exception as exc: + primitives, problems = [], ['drawing failed: {0}'.format(exc)] + for prim in primitives: + try: + _draw_primitive(canvas, prim) + except Exception: + pass + self._fill_drawing_notes(entry['notes'], problems) + if entry.get('cutline'): + self._add_cutline(entry, values) + + def _fill_drawing_notes(self, panel, problems): + panel.Children.Clear() + for problem in (problems or [])[:4]: + note = TextBlock() + note.Text = u'• ' + problem + note.FontSize = 10 + note.Foreground = Brushes.Firebrick + note.TextWrapping = TextWrapping.Wrap + note.Margin = Thickness(0, 0, 0, 3) + panel.Children.Add(note) + + # -- the draggable cut line (owner, 2026-09-04) -------------------------- + def _cutline_geometry(self, entry, values): + """The pixel box `{'x0','x1','y0','y1'}` the draggable cutline + should span -- the renderer's half of the contract + `pa_sketch_extents.outline_geometry()`'s own docstring describes + ("OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER"). A block + opts in with `'geometry': ''` (resolved in the drawing's + OWN module, the same `modname` `_resolve_drawing_fn` already uses); + with no `geometry` key, an unresolved module/function, a non-dict + return, or a return missing any of the four keys, this falls back + to the bare canvas box (`x0=0, x1=w, y0=0, y1=h`) -- exactly the old + behaviour -- so a drawing that never declares `geometry` keeps + working unchanged. + """ + w, h = entry['w'], entry['h'] + fallback = {'x0': 0.0, 'x1': w, 'y0': 0.0, 'y1': h} + geom_name = entry.get('geometry') + if not geom_name: + return fallback + mod, err = _load_section_module(entry.get('modname')) + if mod is None: + return fallback + fn = getattr(mod, geom_name, None) + if fn is None: + return fallback + try: + geo = fn(values, w, h) + except Exception: + return fallback + if not isinstance(geo, dict) or not all( + k in geo for k in ('x0', 'x1', 'y0', 'y1')): + return fallback + return geo + + def _add_cutline(self, entry, values): + """A draggable vertical line over a full-width outline drawing -- + "panel outline 上面做一个 cut-line,用户可以拖动,然后 cross section + 动态加载...跟做核磁一样" (like an MRI slice). Drawn as the LAST two + children added to `entry['container']` (a plain visible line plus a + wide transparent one on top of it as the real hit target -- a 2px + stroke is nearly impossible to grab with a mouse), so it always + paints over the primitives `_redraw_full_width_entry` just drew. + Dragging writes the fraction as TEXT into `self.values0[key]` (never + through an editor -- there is none) and throttles the redraw of + every OTHER drawing that declares `'depends_on': [key]` to at most + one per dispatcher pass (`_throttled_redraw`); it never rebuilds the + form and never redraws this entry itself (repositioning the two + lines directly is enough while dragging). + + THE LINE'S X IS `x0 + frac*(x1-x0)`, not `frac*w`: the outline + drawing has margins (`pa_sketch_extents.outline_geometry`), so the + panel rectangle's left/right edges sit at `x0`/`x1`, not `0`/`w` -- + see `_cutline_geometry`. The line itself is also drawn only between + `y0-8` and `y1+8` (the panel's own vertical extent, padded 8px past + each end) rather than the whole canvas height, so it reads as + marking the panel, not the canvas. + """ + spec = entry.get('cutline') or {} + key = spec.get('key') if isinstance(spec, dict) else spec + try: + default = float(spec.get('default', 0.5)) if isinstance(spec, dict) else 0.5 + except (TypeError, ValueError): + default = 0.5 + if not key: + return + raw = values.get(key, self.values0.get(key)) + try: + frac = float(raw) if raw not in (None, '') else default + except (TypeError, ValueError): + frac = default + frac = max(0.0, min(1.0, frac)) + + canvas = entry['container'] + w, h = entry['w'], entry['h'] + geo = self._cutline_geometry(entry, values) + x0, x1 = geo['x0'], geo['x1'] + if x1 < x0: + x0, x1 = x1, x0 + span = x1 - x0 + y_top = max(geo['y0'] - 8.0, 0.0) + y_bot = min(geo['y1'] + 8.0, h) + x = x0 + frac * span + + line = Line() + line.X1, line.X2 = x, x + line.Y1, line.Y2 = y_top, y_bot + line.Stroke = Brushes.Firebrick + line.StrokeThickness = 2.0 + line.StrokeDashArray = DoubleCollection([4.0, 2.0]) + line.IsHitTestVisible = False # the wide `hit` line below is grabbed + try: + line.Cursor = Cursors.SizeWE + except Exception: + pass + + hit = Line() + hit.X1, hit.X2 = x, x + hit.Y1, hit.Y2 = y_top, y_bot + hit.Stroke = Brushes.Transparent + hit.StrokeThickness = 14.0 + try: + hit.Cursor = Cursors.SizeWE + except Exception: + pass + + canvas.Children.Add(line) + canvas.Children.Add(hit) + entry['cutline_line'] = line + entry['cutline_hit'] = hit + entry['cutline_key'] = key + + drag = {'on': False} + + def _down(s, e): + drag['on'] = True + try: + hit.CaptureMouse() + except Exception: + pass + e.Handled = True + + def _move(s, e): + if not drag['on']: + return + pos = e.GetPosition(canvas) + nx = pos.X + if nx < x0: + nx = x0 + if nx > x1: + nx = x1 + line.X1 = nx + line.X2 = nx + hit.X1 = nx + hit.X2 = nx + frac2 = ((nx - x0) / span) if span else 0.0 + frac2 = max(0.0, min(1.0, frac2)) + self.values0[key] = F.format_number(frac2) + self._throttled_redraw(canvas, key) + e.Handled = True + + def _up(s, e): + drag['on'] = False + try: + hit.ReleaseMouseCapture() + except Exception: + pass + e.Handled = True + + self._handlers.extend([_down, _move, _up]) + hit.MouseLeftButtonDown += _down + hit.MouseMove += _move + hit.MouseLeftButtonUp += _up + + def _throttled_redraw(self, canvas, key): + """Schedule at most ONE `_redraw_drawings(keys=[key])` per dispatcher + pass -- a mouse-move batch during a cutline drag can raise dozens of + events before WPF next paints, and redrawing a dependent section on + every one of them is the "never a full form rebuild" the contract + explicitly rules out. `_cutline_pending[key]` is the guard: a second + `MouseMove` that arrives while a redraw for the same key is already + queued is a no-op here, and the queued one reads whatever `values0` + holds at the moment it actually runs -- always the latest position. + """ + if self._cutline_pending.get(key): + return + self._cutline_pending[key] = True + + def _do(): + self._cutline_pending[key] = False + self._redraw_drawings(keys=[key]) + + try: + canvas.Dispatcher.BeginInvoke(DispatcherPriority.Background, + Action(_do)) + except Exception: + _do() + + def _redraw_drawings(self, keys=None): + """Redraw every registered drawing, or -- with `keys` -- only the + ones whose own `depends_on` names one of `keys`. `keys=None` (every + ordinary field edit, via `_editor_changed`) is the original + behaviour, unchanged; `keys=[...]` is what the cutline drag uses to + redraw the section below without touching the outline drawing that + hosts the cutline itself. + """ + vals = self.current_values() + for entry in self.drawings: + if keys is not None: + deps = entry.get('depends_on') or [] + if not any(k in deps for k in keys): + continue + try: + if entry.get('kind') == 'full_width': + self._redraw_full_width_entry(entry, vals) + else: + self._redraw_fixed_entry(entry, vals) + except Exception: + pass # a drawing is never worth taking the form down + + # -- ui ---------------------------------------------------------------- + def _section_tab(self, title, modname): + content = StackPanel() + content.Margin = Thickness(6, 4, 6, 4) + + mod, err = _load_section_module(modname) + if mod is None: + content.Children.Add(_missing_section_note(title, modname, err)) + return _tab(title, content) + if not hasattr(mod, 'page'): + content.Children.Add(_missing_section_note( + title, modname, 'module has no page()')) + return _tab(title, content) + + try: + blocks, problems = mod.page(self.values0, reads=self.reads) + except Exception as exc: + content.Children.Add(_missing_section_note(title, modname, exc)) + return _tab(title, content) + + rows = _RowCount() + for b in (blocks or []): + w = _render_block(b, self, rows=rows) + if w is not None: + content.Children.Add(w) + + # The Sandwich-only groups (per-wythe Concrete Extents + the wythe-2 + # material trio) have no home in the section modules yet; they are + # appended here, collapsed, and shown only when `section_type` is + # Sandwich (see `_sync_sandwich_groups`). + self._append_sandwich_groups(content, title) + + if problems: + exp = Expander() + exp.Header = 'Notes ({0})'.format(len(problems)) + exp.IsExpanded = False + exp.Margin = Thickness(0, 0, 0, 8) + plist = StackPanel() + plist.Margin = Thickness(6, 4, 6, 4) + for p in problems: + plist.Children.Add(_hint_block(u'• ' + p)) + exp.Content = plist + content.Children.Insert(0, exp) + + return _tab(title, content) + + def _build(self): + body = StackPanel() + body.Margin = Thickness(6, 4, 6, 4) + + self.tabctrl = TabControl() + for title, modname in SECTION_MODULES: + self.tabctrl.Items.Add(self._section_tab(title, modname)) + self.tabctrl.Height = 620 + body.Children.Add(self.tabctrl) + + self.status = _label_block('', size=11) + body.Children.Add(self.status) + + self.run_btn = Button() + self.run_btn.Content = 'Run Analysis' + self.run_btn.Margin = Thickness(0, 6, 0, 4) + self.run_btn.Width = 140 + self.run_btn.HorizontalAlignment = HorizontalAlignment.Left + self.run_btn.Click += self._on_run + body.Children.Add(self.run_btn) + + # The tabs have now been built, so every field's help has been handed + # to `add_note`; drop the single collapsed Notes panel in at the very + # top of the form (owner, 2026-09-03). + if self.notes: + body.Children.Insert(0, self._build_notes_expander()) + + exp = Expander() + exp.Header = self.title + exp.IsExpanded = False + exp.Content = body + exp.Margin = Thickness(0, 2, 0, 2) + # Every tab is built now, so the Sandwich-only groups exist; sync them + # to the (pre-filled) section_type once before the form shows. + self._sync_sandwich_groups() + self.refresh_gate() + return exp + + # -- behaviour --------------------------------------------------------- + def _update_gcpi(self): + if self.gcpi_label is None: + return + pair = self.editors.get('wind_enclosure') + box = pair[0] if pair else None + gcpi, why = F.wind_gcpi(box.Text if box is not None else None) + if gcpi is None: + self.gcpi_label.Text = '' + self.gcpi_label.Foreground = Brushes.Gray + else: + self.gcpi_label.Text = 'GCpi = +/-{0}'.format(gcpi) + self.gcpi_label.Foreground = Brushes.SeaGreen + + def refresh_gate(self, *args): + self._update_gcpi() + errors = F.validate(self.current_values())[0] + self.run_btn.IsEnabled = not errors + if errors: + shown = '; '.join(errors[:6]) + if len(errors) > 6: + shown += ' ...' + self.status.Text = 'Still needed: ' + shown + self.status.Foreground = Brushes.Firebrick + else: + self.status.Text = 'Ready to run.' + self.status.Foreground = Brushes.SeaGreen + + def _on_change(self, sender, event): + """Kept only for any caller that still wires a bare WPF handler: it is + `_editor_changed` with no key, i.e. refresh the gate and the drawings + but recompute nothing derived (there is no source key to derive from). + Every editor this file builds goes through `_bind` instead.""" + self._editor_changed(None) + + def _on_run(self, sender, event): + errors, parsed = F.validate(self.current_values()) + if errors: + self.status.Text = 'Still needed: ' + '; '.join(errors[:6]) + self.status.Foreground = Brushes.Firebrick + runlog.record(TITLE, doc_title=doc.Title, marks=[self.mark], + outcome='refused', + report_text='Still needed: ' + '; '.join(errors), + values=self.current_values()) + return + deck = F.build_deck(parsed) + ok, message = _run_one(self.mark, deck) + if ok: + self.status.Text = 'Ran. Page opened.' + self.status.Foreground = Brushes.SeaGreen + _report_one(self.mark, True, message, deck) + else: + self.status.Text = 'Did not run -- see the output panel.' + self.status.Foreground = Brushes.Firebrick + _report_one(self.mark, False, message, deck) + + + def _report_one(mark, ok, message, deck=None): + # Every run, ok or not, lands in the run log with the deck it was fed -- + # that is the form's values after validation, which is what a report + # about "the button did X" needs to be checked against. + if ok: + output.print_md('- **{0}** — ran. [{1}]({2})'.format( + mark, os.path.basename(message), + 'file:///' + message.replace('\\', '/'))) + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='ok', + report_path=message, values=deck) + else: + output.print_md('- **{0}** — NOT RUN: {1}'.format(mark, message)) + runlog.record(TITLE, doc_title=doc.Title, marks=[mark], + outcome='error', report_text=message, values=deck) + + + def _analyze_all(panels, overall_status): + named = [(pf.mark, pf.current_values()) for pf in panels] + to_run, skipped = F.batch_plan(named) + output.print_md('## Analyze All') + ran_ok = 0 + for label, parsed in to_run: + deck = F.build_deck(parsed) + ok, message = _run_one(label, deck) + _report_one(label, ok, message, deck) + if ok: + ran_ok += 1 + for label, errors in skipped: + output.print_md('- **{0}** — SKIPPED (incomplete): {1}'.format( + label, '; '.join(errors[:6]))) + runlog.record(TITLE, doc_title=doc.Title, marks=[label], + outcome='refused', + report_text='Skipped (incomplete): ' + '; '.join(errors)) + overall_status.Text = ('Analyze All: {0} ran, {1} skipped (incomplete). ' + 'See the output panel.'.format(ran_ok, + len(skipped))) + overall_status.Foreground = (Brushes.SeaGreen if not skipped + else Brushes.DarkGoldenrod) + + + def show_form(panels): + # Sized for real content -- each tab is a full Eriksson printed page, not + # a handful of text boxes, and the old 620x720 could not show one without + # a scrollbar inside a scrollbar. This still fits a 1080p screen with + # room to spare, and every scroller inside still has its own + # ScrollBarVisibility.Auto for anyone on something smaller. + win = Window() + win.Title = TITLE + win.Width = 1180 + win.Height = 880 + win.MinWidth = 760 + win.MinHeight = 520 + win.SizeToContent = SizeToContent.Manual + win.WindowStartupLocation = WindowStartupLocation.CenterScreen + + root = Grid() + root.Margin = Thickness(10) + row_top = RowDefinition() + row_bottom = RowDefinition() + row_bottom.Height = GridLength.Auto + root.RowDefinitions.Add(row_top) + root.RowDefinitions.Add(row_bottom) + + header = _math_block( + 'Confirm the pre-filled fields, fill the blanks (especially the ' + 'support positions), and press Run Analysis on a panel below -- or ' + 'Analyze All for every complete one.', size=11, brush=_QUIET) + header.TextWrapping = TextWrapping.Wrap + header.Margin = Thickness(0, 0, 0, 8) + Grid.SetRow(header, 0) + # header lives inside the scroller's stack instead of its own grid row, + # so it scrolls with the panels rather than stealing a fixed row -- see + # below. + + scroller = ScrollViewer() + scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto + stack = StackPanel() + stack.Children.Add(header) + for pf in panels: + stack.Children.Add(pf.expander) + scroller.Content = stack + # Ctrl + mouse wheel zooms the whole form (owner, 2026-09-03). A + # LayoutTransform on the stack, not a RenderTransform, so the scroller's + # extent follows the scale and nothing is clipped; a plain wheel keeps + # scrolling. Clamped to 50 %..300 %. + # + # This transform is WHY `_drawing_canvas` no longer wraps each drawing + # in a `Viewbox`: a `Viewbox` fits itself to whatever space it is + # offered, and THIS transform offers its descendants less space the + # higher `scale` climbs (it deflates by 1/scale before measuring, then + # inflates the result back by scale) -- the two effects cancel exactly + # for anything that resizes itself to available space, which is why the + # drawings alone did not zoom while ordinary text did. See + # `_drawing_canvas`'s own docstring and `panelsketch.drawing_display_scale` + # for the fixed-transform fix. + zoom = ScaleTransform(1.0, 1.0) + stack.LayoutTransform = zoom + + def _wheel(sender, e): + if (Keyboard.Modifiers & ModifierKeys.Control) != ModifierKeys.Control: + return + factor = 1.1 if e.Delta > 0 else 1.0 / 1.1 + scale = max(0.5, min(3.0, zoom.ScaleX * factor)) + zoom.ScaleX = scale + zoom.ScaleY = scale + e.Handled = True + + scroller.PreviewMouseWheel += _wheel + Grid.SetRow(scroller, 0) + root.Children.Add(scroller) + + bottom = StackPanel() + bottom.Margin = Thickness(0, 8, 0, 0) + Grid.SetRow(bottom, 1) + + overall_status = _label_block('', size=11) + + btn_row = StackPanel() + btn_row.Orientation = Orientation.Horizontal + + all_btn = Button() + all_btn.Content = 'Analyze All' + all_btn.Width = 160 + all_btn.Margin = Thickness(0, 0, 8, 0) + all_btn.Click += lambda s, e: _analyze_all(panels, overall_status) + btn_row.Children.Add(all_btn) + + close_btn = Button() + close_btn.Content = 'Close' + close_btn.Width = 100 + close_btn.Click += lambda s, e: win.Close() + btn_row.Children.Add(close_btn) + + bottom.Children.Add(btn_row) + bottom.Children.Add(overall_status) + root.Children.Add(bottom) + + border = Border() + border.Child = root + win.Content = border + win.ShowDialog() + + +# ------------------------------------------------------------------- main + +KNOW = None +try: + KNOW = knowledge.load(doc) +except Exception: + KNOW = None + +panels = _resolve_framing() +if not panels: + not_run('Select one or more structural-framing panels (the pieces you ' + 'want analysed), then run this again. Selecting an assembly, or ' + 'any member of one, resolves to its framing member.') + +output.print_md('# {0}'.format(TITLE)) + +if _USE_WPF: + output.print_md('Opened an input form for **{0}** panel(s). Confirm the ' + 'pre-filled fields, fill the blanks (especially the ' + 'support positions), and press **Run Analysis** on a ' + 'panel -- or **Analyze All** for every complete one.' + .format(len(panels))) + forms_list = [PanelForm(el, i, KNOW, asm) + for i, (el, asm) in enumerate(panels)] + # The pre-filled state, before the user touches anything: what the reads + # recovered from the model is the half of a "field X came up empty" + # report that cannot be pasted back by hand. + runlog.record(TITLE, doc_title=doc.Title, + marks=[pf.mark for pf in forms_list], outcome='opened', + values=dict((pf.mark, pf.current_values()) + for pf in forms_list)) + show_form(forms_list) + runlog.record(TITLE, doc_title=doc.Title, + marks=[pf.mark for pf in forms_list], outcome='closed') +else: + output.print_md('Opening the Panel Analysis page for **{0}** panel(s), ' + 'one at a time.'.format(len(panels))) + for i, (el, asm) in enumerate(panels): + _run_panel_web(el, i, KNOW, asm) diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/bundle.yaml new file mode 100644 index 0000000..ee139b0 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/bundle.yaml @@ -0,0 +1,27 @@ +title: Panel Lift Check +# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported +# on each click, so an edit to `liftexport` -- the Revit half of the +# panellift boundary -- is visible without a Revit restart. It trades press +# latency (engine construction instead of engine reuse) for restart-freedom. +# The CPython half already needs no restart; this is for the half that does. +# +# Read docs/architecture-migration.md section 8 before trusting or removing +# this. Two things it says that are not obvious: pyRevit's DEFAULT is already +# a clean engine, and this extension gets one today only because +# extension.json has no `url:` and so is never registered as a package -- +# add a url and every button silently switches to the shared engine. This key +# is the pin against that, not a new capability. +# +# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below. +engine: + clean: true +# No tooltip and no author on purpose. pyRevit renders both on hover; +# the owner wants nothing there. Guarded by +# WillTools.extension/tests/test_button_tooltips.py -- do not add them back. +# +# MOVED 2026-09-05 from Tools.panel, where it was one of four large icons +# (see Tools.panel/bundle.yaml). One line, no `\n`: this button is now a row +# in DesignHub's `.pulldown`, and a pulldown row renders as text beside a +# 16px icon -- a `\n` has nowhere to go and the dropdown grows to fit the +# longest row, so the title is free to say what it means. No (WIP): moving +# panels did not change what this tool does, and it is not half-built. diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bc736b9a8cfc144f1c30c4bf427b2ccb672dba13 GIT binary patch literal 4413 zcmV-D5yI|?P)=UbmHzH~Ro&HUsU;cN#x?=RX0ybOA(#*_TO7tFgfJ}z%yI%_hzD$7a!4jaoOXxI zl1wHL+t^@E&KY7>^-PAv#%3|Ln7|kW6TBdA7!nL#uq?@1da0`S?)<3kwq#4#k_^41 z?>VJ&)U9q+-S6G|-FNSM?-GV46b?6t+Ruhl^N2lMTl+poqfw3GmR`>si-k2nJm@bQ z%!e3Ev2aY($LiIA@6Gzwl92G13!uFSbzgJDr0C;;AQBKHq1b`a>Jwrt*P7v3uKPk5mQ%p?iq%eN$Q^U6q2PZ3du-z3*azpkhXVq0`UFUFCKpB#?6rki)a*lD1JDUt|$_TP&69VcT9b3iXr^-{HAqc%ISt; z<$_5NLFCLSP(~-2AGyGTy#WX^EME|gYwrW=IS;;Y{d@q3AR?mZ-zCxK`uhkd8YMk@ z+T#%^&A9;JxG9$ia{uFhxgrDs1SprmT4#|k3;__pI<1l%V8LJ|NEm)I2-{6IbS1lH z{`Tb=t)4ZI9smIQl|O6RlEr2I${F!wyJCQdNaRZKN-p#BN>S~Y9h?-mDq6*Cg44hKZW zV8hlSD_;`goi+mHKGdEy8mCVB3dT(sj!<;~0FX*J`16*%*zooz*wFMTT6T6KSm95r zBUk3Yj^zuQj+<(7j0>PX?t z3y#AzGbUoxaaEuDkYVmJ`vzJ8sGWeS(twJ`KYL}pf!Va=8mWr6|x4b zG81*@jKP?%R%6p2cfv`!5K`nSN|3Ibax8!7gtNZ-SGf1NGggJe4YIj8#(h|T=l7Fu z#3~Y&{jL<|ct@pl&V0TU=xk5muWvsGS6p`*w9+6VP>&!t<6U}&1I&O{8qyHhvUV4K zeeVkREhAUN41k#+v=oL`N==%#Z2G%BNja1lN;>XO-DCMG#ycwM=8|yZou1pd z;=0qITn!Q;t9)jL)(j#~`I+A{U`Rtixf&;)el)K7-dX7GOy$lBK*X#dglTH1fA5Ec zy_CUi)1ExaBHl=l!!$dTi%^pQX!eNrCkwB{@G&81&0hGKRz9U~E79#hYu$@*Gw(5{ z0}{>nm)Xnk$+lMb{6@}58O+KwEa^D*CG%grVO3_fEIi^}EdgQ+e23o*)+lXruHt+W zgk;=C-NZ2%J|+b2L-~7lc4En|-$3-%|Hj-~pT)c%zk)66c7cep3eH%DFa#!jb3E*n z+sl3B#ufxv=s66g+S)K@-6w--*0+}XSnkVDsyy;S)?yIUORJ5eWKFQ5onJ*@(1j+R*d~0_7%LN2UEd;JOm>JR#2n`EB%(<^75)m9* zBb9V}xhB>OtIWh#jvod))q8R!1VK9*RW(738e0W;NvmvF#oEguQ6419nKcBcg8ec8 zfJH1;uYMF>93sZau5y4Jrk9xkVG4A#CGhdqy#Vje<#0MDH1+z^pfw}W?LY|fl60;{ z``$P%zor(U>Ogjmz-b^l+7kHmPi^q|WbVSy^iWb-X~VF}Y}OO9WSg{o#9$M7VWkAt zjP-AS>P-sL3KPP6ef%jS@pnJF6r;yhArW_w=(Z84@MHSj=i{<#>b%fXro&dP!3=C! zw+kJu2}o0d^S~7mkx)zq02n6S$!CJ!~FtBdTc3geSS+Ig;dL(;u1ZKvW7aWJv zCmoAz&7VQJ8pn*QMtP;xZ)Ek>$`3tII4>U28&Krkj6j|VGhq58T6T6~*%NC)M9@n2 z;s~w1QGUXyBXRPXqflOHWm6FuQLZE=3zJ-p~ zID!?vyjFhR1CYs%cJ52!mLGisIt}T+DQj1pAQC0YsPUW)nNKvlMwH+_s@t+01Rd&L<%RJF$&ktJQu9Fmok~T z&q}or1g#k7PdNdXhwIR_F9|8B1i(P^L9VS(Q60dHf4mT;-vIZh{c7cbvFh24cYSy?#@qA7I{s&J=XxG zBcO&wZ)4*-pQ5bXi}9HO!za<%mcXO`{5qVJ+cSyk0g}h0EDD8mg(STB(pJ0@dmrVM zRMX`}?p^>sEM$#=LQWjstJ}<2qc1q=xbdL-v1GmqajH=;5q~gvO zLdxQZpWj0SV2*VcIGrz>&G55Ix2C)MoVwGLqElK1fO*1Z@prUJ@k!&>UHEyX4BT>t0rJd{i%7iT%6M(Xe#N#^h?VKy7>%L4G*R_PIGpG)!#b) zp6}=5)!(+Mo|o%t2tl}Z=DF}&=8z_jV$eqlI0}_Vm7#9p7%&6U@JzR7l+l-e&qcH| zkgLDv?Rgo70BgpGF(HgSxdy4k;EzBMLzz*UJ^|Aw^L`Ef7nx-ATjr4XObm5LW1eok z`ck)274;68VwVyCB>+kQl%xbe34oH604M=ak`e$V07_Cw05BtOkAXokry`gEX-c%W zbbE*E0liMODt6J@tL}Aoq_F*iR`{&JpGj>D<&bS52rn#o4=0~F8qyHi(qqxPjdbey z#V6OHy`>w$kblr?nhXH2-2_RLi-C>;D_~;VhR?8I&a3$D-=2-pW2^C(kehDb8^It2DE4FWu!Z=&GE)yMIsa$J@zs$tCHQTXe?JgX9 z!cp)CjG=T%%=;!{tQp(4wW7TxjzG{iVCOAJlH;bjm2|fPfYIDsN3q6>-C5I?v=GSw zA79Cuvo+DJ>v!j_UkkpxB8<;(AXwoaR4I~3kkU?yeC2HbU{rtDCIBdAdP@kJ!Zh#( zkeM5?%1qE-NaQo9Ixs^UhA9=hYinZf$HU2)_51tn3=LYWZsWu`eBM+IVzE&Z10JZ97*Dk z!XUx)i&(7QuCMnd!2$rHARP`j$OTKMZ&KQM#_|OPgG*L$Efrz4Sw zh{X=*BUW4cJ~J~Zo9?%rWT%8Ialw%bU9}Vjf#rSkSKO6qZmy$@hsm}wY;0`g=H@#2 z*bCS1ojCrgj(}A$&2>{Qh-68FL+Y5}27DnSX?MjIEW0s!V7Kjln|aQfy5v6t{?Ik? z)INuD)y%W-m9Kn8nN-UDNZ97Y(4=ttfnBx_%KtMn5sl1XLImQMkIr6o-uP=?2JvxH8pj*bCzN)H!7ee_%V9H$ zzaZe%zKEfjpur&1@C%<|3D$ap((c{AdExrGO&={!L?SE}E<8mas<3rf(i6$qS2kQO zNZrfK7yAs$&|oNKL$ij~S{El(c%i!xQVJ?03Z^Hcr)jy=azo(?S@*H^f#f^h0K@;pi)XBBuEq~1O(enBnXHzXssanA1%bo z!=Br;CgZ9P<#L7uKu>FEh=n!vK=__;L%{DCWnE5pt%M<6xJ3tmVHnWD_W=^|j`=HY z?C8To=z&IxL-N;NIdigT90U|vYjjHE<<<000000NkvXXu0mjf DK^s-1 literal 0 HcmV?d00001 diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/script.py b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/script.py new file mode 100644 index 0000000..82eb2a8 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton/script.py @@ -0,0 +1,1066 @@ +# -*- coding: utf-8 -*- +# +# Handling check for one assembly: stripping, transportation, erection. +# +# What the selection says decides how this runs. One assembly preselected -- +# or any member of one -- is checked straight away. Several preselected run +# as a background batch on exactly those pieces. Nothing preselected asks: +# one piece, or a batch across the assemblies the job's DECLARED SCOPE says +# are this engineer's (`vibe.scope`). The batch list is the TOOL choosing, so +# it is scoped and says what it left off; a preselected batch is the USER +# choosing, so it is never filtered. +# +# In the single-panel flow the piece is measured out of the model, you pick the +# inserts that carry each stage by clicking them ON the piece, you say where the +# report goes, and the calculation runs on CPython -- see vibe.liftexport for why +# it is not in here. +# +# The only thing it writes to the model is a temporary isolate of the piece, in +# whatever view you are already in, so that you are clicking inserts on the piece +# rather than hunting them in a view of the whole building. It writes that twice: +# on, and off again. Nothing else is changed -- including the inserts this report +# recommends moving. + +# pylint: disable=import-error,invalid-name,broad-except +__title__ = 'Panel Lift Check' + +import io +import json +import os + +from Autodesk.Revit.DB import (AssemblyInstance, ElementId, + OverrideGraphicSettings, Reference, + TemporaryViewMode) +from Autodesk.Revit.UI.Selection import ISelectionFilter, ObjectType +from Autodesk.Revit.Exceptions import OperationCanceledException +from System.Collections.Generic import List + +from pyrevit import revit, script, forms + +from vibe import (batchrun, configmodel, knowledge, liftexport, ticketqc, + designspec, tx) +from vibe import runlog +from vibe import scope as scopelib +from vibe.compat import as_text + +doc = revit.doc +uidoc = revit.uidoc +output = script.get_output() +output.close_others() + +# The piece(s) this run is about, for the run log. Set once the selection is +# resolved (single mark after `asm = assemblies[0]`, the batch's chosen names +# inside `_run_batch`), and read by not_run()/die() -- so a NOT RUN or refusal +# records the piece it happened to, and a press that dies before any selection +# resolves records nothing. The record is written at the exit, where the +# outcome is known: every deliberate exit here leaves through sys.exit() or an +# exitscript alert, and a runlog.run() context would log that SystemExit as +# outcome='error'. Same shape as Ticket QC's runlog wiring. +_marks = None + + +def die(message, title='Panel Lift Check'): + """Usage error, not a calculation refusal (Claude 08-28). + + die() is ONLY for 'the user has not given us something yet' -- no + assembly selected, nothing isolated, nothing in scope to offer. A + refusal to CALCULATE (missing prestress / weight gate / no stages / + no Python / no concrete strength) must go through not_run(), which + leaves a row in the report saying what was not done and why. The + report must survive the dialog. + """ + runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks, + outcome='refused', report_text=message) + forms.alert(message, title=title, exitscript=True) + + +def not_run(message, title='Panel Lift Check', outcome='not-run'): + """'Could not be calculated' is a RESULT, not a failure of the piece + (Claude 08-28: exit code non-zero != the panel failed). Print a + NOT RUN row with the reason that tells the user what to do next, + then stop. The five-word discipline from Ticket QC applies here too: + the tool must be able to say what it did not do. + + `outcome` is the run-log disposition. It defaults to 'not-run' -- the + banner says NOT RUN -- and the one site that is a refusal rather than a + not-run (the weight gate, which refuses to bless a piece whose measured + geometry does not weigh what the model says) passes 'refused'. + """ + output.print_md('# Panel Lift Check — NOT RUN') + output.print_md('**NOT RUN** — the check did not run for this piece.') + output.print_md('> {0}'.format(message)) + runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks, + outcome=outcome, + report_text=('# Panel Lift Check — NOT RUN\n' + '**NOT RUN** — the check did not run for ' + 'this piece.\n> {0}'.format(message))) + script.exit() + + +# ------------------------------------------------- background batch engine +from System.Threading import Thread, ThreadStart + + +def _design_of(asm, groups): + """The design key an assembly belongs to, or None if it is in none.""" + for key, group in groups.items(): + for member in group: + try: + if member.Id == asm.Id: + return key + except Exception: + pass + return None + + +def _run_batch(preselected=None): + """Run the full check on many panels in the background. + + Called with no argument, the user picks from the assemblies inside the + declared scope, and the page says how many were left off and of what + kind. Given a selection -- several assemblies highlighted before the + button was pressed -- those ARE the batch, whatever their marks: nobody + is asked which pieces they meant and nothing is dropped. + """ + global _marks # the run-log piece list is read by not_run()/die() + know = knowledge.load(doc) + _summaries, sheets = ticketqc.collect_context(doc) + groups = ticketqc.assemblies_by_design(doc, know) or {} + + if preselected: + seen = set() + items = [] + for asm in preselected: + try: + asm_key = asm.Id.IntegerValue + except Exception: + asm_key = None + if asm_key is None: + try: + asm_key = asm.Id.Value + except Exception: + continue + if asm_key in seen: + continue + seen.add(asm_key) + items.append([str(asm.Name), asm, _design_of(asm, groups)]) + items.sort(key=lambda r: (str(r[2]), r[0])) + else: + # Nobody preselected anything, so the TOOL is choosing what to offer + # -- and it offers what the job's DECLARED SCOPE says is this + # engineer's. What it left off the list is printed, by kind, rather + # than quietly missing. A PRESELECTED batch (the branch above) is + # never filtered: the selection is the request. + the_scope = scopelib.of(know) + items, left_alone = [], [] + for design, asm_list in groups.items(): + for asm in asm_list: + name = str(asm.Name) + (items if the_scope.includes(name) + else left_alone).append([name, asm, design]) + items.sort(key=lambda r: (r[2], r[0])) + scope_line = the_scope.line([r[0] for r in left_alone], + total=len(items) + len(left_alone), + unit='piece') + if not items: + die('There is nothing to offer for a batch.\n\n{0}' + .format(scope_line)) + + names = [r[0] for r in items] + output.print_md('> ' + scope_line) + picked = forms.SelectFromList.show( + names, multiselect=True, + title='Batch - choose pieces (Ctrl/Shift to multi-select)') + if not picked: + runlog.record('Panel Lift Check', doc_title=doc.Title, marks=None, + outcome='refused', + report_text='Batch picker closed -- no pieces chosen.') + script.exit() + picked_set = set(picked) + items = [r for r in items if r[0] in picked_set] + if not items: + runlog.record('Panel Lift Check', doc_title=doc.Title, marks=None, + outcome='refused', + report_text='Batch picker closed -- no pieces chosen.') + script.exit() + + chosen = items + + # What this batch is ABOUT, for the run log. The preselected branch + # (above) chose by clicking; the pick-list branch chose from the scope. + # Either way the record's marks are the pieces the run was asked for. + _marks = [r[0] for r in chosen] + + start_dir = knowledge.get(know, 'handling.report_dir') or \ + designspec.sub_dir(doc, 'handling') + try: + if start_dir and not os.path.isdir(start_dir): + os.makedirs(start_dir) + except Exception: + start_dir = '' + python_exe = liftexport.find_python(know) + if not python_exe: + not_run('CPython 3.9 or newer was not found.\n\nSet ' + 'handling.python_exe in the knowledge file to the full ' + 'path of python.exe.') + + compute = [] + total = len(chosen) + with forms.ProgressBar(title='Batch: measuring {0} panels...'.format(total), + cancellable=True) as pb: + for i, (name, asm, design) in enumerate(chosen): + if pb.cancelled: + break + pb.update_progress(i, total) + job, problems = liftexport.export(doc, asm, know, sheets, + design, batch=True) + if job is None: + continue + stages, _rows, stage_problems = liftexport.default_stages(job, know) + problems = list(problems or []) + list(stage_problems or []) + missing = [s['name'] for s in stages + if s.get('enabled', True) and not s.get('fc_psi')] + if problems or missing: + continue + job['stages'] = stages + job_path = os.path.join(start_dir or '', + '{0}.job.json'.format(name)) + blob = json.dumps(liftexport.strip_private(job), indent=1, + sort_keys=True) + with io.open(job_path, 'w', encoding='utf-8') as fh: + fh.write(as_text(blob)) + compute.append([name, job_path]) + pb.update_progress(len(chosen), total) + + if not compute: + not_run('Nothing was measured for batch (all refused or ' + 'cancelled).') + + status_path = os.path.join(start_dir or '', 'batch-status.txt') + + # The thread that runs the batch outlives this command. pyRevit tears + # the command module's scope down when the command returns, so a thread + # that looks ANY module global up from that scope -- `io`, `os`, + # `liftexport` -- raises UnboundNameException, on a NON-MAIN thread, + # which Revit cannot catch: the whole process dies (measured + # 2026-08-27). The worker therefore lives in vibe.batchrun, whose module + # scope is the session's, not the command's, and every name it needs is + # bound as a default parameter at definition time. `run_one` does the + # same for the two names that are this file's to pass on. + # + # recommend=False, and this is the whole reason a batch is usable. + # + # The position search is 93% of a run -- 12.6 s of a 13.6 s median piece, + # 49 s on the worst. Measured through this exact path on the 184-piece + # reference sweep: 38 minutes with the search on, 2.5 minutes without, + # and about half a minute without it and eight at a time. The verdicts + # are untouched -- 111 OK / 59 WARN / 13 FAIL / 1 refused either way -- + # because every stage is still analysed and every check still runs. A + # batch is a triage pass: WHICH pieces need looking at. "Then where?" is + # a question about ONE piece, and the single-piece run answers it in + # thirteen seconds. + # + # The reports say so on the page. A batch of reports whose Recommended + # positions section had quietly vanished would read, 184 times over, as + # 184 pieces with nothing to recommend. + def run_one(name, job_path, out_path, lx=liftexport, px=python_exe): + lx.run(px, job_path, out_path, recommend=False) + + # Several at once. Each job is already an isolated CPython subprocess + # writing its own files, so this is a scheduling change, not a numerical + # one -- and two cores are left alone so Revit stays usable while it runs. + at_once = batchrun.worker_count() + worker = batchrun.make_worker(compute, status_path, run_one, + workers=at_once) + + Thread(ThreadStart(worker)).Start() + + output.print_md('**Batch started in the background.**') + # The scope, on the page, every time. A batch of the pieces this engineer + # owns and a batch of everything in the model look identical once they + # are running. + _scope = scopelib.of(know) + _outside = [name for name, _asm, _d in chosen if not _scope.includes(name)] + output.print_md('> ' + _scope.line([], unit='piece')) + if _outside: + output.print_md('> ' + (_scope.annotation(_outside, unit='piece') or '')) + output.print_md('Panels were measured on the UI thread; each report is ' + 'now computed in the background ({0} CPython subprocesses ' + 'at a time, two cores left free), so Revit stays usable.' + .format(at_once)) + # Said here as well as in every report. Somebody who starts a batch and + # then opens one of its reports looking for the move list has to find out + # why it is not there, and the place they are looking at the moment they + # wonder is this page. + output.print_md('> **Insert positions are not searched in a batch.** That ' + 'search is 93% of a run — it is what made a sweep of ' + 'this model take 38 minutes instead of half a minute. ' + 'Every stage is still ' + 'analysed and every check still runs, so the verdicts are ' + 'the whole answer to *which pieces need looking at*. Run a ' + 'piece on its own to be told **where** its inserts should ' + 'go; each report says so where the positions would be.') + output.print_md('Reports + job files go to:\n\n `{0}`'.format(start_dir)) + output.print_md('Progress is written to:\n\n `{0}`'.format(status_path)) + # The batch itself is the record here. Each panel's own report is written + # by the background worker, which lives on in vibe.batchrun after this + # command's scope is torn down -- so a per-piece record would have to come + # from the worker (which cannot reach this module's globals), and the + # command-scope record of a batch that was started is the honest one. + runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks, + outcome='ok', + report_text=('Batch started in the background.\n{0} pieces ' + 'were measured on the UI thread and handed to ' + '{1} CPython subprocesses at a time.\nReports ' + '+ job files go to: {2}\nProgress: {3}' + .format(len(compute), at_once, start_dir, + status_path))) + script.close() + + +# -------------------------------------------------- mode: batch / assembly +# The selection decides. One assembly preselected (any member of one +# counts) runs straight through as the single-panel check. Several +# preselected run as a background batch on exactly what is highlighted. +# Nothing preselected asks. +# +# Deliberately off the preselection rather than a list: the piece is the +# subject of the whole run, and picking it from several hundred assemblies +# is worse than clicking it in the model. + +selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()] +assemblies = [el for el in selection if isinstance(el, AssemblyInstance)] + +if not assemblies: + owners = [] + for el in selection: + try: + owner = doc.GetElement(el.AssemblyInstanceId) + except Exception: + owner = None + if isinstance(owner, AssemblyInstance) and owner not in owners: + owners.append(owner) + assemblies = owners + +if len(assemblies) > 1: + _run_batch(assemblies) + script.exit() + +if not assemblies: + _modes = ['Batch (many pieces)', 'Assembly (one piece)'] + _mode = forms.SelectFromList.show(_modes, multiselect=False, + title='Panel Lift Check') + if not _mode: + runlog.record('Panel Lift Check', doc_title=doc.Title, marks=None, + outcome='refused', + report_text='Mode picker closed -- no run chosen.') + script.exit() + if _mode.startswith('Batch'): + _run_batch() + script.exit() + die('Select the assembly you want checked, then run this again.\n\n' + 'Selecting any member of it works too.') + +asm = assemblies[0] +_marks = [str(asm.Name)] + +# ------------------------------------------------------------------ read it + +know = knowledge.load(doc) +for problem in knowledge.config_problems(doc, know): + not_run(problem) +if not (knowledge.get(know, 'handling.lifter_groups', {}) or {}): + not_run('No handling.lifter_groups in the knowledge files.\n\n' + 'The check needs to know which family is a face lifter and ' + 'which is an edge anchor. Add them to\n\n{0}\n\nand run again.' + .format(knowledge.plant_file_in_use(know))) + +with forms.ProgressBar(title='Measuring {0}...'.format(asm.Name)) as pb: + pb.update_progress(0, 3) + _summaries, sheets = ticketqc.collect_context(doc) + design = None + for key, group in ticketqc.assemblies_by_design(doc, know).items(): + if any(a.Id == asm.Id for a in group): + design = key + break + pb.update_progress(1, 3) + job, problems = liftexport.export(doc, asm, know, sheets, design) + pb.update_progress(3, 3) + +if job is None: + not_run('\n\n'.join(problems) + + '\n\nFix the cause above and run again.') + +panel = job['panel'] +# The record's marks follow the piece into the job: `panel['mark']` is the +# mark the calculation ran on (assembly type name read from the model), which +# is the identifier the report and the job file carry. +_marks = [panel['mark']] + +# Before anything is calculated: does the geometry we read weigh what the +# model says it does? A blockout read wrong is silent, and it makes the +# section modulus wrong in the direction that says a panel is fine. +ok, message, _numbers = liftexport.weight_check(job, know) +if not ok: + # The weight gate REFUSES the run: measured geometry does not weigh what + # the model says, and the check must not bless a piece read that way. The + # page still says NOT RUN (the check did not run), but the log records the + # refusal distinctly from a plain not-run -- see not_run(). + not_run(message + '\n\nFix the weight mismatch and run again.', + title='Panel Lift Check - {0}'.format(panel['mark']), + outcome='refused') + +stages, _rows, stage_problems = liftexport.default_stages(job, know) +if not stages: + not_run('No handling.stages in the knowledge files, so there is nothing to ' + 'check the piece through.') +if not [s for s in stages if s.get('enabled', True)]: + # Some stages are configured and every one of them is switched off. That + # is a different fact from "no stages", and it belongs here rather than as + # a JobError out of the calculation, where it would arrive as a stderr + # line in a dialog about a job file the engineer never opened. + not_run('Every handling stage is switched off in the configuration ' + '({0}), so there is nothing to check. Enable at least one stage ' + 'in Project Config.' + .format(', '.join(s['name'] for s in stages))) +if stage_problems: + not_run('\n\n'.join(stage_problems), + title='Panel Lift Check - plant dunnage setting') + +# The audit used to refuse the run. It was guarding an automatic pick nobody +# could see; now every insert is clicked on the piece, so a bad default costs +# a few clicks rather than a wrong answer. It is still worth saying out loud, +# because it means the highlighted defaults are not to be trusted here. +audit = liftexport.stage_audit(stages) + +# --------------------------------------------------------- confirm the picks +# The inserts for each stage are picked IN THE MODEL, one stage at a time. +# +# This used to be a checklist of rows. It was wrong twice over: a row is the +# tool's guess about which inserts share a hook, and the label named one family +# when the row held two -- which is how one edge anchor and two face lifters an +# inch apart read as three edge anchors, and a whole support line went missing. +# +# Nobody can audit a checkbox that says 5'-8". Everybody can audit three +# inserts lit up on the piece in front of them. The knowledge rules still +# choose the defaults, so the usual answer is still one click; they just no +# longer get the last word, and they no longer get it silently. + +by_mark = dict((l['mark'], l) for l in panel['lifters']) +insert_ids = set(int(m) for m in by_mark) + + +class OnlyTheseInserts(ISelectionFilter): + """Only the lifting inserts of this piece can be clicked. + + Without it the panel itself, its rebar and its neighbours are all pickable, + and a stray click becomes a support line the piece does not have. + """ + + def AllowElement(self, element): + try: + eid = ticketqc.eid(element) + return eid in insert_ids or eid in companion_of + except Exception: + return False + + def AllowReference(self, reference, point): + return False + + +pick_filter = OnlyTheseInserts() + + +def elements_of(marks): + found = [] + for mark in marks: + try: + el = doc.GetElement(ElementId(int(mark))) + except Exception: + el = None + if el is not None: + found.append(el) + return found + + +def zoom_state(): + """The active view's zoom, so it can be put back after the fit.""" + try: + for uiview in uidoc.GetOpenUIViews(): + if uiview.ViewId == uidoc.ActiveView.Id: + corners = uiview.GetZoomCorners() + return (uiview, corners[0], corners[1]) + except Exception: + pass + return None + + +def id_int(element_id): + try: + return element_id.IntegerValue + except Exception: + return element_id.Value + + +def _location_point(el): + """The element's placement point in model space, or None.""" + try: + loc = el.Location + pt = getattr(loc, 'Point', None) + if pt is None: + curve = getattr(loc, 'Curve', None) + pt = curve.Evaluate(0.5, True) if curve is not None else None + return pt + except Exception: + return None + + +def _companions_of(): + """Members of the assembly that sit ON an accepted lifter, keyed by + element id int -> the lifter's mark. + + A lifter family can be two elements at one point: the lifter the plant + knowledge names and the body it is placed with (measured 2026-09-03 on + a top lifter: the named element and its body at distance 0.0). Only + the named one is in `panel['lifters']`, so without this the body is + ghosted with the concrete, the pre-selected top lifter highlights + nothing you can see, and a click that lands on the body is not a pick. + Co-located means within 0.05 ft. + """ + anchors = [] + for mark in by_mark: + try: + el = doc.GetElement(ElementId(int(mark))) + except Exception: + el = None + pt = _location_point(el) if el is not None else None + if pt is not None: + anchors.append((mark, pt)) + out = {} + if not anchors: + return out + for member in asm.GetMemberIds(): + mid = id_int(member) + if mid in insert_ids: + continue + el = doc.GetElement(member) + pt = _location_point(el) if el is not None else None + if pt is None: + continue + for mark, apt in anchors: + try: + if pt.DistanceTo(apt) <= 0.05: + out[mid] = mark + break + except Exception: + pass + return out + + +companion_of = _companions_of() + + +def show_the_piece(): + """Isolate the piece in the view the user is already in, and see into it. + + Their view, not one this picks for them and not a new one: the view on + screen is the view they chose to be in. + + Isolating is not enough on its own. An insert is cast INSIDE the concrete, + so in any shaded view the panel is standing in front of every one of them + -- you can still click them, because the filter passes the click through + to the insert, but the highlight that says which one you got is painted + behind six inches of opaque concrete. The pick works and shows you + nothing. So everything that is not an insert goes 85% transparent, which + leaves the panel and its openings as an outline to place them against. + + A view template can lock the display style -- wireframe is not available + to us here -- but per-element overrides go through it, which is why this + is done insert by insert rather than by changing how the view draws. + + Those two things are all this button writes to the model, and it writes + them twice: on, and off again. Everything else here reads. + + Returns the callable that puts the view back. + """ + view = uidoc.ActiveView + ids = List[ElementId]() + ids.Add(asm.Id) + others = [] + for member in asm.GetMemberIds(): + ids.Add(member) + if id_int(member) not in insert_ids and \ + id_int(member) not in companion_of: + others.append(member) + + ghost = OverrideGraphicSettings() + ghost.SetSurfaceTransparency(85) + try: + with tx('Show assembly'): + view.IsolateElementsTemporary(ids) + for member in others: + try: + view.SetElementOverrides(member, ghost) + except Exception: + pass + except Exception: + die('This view cannot isolate the piece, so there is nothing to pick ' + 'on. Schedules and sheets have no geometry to hide.\n\nOpen a view ' + 'that shows {0} and run the check again.'.format(asm.Name)) + + zoom = zoom_state() + # ZoomToFit and not ShowElements: with everything else hidden the fit IS + # the piece, and ShowElements can raise a modal dialog when it disagrees + # about whether a view shows something. + if zoom is not None: + try: + zoom[0].ZoomToFit() + except Exception: + pass + + def restore(): + plain = OverrideGraphicSettings() + try: + with tx('Show assembly'): + for member in others: + try: + view.SetElementOverrides(member, plain) + except Exception: + pass + view.DisableTemporaryViewMode( + TemporaryViewMode.TemporaryHideIsolate) + except Exception: + pass + if zoom is not None: + try: + zoom[0].ZoomAndCenterRectangle(zoom[1], zoom[2]) + except Exception: + pass + + return restore + + +def pick_inserts(stage, preset): + """Click the inserts for one stage. None if the user pressed Esc.""" + pre = List[Reference]() + ids = List[ElementId]() + for el in elements_of(preset): + try: + pre.Add(Reference(el)) + ids.Add(el.Id) + except Exception: + pass + # The bodies that sit on the preset lifters come along, so the + # pre-selection highlights something the eye can find. + for cid, mark in companion_of.items(): + if mark not in preset: + continue + try: + el = doc.GetElement(ElementId(cid)) + pre.Add(Reference(el)) + ids.Add(el.Id) + except Exception: + pass + # Said twice on purpose. PickObjects takes the pre-selected references and + # returns them, but does not reliably paint them, and a default the user + # cannot see is a default they cannot accept. Setting the selection first + # puts them on the screen in the ordinary blue. + try: + uidoc.Selection.SetElementIds(ids) + uidoc.RefreshActiveView() + except Exception: + pass + prompt = ('{0} of {1}: select the inserts that carry the piece, then ' + 'click Finish'.format(stage['name'].upper(), panel['mark'])) + try: + try: + refs = uidoc.Selection.PickObjects( + ObjectType.Element, pick_filter, prompt, pre) + except TypeError: # older binding: no pre-selected overload + refs = uidoc.Selection.PickObjects( + ObjectType.Element, pick_filter, prompt) + except OperationCanceledException: + return None + picked = [] + for ref in refs: + el = doc.GetElement(ref.ElementId) + if el is None: + continue + eid = ticketqc.eid(el) + mark = companion_of.get(eid, str(eid)) # a click on the body is the lifter + if mark in by_mark and mark not in picked: + picked.append(mark) + return picked + + +def tally(marks): + """Families counted, and the stations they stand at, in order.""" + counts = {} + stations = [] + for mark in sorted(marks, key=lambda m: by_mark[m]['x']): + lifter = by_mark[mark] + counts[lifter['family']] = counts.get(lifter['family'], 0) + 1 + where = ticketqc.fmt_feet(lifter['x']) + if where not in stations: + stations.append(where) + return (', '.join('{0} x{1}'.format(f, c) + for f, c in sorted(counts.items())), + ', '.join(stations)) + + +put_it_back = show_the_piece() + +support_note = '' +supported = [s for s in stages + if s.get('supports') and s.get('enabled', True)] +if supported: + support_note = ('\n\nNot picked, read from plant knowledge: ' + + '; '.join( + '{0} on dunnage at {1}'.format( + s['name'], + ', '.join(ticketqc.fmt_feet(x) + for x in s['supports'])) + for s in supported)) + +forms.alert( + 'You will be asked to select the inserts for each stage in turn, on the ' + 'piece itself:\n\n {0}\n\nWhat the rules expect is highlighted ' + 'already. Ctrl-click adds, Shift-click removes, then Finish on the ' + 'options bar. Esc cancels the whole run.{1}{2}' + .format(' -> '.join( + s['name'] if s.get('enabled', True) + else '{0} (not analysed)'.format(s['name']) for s in stages), + '' if not audit else + '\n\nThe defaults need a look on this piece:\n\n ' + + '\n '.join(audit), + support_note), + title='{0} - inserts'.format(panel['mark'])) + +chosen = [] +try: + for stage in stages: + # A stage switched off in the plant config is not analysed, so there + # is nothing to pick for it. It still travels into the job file, and + # panellift still prints it -- as the sentence "not performed", in + # its place among the stages that ran. Asking for inserts here would + # be asking the engineer to configure a check nobody is running. + if stage.get('enabled', True) is False: + chosen.append(stage) + continue + if stage.get('supports'): + chosen.append(stage) + continue + preset = list(stage['lifters']) + while True: + marks = pick_inserts(stage, preset) + if marks is None: + # Esc out of a pick: the isolate is restored by the finally + # below, and the run log records a user cancellation before + # the exit so the press is not silent. + runlog.record('Panel Lift Check', doc_title=doc.Title, + marks=_marks, outcome='refused', + report_text=('Esc out of the insert pick for ' + '{0} -- run cancelled.'.format( + panel['mark']))) + script.exit() + if len(marks) < 2: + forms.alert('{0} needs at least two inserts to be a pick, ' + 'and {1} were selected.' + .format(stage['name'], len(marks)), + title='Panel Lift Check') + preset = marks or preset + continue + if len(marks) < len(stage['lifters']): + families, stations = tally(marks) + # Fewer inserts than the rules expected is a real pick and + # often the right one -- a crane that cannot reach the top + # row, an insert cast over. It is worth one question because + # a support line dropped by accident is invisible in the + # result: the report is complete, the diagrams are drawn, and + # the piece was checked on a pick it does not have. + if not forms.alert( + '{0}: {1} inserts selected where the rules expected ' + '{2}.\n\n {3}\n at {4}\n\nUse these?' + .format(stage['name'], len(marks), + len(stage['lifters']), families, stations), + title='{0} - {1}'.format(panel['mark'], + stage['name']), + ok=False, yes=True, no=True): + preset = marks + continue + stage['lifters'] = marks + chosen.append(stage) + break +finally: + # Esc out of a pick raises through here. Leaving a temporary isolate + # switched on in somebody's view because they changed their mind is not + # something they should have to work out how to undo. + if put_it_back is not None: + put_it_back() + +job['stages'] = chosen + +missing = [s['name'] for s in chosen + if s.get('enabled', True) and not s.get('fc_psi')] +if missing: + not_run('No concrete strength for: {0}.\n\nThe design summary for ' + '{1} did not give an f\'c or f\'ci that these stages could ' + 'use.'.format(', '.join(missing), design or 'this piece')) + +# ------------------------------------------------------------ where it goes + +start_dir = knowledge.get(know, 'handling.report_dir') or \ + designspec.sub_dir(doc, 'handling') +try: + if start_dir and not os.path.isdir(start_dir): + os.makedirs(start_dir) +except Exception: + start_dir = '' + +out_path = forms.save_file( + file_ext='html', init_dir=start_dir or '', + default_name='{0}.html'.format(panel['mark']), + title='Save the handling report as') +if not out_path: + runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks, + outcome='refused', + report_text=('No report path chosen for {0} -- run ' + 'cancelled.'.format(panel['mark']))) + script.exit() + +# ------------------------------------------------------------------- run it + +python_exe = liftexport.find_python(know) +if not python_exe: + not_run('CPython 3.9 or newer was not found.\n\n' + 'The calculation runs outside Revit so it can be tested ' + 'without one. Install Python, or set handling.python_exe in ' + 'the knowledge file to the full path of python.exe.') + +# The job file is kept beside the report on purpose. It is the whole input to +# the calculation, so a number anybody disputes can be re-run, by hand, on a +# machine with no Revit on it. +job_path = os.path.splitext(out_path)[0] + '.job.json' +blob = json.dumps(liftexport.strip_private(job), indent=1, sort_keys=True) +with io.open(job_path, 'w', encoding='utf-8') as fh: + fh.write(as_text(blob)) + +# Whether this run is willing to spend the 13 seconds. ON by default and ON +# after any failure to read the file, exactly like expensive_geometry: an +# unreadable options file must never quietly take half the report away. +search_positions = True +try: + search_positions = bool(configmodel.load_run_options( + designspec.root_dir(doc))['recommend_positions']) +except Exception: + search_positions = True + +# The position search is the long part and it can genuinely run for minutes on +# a piece with many movable rows. It runs on Revit's UI thread, so without a +# progress bar being ticked the window is simply frozen, which nobody can tell +# apart from a crash -- and a run that looks crashed gets killed. +# +# The title tells the truth about which of the two it is doing. A bar that says +# "searching insert positions" on a run that is not searching them is the same +# lie as a report section that quietly disappears. +with forms.ProgressBar(title='{0}: {1}... {{value}}s'.format( + panel['mark'], + 'searching insert positions' if search_positions + else 'checking the piece (position search off)'), + cancellable=True) as pb: + cancelled = [] + + def tick(elapsed_ms): + if pb.cancelled: + cancelled.append(True) + pb.update_progress(elapsed_ms // 1000, max(60, elapsed_ms // 1000)) + + pb.update_progress(0, 60) + code, summary, stderr = liftexport.run(python_exe, job_path, out_path, + on_tick=tick, + recommend=search_positions) + +if code == 2 or summary is None: + not_run('The calculation could not run.\n\n{0}\n\n' + 'Job file kept at:\n{1}'.format(stderr or 'no error text', + job_path)) +# exit 1 is a RESULT (a stage failed its check), not a crash -- it is +# shown as FAIL in the table below, which is 'calculated and failed', +# distinct from the NOT RUN above. + +if knowledge.get(know, 'handling.open_report', True): + liftexport.open_file(out_path) + +# -------------------------------------------------------------------- report + +output.print_md('# {0} — handling'.format(panel['mark'])) +# The scope, on the single-piece report too. This piece was SELECTED, so it +# was never a candidate for being set aside -- but if it is outside the +# declared scope the reader is told that the numbers rest on knowledge nobody +# validated for that kind of piece. +_scope = scopelib.of(know) +output.print_md('> ' + _scope.line([], unit='piece')) +if not _scope.includes(panel['mark']): + output.print_md('> ' + (_scope.annotation([panel['mark']], unit='piece') + or '')) +output.print_md('Design **{0}** · {1} × {2} × {3}" ' + '· {4} lb' + .format(panel.get('design_number') or '-', + ticketqc.fmt_feet(panel['length_ft']), + ticketqc.fmt_feet(panel['width_ft']), + panel['thickness_in'], + '{0:,.0f}'.format(panel['weight_lb'] or 0))) +# A bare Windows path is not a URL: with backslashes and no scheme a +# browser will not navigate to it, so the link rendered but did nothing. +# The text stays the real path -- that is what the engineer copies -- and +# only the href gets the file:/// form. +_href = 'file:///' + out_path.replace('\\', '/') +output.print_md('**{0}**   [{1}]({2})'.format(summary['verdict'], + out_path, _href)) + +# The verdict above is the answer for the stages that RAN. If one was +# switched off, saying only "OK" here claims the piece, and a reader who +# takes the button's one bold word and closes the window has been told the +# panel is fine when a whole handling condition was never looked at. +_not_run = summary.get('not_run') or [] +if _not_run: + output.print_md( + '> **{0} of {1} stages were not analysed** — {2}. The verdict ' + 'above covers the stages that were.' + .format(len(_not_run), len(summary['stages']), + '; '.join('{0}{1}'.format( + s['name'], ' ({0})'.format(s['reason']) + if s.get('reason') else '') for s in _not_run))) + +# WHICH END IS UP, when the ruled mechanism did not answer it. +# +# `knowledge.up_end_from_origin` is the project's flagship declared-coverage +# refusal, and `liftexport.erected_orientation` falls back to global elevation +# when it declines. The fallback is ALLOWED -- global elevation is the sound +# mechanism for a piece erected on end, which is the case the rule hands over +# -- but it may never be silent, or it is a guess wearing the refusal's +# credibility. The key is written only when the fallback answered, so this +# block never fires on a piece the rule settled. +_basis = summary.get('basis') +if _basis: + output.print_md( + '> **Which end is up came from a fallback, not the rule.** Read from ' + '{0}. {1}
What it decides: {2}. Stripping and transportation are ' + 'analysed flat and are unchanged by it.' + .format(_basis.get('basis'), _basis.get('why') or '', + _basis.get('affects') or 'the erected stage')) + +for problem in problems: + output.print_md('> {0}'.format(problem)) + + +def _stage_row(s): + """One row of the summary table. A stage that was not analysed gets + words across the numeric columns, not dashes: a dash in a column of + numbers is read as a number that came out small.""" + if not s.get('analysed', True): + return [s['name'], '-', '-', 'not analysed', '-', '-', 'NOT RUN'] + return [s['name'], '{0:.2f}'.format(s['multiplier']), + '{0:,.0f}'.format(s['fc_psi']), + '-' if s['peak_tension_psi'] is None + else '{0:,.0f}'.format(s['peak_tension_psi']), + '{0:,.0f}'.format(s['allowable_psi']), + '-' if s['ratio'] is None else '{0:.0%}'.format(s['ratio']), + s['verdict']] + + +output.print_table( + [_stage_row(s) for s in summary['stages']], + columns=['stage', 'mult', "f'c", 'peak psi', 'allowable', 'of allow', + 'result']) + +# What was actually picked, in writing. The numbers above mean nothing without +# it, and it is the one part of the run that came from a human. +# +# A stage carried on dunnage was not picked and has no 'lifters' key at all -- +# it is dropped rather than emptied, because panellift refuses a stage that +# gives both a support line and an insert. So it gets its own row saying where +# it sat, rather than an empty one implying nobody chose anything. +output.print_md('## Inserts picked') +rows = [] +for s in chosen: + if s.get('enabled', True) is False: + rows.append([s['name'], '-', 'not analysed - no pick was made', '-']) + elif s.get('supports'): + rows.append([s['name'], '-', 'on dunnage (plant knowledge)', + ', '.join(ticketqc.fmt_feet(x) for x in s['supports'])]) + else: + marks = s.get('lifters') or [] + rows.append([s['name'], str(len(marks))] + list(tally(marks))) +output.print_table(rows, columns=['stage', 'n', 'inserts', 'at']) + +rec = summary.get('recommendation') or {} +if rec.get('searched') is False: + # `searched` is written by panellift ONLY when the search was skipped, so + # `is False` is the whole test and a summary from a build that predates + # the flag reads as "it ran" -- which it did. Printed under the heading it + # would have filled, not dropped: a missing section reads as a piece with + # nothing to recommend, and this piece was never asked. + output.print_md('## Recommended insert positions') + output.print_md( + '**NOT COMPUTED** — the position search is switched off for ' + 'this run (`recommend_positions` in Project Config → run-time ' + 'options). Every stage above was checked on the inserts exactly as ' + 'drawn and the verdict stands; what is missing is only where they ' + 'should go instead. Switch it back on and run the piece again to get ' + 'it.') +elif rec.get('note') and not rec.get('moves'): + output.print_md('> {0}'.format(rec['note'])) +elif rec.get('moves'): + output.print_md('## Recommended insert positions') + if rec.get('worth_moving'): + output.print_md( + 'Moving them takes the governing stage from **{0:.0%}** of ' + 'allowable to **{1:.0%}**.'.format(rec['as_built_ratio'], + rec['recommended_ratio'])) + else: + output.print_md('The inserts are already where the search would put ' + 'them.') + output.print_table( + [[m['mark'], m['family'], + ticketqc.fmt_feet(m['from_x_ft']), ticketqc.fmt_feet(m['from_y_ft']), + ticketqc.fmt_feet(m['to_x_ft']), ticketqc.fmt_feet(m['to_y_ft']), + '-' if abs(m['dx_in']) < 0.005 else '{0:+.2f}'.format(m['dx_in']), + '-' if abs(m['dy_in']) < 0.005 else '{0:+.2f}'.format(m['dy_in']), + 'pinned' if m['pinned'] else ('move' if m['moved'] else '')] + for m in rec['moves']], + columns=['insert', 'family', 'x', 'y', 'to x', 'to y', + 'dx in', 'dy in', '']) + for row in rec.get('rows') or []: + if row.get('note'): + output.print_md('- {0}: {1}'.format( + ticketqc.fmt_feet(row['to_x_ft']), row['note'])) + output.print_md( + '> The recommendation is one position per insert, solved for every ' + 'stage at once. **x** is searched to minimise the governing ' + 'utilisation; **y** is geometry only — a beam model has no ' + 'transverse mechanics, so whether the piece hangs level is stated ' + 'from the layout and not calculated.') + +for stage in summary['stages']: + bad = stage['findings'] + if not bad: + continue + output.print_md('### {0}'.format(stage['name'])) + for f in bad: + output.print_md('- **{0}** `{1}` — {2}' + .format(f['severity'], f['code'], f['message'])) + +output.print_md('---') +output.print_md('Report: `{0}` \nJob file: `{1}`'.format(out_path, job_path)) + +# The report file exists and was (unless configured otherwise) opened -- the +# run produced its output, so the record is 'ok' with the path the report +# landed at. Left until after every print, as in Ticket QC: an exception while +# the page is rendering records nothing rather than claiming an ok the user +# never saw, and record() never raises. +runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks, + outcome='ok', report_path=out_path, + report_text=('{0} — {1}\nReport: {2}\nJob file: {3}' + .format(summary['verdict'], panel['mark'], + out_path, job_path))) diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/bundle.yaml new file mode 100644 index 0000000..2897969 --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/bundle.yaml @@ -0,0 +1,36 @@ +# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and +# why a container may carry a `title:`. +title: DesignHub + +# REORGANISED 2026-09-05, on the owner's own instruction: "把 Panel Lift +# 移到 Design Hub 里面去,再把 Beam Analysis 跟 Connection Analysis 也加 +# 进去" -- Panel Lift Check moved onto this pulldown from being one of +# Tools.panel's four large icons (see that panel's own bundle comment), and +# two new placeholder rows, BeamAnalysis and ConnectionAnalysis, joined +# GravityLoadChase and LateralExtract as more (WIP) rows. +# +# PanelLiftCheck is NOT half-built -- it is the one row here that runs, end +# to end, on a real piece, exactly like PanelAnalysis. It moved here because +# the owner wants it grouped with the other analysis tools rather than +# because it stopped working; nothing about its own behaviour changed. +# +# The other four rows are half-built and all four say so in their own +# titles. Keep the (WIP) suffixes until the tool behind one actually runs: +# the arrow hides them from the ribbon, but a row that opens is still a row +# somebody clicks. PanelAnalysis differs from BeamAnalysis, ConnectionAnalysis, +# GravityLoadChase and LateralExtract in one way worth naming: it DOES run, +# end to end, and opens a real page -- what is unfinished is the analysis +# pipeline behind the page it opens, not the button itself. See that +# button's own script.py. +# +# Order: the two tools that run today, then the four that do not, in the +# order the owner named them. +# +# Anything not listed here is not created. Add a button, add it here. +layout: + - PanelLiftCheck + - PanelAnalysis + - BeamAnalysis + - ConnectionAnalysis + - GravityLoadChase + - LateralExtract diff --git a/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/icon.png b/WillTools.extension/WillTools.tab/Design.panel/DesignHub.pulldown/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3b00e4256a2b4823959ba88c39aad0752691ac28 GIT binary patch literal 5126 zcmV+h6#46kP)>|tjM*4Ok(UrqF{U;Z?TMC2Hra}0wxXL@*~Adhp7s$S z(MqD5)K=WuJgt}+tQaMt#0VoWfS@i$25=ONpx`*~M?dbp=Y0Fe?QUkq0cLt;=yrBa z6%5t%bl>y+&iUTwcL+lp6p2JcMPmicnK*~{)$HRKVj81+^qO;|CZY+%Ie%C#zr*5gO^pJ8 zRhK<*wq;oV2A~qaY)jhEtifP-+5^C&R{uz0K!6}fLa_%=tD^+k2+a@2c6|5s3=sWk zhnxY3E{Phk+L(9er`MJQ0`d+3->{^8wyPbu+GQ{)fQbMwL0}XQV$$i&%>aZUq%frg zN2MHscuaA8-Tig<9E?VzqAFI!r?U@qT9p%xQY;qJch6sYwG{GUO9o~pRRT(D4+KFN z-2xwgJ!CK`A{ap<1Xnrjn)TiH)_w000Ei+gPNkCMsSGWmpja%X@44bfQ9~O44WjW0 zFX1tmh)7bNbvzM)D8FWgKt%qoXID82Kqz1YS1z2qwBnM5JZxYzgY`GAR)la&`N_f14NL900{y!=W#udU@*Jd z^}=TOVgXtiKJa8M2CQ>2|wZ0vY-AYBDQ5*`0z` z8g|G+s5ArsNOh->=tu$p?63_X$pux+A=6Q*-Hzj}T(f&sOM1ujdN89ugIifs z>8+aoKvj`dd`qX>;S$Kq=VXK=xJeIMY0O$U1E0KV4$hu_7RG<93;@vC)P=^wO*pXa zP3+xNk5spVK#>itbUsy+nQ-H7*etqKo86BSXhn6TTA~K(q&pxIsg^Z0m1@=eb)N}H zDR1_|w{rXxMoO;gTS90qlgZ5jMNL zuBso_{UFxYMWvp4KN_u~;P&9S?gYIo$(ZgcM-!3ApS~AJcBe4qljq^f58i-rg8yZMJz@d=;1!y8YBop`UW@S1!Z0nQur&L zfX}a5i8)t(9Epx3grvNBNt!hz8Kzcv$bYq}w+dn~5eU&G502Na+D|0P6?>FVZ-)dS z)$QPx|GF77=1qlC8d45yJ#ZR?cxxO#z3EZJ+Y^w6%%_J0FqA1RnbhvGHFbAx&Zv6< z098w>Bmh>f_sxJ2oS?MF`F1svT`A03bRlNUn+iP$;b*{O%?Ou=am5!eMXK8Y3CerO zCWs(d-_2mEsMyCnZ2;YV&Cjgj_W0|jG^e}TWkMcxzZB?hjpIu{x*q?u@*-%ZA>`m5 zM{9q=%|~1D(;I);W5egwD6@c6E<2xCv*WG<3>Jc;EC6u0z58NQn$uk6BA>$dg{NVM zZA_dtA!877up$1>;>#|>070^b;GR^00d)!DNF&7 zLY~tL+S3S?1yO!(X|D1IkslSC0TbotmBMrK4*-FQSpyJ@0N`NJK^6y#4l)1;5?3-a z2wV_3d;$%d-WEg!Qc!?!wF_px{KqSP9ImacRbt}AiOjyxCou9~O=M1YSj0auPbI?maDyj$ma_~BGI0}2X8m0gRGqN zt?!DqC(wMf6^13jocCQ#N^5OPJLI^j+1WrW;M&As6DX9ThyQc`iw$YVW$=>AB;y)( z9zu6ZJpXY(ZzMz}4<~w{Q!sTz)*69vHeTKMI+~BPo``Sy##3$knpp1 zNpLR!P*7DSKNxcoF4o_>0pRpT7zhXqXvGjx;JKe~!O?y1!46q@Pfwo|2o&hhnh_|n z(Xi_fo?N%l&;1BGWnxV$zcD}>0(+j?i!HydL$ElI*JRx3hJgYeT4{t!g4q6t-Do}5 zfgA4rEXGYJI%Nc{J&le3`2uQxy8~9xM4=C(&>v81MyMo+{k3o4XZw#~(ao1((ao2E zq{s$Vneyaoo9a>f+nsoK|1ku|1$xNW49Waa_7^Xz1BzV&I1jqUMzL0hPM{)=BbT3b7gf$new=*gL z7>#2LfH45Z0Qks^O!56xaX|h{kE0NPOb3LMa1n2d_wQb^pZQF6rLtg*0WeJU;Z(N+ zC*k1SnP+3smoA5Co4uQJ8L&bYuKo9Am_BbRm>Kc*L>9QQI3UMW4+gqgx-nzkRIIpl zF{aI((*MdPKo|n^E9YW<d5aP5(I8%&aW?a0Y0%u(?0oBo`aO~i5*kKD=@t6%b-S8ZR8}7Ld7cH9wPLG z*M0$>qcG+LovIzNJB1nZreeug=KEExVd!L#hJaQY)8*6 zdHYgd@PuJg7n!3=%f7x4<&(?cIbOl2B2g%--$}SQ_v4dr{>A5knIVQTq9yX1b%Wyq zn7!!20%_JQq#Zy6FXf?P@rBs~79;T;U|`PDnUH4TbV-Op$@2?CVCp63=iH1tpk`eH zJ~roE6par39>!Ty#*dWzv(378*v7;Q#=}eHzgf4Sc7S0S2o%{P zZs~;-u)}u2RL(>pl^f5LPztGRDLyLo-eLb98Ur9J5NS%pTN7wH(sH7+d4$^Dow$Rh zBP}p(1BKuSiF}i2kRS*l(9zV1f4ufCMhbzZdAMdRUSKfg4< z3Yu8@uM2STirLuwt2(^=^j;`eBUl{Bo-fvlv(FtY4!|z<=M!&>Bhi_JkOG0QJuIz{ z#u+8ih&!+XCgN=gluRndb>CTmd0)5$(h%9npNZf|D~<6VE5{A@T!&9xdl9z$PaXDe zZGauNpj-trXyLN!FTt#ZGca-Lglt1_^O07(|JDcCyJ;WZsCx@S3YY<7NHqomfasE+ zlzL9;Z7D>FVl|*c+BuPEta%1+?>&kIH(rXRUt551St#ok_EbAs`{$B|qo&L3ANa^{ zPk?&6y&3iM@`l&&>?1Fr{Jb(;f7fSE9FaZAcw@&~c;fzN&~~g1fuey|+nAwk!xkx> zT>8NFRagBmyv%oMr4cF(`iJ?_JL6>YAwf_|W8C;6 zeDx=lD4A3Y*Ku>&twt_NOSu}ARi8tsG&EomjZBwAUoR)K3y6F*^~}S6!~PfFfR|Eu z`_*^w#rr>x3oo0ReJ|1!B7G;z?EjSkR!D*7Bdz%3J%7fVyADAb60dA{4Oe~hN=%=7 z!H0IB@A2?}&nHVMpp-_aG=$2k&*9N;JO;xWJQIQhfGaP!CZLu2wjc^H7$;Lf84``z zi)Z39Utc(A5{>?hED1t)YaE+?u?0`u{}&v8>jMOeYy`qKQr!+-etIvGT`8P9YZC0R z-ODFu&Dja}T&2oY*!9FK_`?tWgr;{|P*fIz88Fdwq!q6|^LMlz@4(s9CLo;l)_Qq# zG&t8bW6+P*AH(s6Mp&m0@YoOrDfWJ|^~m4eHU{g*X;0%**UTA~$ka=D*z@#W{O!?g zXn(&0fv}xzA5q$`2TN08%WvxN+VlJI@hhgI;>wvg|DwqVmxX$*)MQr*jfb1CZ}UMM z-2NusJ$M{ez(kWD`;l* zcjMtEbT)UxiKk!&OoWPqVBI^vlsW1eE)T;^xp?YF&*AVZ@8ByBMIcR?8wly&nK*3% z>;W3$oB$vqC{H0&78(+XM#h^kECbrpFl`g2Wk8+g^rJLn_UKWvGl|2mymKO}N&e?a zQy^Rx{*d55^m(3!VMy2^+yA#^WWAwWSy{@@Ek&p-hmGk{NeKu;prf%f%_TH6 zGN;bwE+o5BgC>s^|6fTom;u9*Xg=EN=Y41h^I_nFx0{h@Pe2%Q zBpL_t@PXsF{Dw>LiRH6Vc1{V*fSCoVy|EK-y>uAcAK#6m^@T~Ikx4PzAq$&-wH*z+ z4q?HKmty+7sVJE^E}O7(lP+41wd3#4@5j#dub}3R^2vUr+|}HTmUr9G-4=&T^E?#lS*^^12$ckJtf3Kqyyq!|OG7B1T#BOcVE{nO zyKQKHzXOTRBz z!!R2FK-AaQQ>-@T5mA%xVvPZS5x#0ku}&Ex&`+;zGQLZY02dH{=;*tJ584za z1ZXd6KZr*n5h<2WTdn~>LA|J0jZxT)s0tz$B*DzvVue4w;IY2>WVCKj*LOKxE)khyqRd70Xk{A#=_&UxX=5&~ z?r7_K%l3zS{;Jw46^%xPC;66ym)IRNLnedWF>wxF_@=a^X5H$+t&HvbPQTaLihdGB zj2N(X+p1QF)YYy^?G77784$*7!T{BdZ3HBM)}(T-{$btfJ=KwF8H;`RYV&@h-WXy! z8jXsz+g7!vNL`&$$>%~w*aSdh-qEQKUof)@n;}DM-H=qy)oXYBV0ZeCW_8jdN+%mV zFQmVF{<7Z6f0te=O5@I_)Qr^qhH<(VrT|w5R)k_VH=jv z%&#lB-;Qm+vufLW+Y-@eRNVH9+w^G<=?!G`L^8JE!4;bLyR5O)Hf%$)hNoRH7>czX z&6Yw45(EK57?334Y6qJ6?*#qR`qudGYwBZ8G#V9sy`3QgkWon)8vy{(g+HzUi>sKu z#Q^4Npu`kLG12Hus1ksbN+kfq3D^swtwP96*xI=>Ba_pqoFM^#?A`MbSzS}Ddm{YM zwRA384{lqz3 oGYHg2!-yg(up~QoovVxfe=}FWy#9FmmjD0&07*qoM6N<$f~PgGlK=n! literal 0 HcmV?d00001 diff --git a/WillTools.extension/WillTools.tab/Design.panel/bundle.yaml b/WillTools.extension/WillTools.tab/Design.panel/bundle.yaml new file mode 100644 index 0000000..6c5142c --- /dev/null +++ b/WillTools.extension/WillTools.tab/Design.panel/bundle.yaml @@ -0,0 +1,16 @@ +# The panel's name on the ribbon is the FOLDER name ("Design"). +# +# THIS PANEL IS THE QUARANTINE, and the pulldown is its door. The owner's +# standing instruction is that unfinished tools must never sit on the same +# panel as the polished ones; gravity-load chasing, lateral extraction, beam +# analysis and connection analysis are buttons-and-docs only until he +# provides worked examples, so they live one click down, behind DesignHub, +# on a panel of their own. Panel Analysis and Panel Lift Check share that +# same door for a different reason (2026-09-05 for Panel Lift Check) -- +# both actually run, but the owner wants them grouped with the tools that +# do not rather than back out on Tools.panel; see DesignHub's own bundle +# comment. +# +# Anything not listed here is not created. Add a button, add it here. +layout: + - DesignHub diff --git a/WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/bundle.yaml b/WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/bundle.yaml new file mode 100644 index 0000000..735a21f --- /dev/null +++ b/WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/bundle.yaml @@ -0,0 +1,13 @@ +title: GOD_MOD +# No tooltip and no author on purpose. pyRevit renders both on hover; +# the owner wants nothing there. Guarded by +# WillTools.extension/tests/test_button_tooltips.py -- do not add them back. +# +# Two lines, short ones. A large ribbon button is as wide as its widest line +# of text, so a long title makes one button dwarf its neighbours -- which is +# exactly what the owner objected to. Keep every line under ~9 characters. +# +# The name is the owner's, verbatim and with the underscore, from +# 2026-08-30: "我不喜欢 claude button. 还是叫 GOD_MOD." The folder is GodMod +# because a pyRevit component name is a C# class name in the generated +# assembly; the title is what anybody actually reads. diff --git a/WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/icon.png b/WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4b3a9450cd6e03ce5bb322c80cfe69008f64e628 GIT binary patch literal 1136 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1qucHLRX4jv*CsZ|CmK76}wN8XOfB^-Db>>Y4oo zHDj@+q{XcZoQ#w;Bpk0O3Fw6AxQGN^aB{qINXbdSk%e{f?%5O8I8Jw1#Oap2ufYA? z2JMgg{#Lv{+fsi2jpYYzCgbw*_cP0%on^1AtrcK#1fmJ8cCH8S1<8Dy{^&!2Dqnjw z>#x1N_wSys-t>0SGJm&&Yl~#`LLV8tEIk#tA*%22@23kB?B9p3n9w0?g69e(KR+oR5@x0w{qH_R8#uXz8z)9uKY*2Smn z@1ACc|Q1wq=Wm;J}x28PdqE`NNkhXr_+bX*oTK>wO>llw*`7p6>vyvXavgbBN-)Hmh=w+$=Ok@bW z;PL4=!=i?XPo^{a`Y5g3w{u?J`D~7Z{>&VgJ)$bg!dEIX)blF%D0RB=+>d9dlfLle z_l?IYE3a3c7ybB`<-vZ&Tj6KF?%K4T;g4y;r!xn?EbLtuw#-h$l;y#4#xEDrYWE$h zz7v+cRdQ*Yz#n4=>!L|2wVT|^!z+0meq&xOoB-zO6ycPX!)TvT^U?L33krrVm{PhRw`$%|SYI`x+1g!BW~ zXN&*L_e&MFj9!?2w6gR1xdt0%j# zR6Tw6CDCsgCN7Rs>nr-5*`H~|+JBsvJNw0>MS{L1kJqIy3}vs(SKw$;;1F83zjP^s VvC+X/WillTools.extension///
'] + for column in columns: + out.append('') + out.append('') + for row in rows: + out.append('') + for cell in row: + out.append('') + out.append('') + out.append('
' + _escape(str(column)) + '
' + _cell_html(cell) + '
') + return ''.join(out) + + +def _cell_html(value): + # Cells already carry the report's own entities (`—`, `†`) + # and its marks. They are escaped and the entities put back, so a mark + # containing an ampersand cannot break the row and a dagger still prints. + return _entities(_escape(str(value))) + + +# The report's whole markdown vocabulary, and nothing else: it is a fixed set +# written by one module, not arbitrary user text, so a 30-line converter is +# honest here where a general one would be a dependency. +_BOLD = re.compile(r'\*\*(.+?)\*\*') +_CODE = re.compile(r'`(.+?)`') +_ITALIC = re.compile(r'(? HTML, for the panel.""" + text_ = md or '' + if text_.strip() == '---': + return '
' + tag, body = 'p', text_ + if body.startswith('### '): + tag, body = 'h4', body[4:] + elif body.startswith('## '): + tag, body = 'h3', body[3:] + elif body.startswith('# '): + tag, body = 'h2', body[2:] + quoted = body.startswith('> ') + if quoted: + body = body[2:] + body = _escape(body) + body = _BOLD.sub(r'\1', body) + body = _CODE.sub(r'\1', body) + body = _ITALIC.sub(r'\1', body) + body = _entities(body) + if quoted: + return ('
' + '<' + tag + '>' + body + '
') + return '<' + tag + '>' + body + '' + + +def _escape(body): + return (body.replace('&', '&').replace('<', '<') + .replace('>', '>')) + + +def _entities(body): + """Put back the handful of entities the reports write on purpose. + + They are escaped along with everything else on the way in -- that is the + point of escaping -- and then restored by name here, so the reports keep + writing `—` (ASCII source, IronPython 2.7 safe) and the panel keeps + printing an em dash. + """ + for name in ('mdash', 'dagger', 'nbsp', 'ndash', 'hellip'): + body = body.replace('&' + name + ';', '&' + name + ';') + return body diff --git a/WillTools.extension/lib/vibe/helpers.py b/WillTools.extension/lib/vibe/helpers.py new file mode 100644 index 0000000..1a68afc --- /dev/null +++ b/WillTools.extension/lib/vibe/helpers.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +"""Small, boring helpers that remove the usual Revit API boilerplate. + +Written for IronPython 2.7 (pyRevit's default engine) but 3.x-safe: +no f-strings, no walrus. +""" +# pylint: disable=import-error,invalid-name,broad-except +from contextlib import contextmanager + +from Autodesk.Revit import DB, UI # noqa: F401 +from Autodesk.Revit.DB import ( + FilteredElementCollector, BuiltInCategory, BuiltInParameter, + ElementId, StorageType, Transaction, TransactionGroup, + UnitUtils, +) + +# UnitTypeId arrived in Revit 2021 and DisplayUnitType was removed in 2022, so +# the two never overlap for long. Resolving the unit handles once here keeps a +# host outside that window from failing the import of the whole library -- +# which would take every button down, not just the four conversions below. +try: + from Autodesk.Revit.DB import UnitTypeId + _MILLIMETERS = UnitTypeId.Millimeters + _METERS = UnitTypeId.Meters + _SQUARE_METERS = UnitTypeId.SquareMeters +except ImportError: # Revit 2020 and older + from Autodesk.Revit.DB import DisplayUnitType + _MILLIMETERS = DisplayUnitType.DUT_MILLIMETERS + _METERS = DisplayUnitType.DUT_METERS + _SQUARE_METERS = DisplayUnitType.DUT_SQUARE_METERS + +from pyrevit import revit, script, HOST_APP + + +# ------------------------------------------------------------ session handles +# +# revit.doc / revit.uidoc are LIVE @property accessors -- they always return +# whichever document is active RIGHT NOW. Assigning one to a module global +# snapshots its value ONCE, at the first import of `vibe`, and freezes it to +# whatever document happened to be active then. With two models open and the +# user switching between them that snapshot goes stale, and any helper that +# read it would silently act on the wrong model -- a transaction opened on the +# stale doc, a collector enumerating the stale doc's elements with no error. +# +# So the helpers below NEVER read these globals; they call _doc()/_uidoc()/ +# _uiapp(), which re-resolve on every call. The globals are kept only for +# backward compatibility: `vibe/__init__.py` re-exports them, and some callers +# do `from vibe.helpers import doc`. IronPython 2.7 has no module-level +# __getattr__ (that is Python 3.7+), so these names cannot be made to +# re-resolve on plain attribute access -- a caller that reads helpers.doc / +# vibe.doc still gets the frozen snapshot. Any such caller in a +# model-reading or transaction path must be repointed to _doc() (or read +# revit.doc directly) instead of these names. +uiapp = HOST_APP.uiapp +uidoc = revit.uidoc +doc = revit.doc + + +def _uiapp(): + """The live UIApplication, re-resolved on each call (never a snapshot).""" + return HOST_APP.uiapp + + +def _uidoc(): + """The active UIDocument, re-resolved on each call (never a snapshot).""" + return revit.uidoc + + +def _doc(): + """The active Document, re-resolved on each call (never a snapshot).""" + return revit.doc + + +# ----------------------------------------------------------------- collecting + +def _as_bic(category): + """Accept BuiltInCategory, 'OST_Walls', or 'Walls'.""" + if isinstance(category, BuiltInCategory): + return category + name = str(category) + if not name.startswith('OST_'): + name = 'OST_' + name.replace(' ', '') + bic = getattr(BuiltInCategory, name, None) + if bic is None: + raise ValueError('unknown BuiltInCategory: ' + str(category)) + return bic + + +def q(category=None, cls=None, view=None, types=False, document=None): + """One-liner element query. Returns a list. + + q('OST_Walls') -> all wall instances + q(cls=DB.Wall) -> same, by class + q('OST_Doors', view=doc.ActiveView)-> only what's visible in the view + q('OST_Walls', types=True) -> wall types + """ + d = document or _doc() + col = FilteredElementCollector(d, view.Id) if view is not None \ + else FilteredElementCollector(d) + if category is not None: + col = col.OfCategory(_as_bic(category)) + if cls is not None: + col = col.OfClass(cls) + col = col.WhereElementIsElementType() if types \ + else col.WhereElementIsNotElementType() + return list(col.ToElements()) + + +def all_of(category, document=None): + """All instances of a category.""" + return q(category, document=document) + + +def types_of(category, document=None): + """All element types of a category.""" + return q(category, types=True, document=document) + + +def by_name(elements, name): + """First element whose .Name matches, else None.""" + for el in elements: + try: + if el.Name == name: + return el + except Exception: + continue + return None + + +def selected(document=None): + """Currently selected elements as a list.""" + d = document or _doc() + return [d.GetElement(i) for i in _uidoc().Selection.GetElementIds()] + + +def pick(message='Pick elements', multiple=True): + """Interactive pick. Returns a list of elements (empty if cancelled).""" + from Autodesk.Revit.UI.Selection import ObjectType + _uidoc_ = _uidoc() + _doc_ = _doc() + try: + if multiple: + refs = _uidoc_.Selection.PickObjects(ObjectType.Element, message) + return [_doc_.GetElement(r) for r in refs] + ref = _uidoc_.Selection.PickObject(ObjectType.Element, message) + return [_doc_.GetElement(ref)] + except Exception: + return [] + + +# ---------------------------------------------------------------- parameters + +def _find_param(el, name): + """Resolve a parameter by BuiltInParameter, exact name, then case-insensitive.""" + if isinstance(name, BuiltInParameter): + return el.get_Parameter(name) + prm = el.LookupParameter(str(name)) + if prm is not None: + return prm + lowered = str(name).lower() + for candidate in el.Parameters: + if candidate.Definition.Name.lower() == lowered: + return candidate + return None + + +def p(el, name, default=None): + """Read a parameter value, typed. Falls back to `default` if missing.""" + prm = _find_param(el, name) + if prm is None: + return default + st = prm.StorageType + if st == StorageType.String: + return prm.AsString() + if st == StorageType.Integer: + return prm.AsInteger() + if st == StorageType.Double: + return prm.AsDouble() + if st == StorageType.ElementId: + return prm.AsElementId() + return default + + +def p_display(el, name, default=None): + """Read a parameter as the string Revit shows in the UI (with units).""" + prm = _find_param(el, name) + if prm is None: + return default + val = prm.AsValueString() + return val if val is not None else prm.AsString() + + +def set_p(el, name, value): + """Write a parameter. Must be called inside a transaction. + + Returns True on success, False if the parameter is missing or read-only. + """ + prm = _find_param(el, name) + if prm is None or prm.IsReadOnly: + return False + return bool(prm.Set(value)) + + +# -------------------------------------------------------------- transactions + +@contextmanager +def tx(name='vibe'): + """Transaction context manager - commits on success, rolls back on error. + + with tx('Rename walls'): + set_p(wall, 'Comments', 'checked') + """ + t = Transaction(_doc(), name) + t.Start() + try: + yield t + t.Commit() + except Exception: + if t.HasStarted() and not t.HasEnded(): + t.RollBack() + raise + + +@contextmanager +def txg(name='vibe group'): + """Transaction group - lets you undo several transactions as one step.""" + g = TransactionGroup(_doc(), name) + g.Start() + try: + yield g + g.Assimilate() + except Exception: + if g.HasStarted() and not g.HasEnded(): + g.RollBack() + raise + + +# --------------------------------------------------------------------- units +# Revit stores lengths in decimal FEET internally, always. + +def ft(millimeters): + """mm -> internal feet.""" + return UnitUtils.ConvertToInternalUnits(millimeters, _MILLIMETERS) + + +def mm(internal_feet): + """internal feet -> mm.""" + return UnitUtils.ConvertFromInternalUnits(internal_feet, _MILLIMETERS) + + +def m(internal_feet): + """internal feet -> meters.""" + return UnitUtils.ConvertFromInternalUnits(internal_feet, _METERS) + + +def sqft_to_sqm(internal_area): + """internal square feet -> square meters.""" + return UnitUtils.ConvertFromInternalUnits(internal_area, _SQUARE_METERS) + + +# -------------------------------------------------------------------- output + +def out(): + """The pyRevit output window for the running script.""" + return script.get_output() + + +def log(*args): + """print() that always lands in the pyRevit output window.""" + print(' '.join(str(a) for a in args)) + + +def table(rows, columns=None, title=None): + """Render a list of lists as a table in the pyRevit output window.""" + output = script.get_output() + if title: + output.print_md('### ' + title) + output.print_table(table_data=[[str(c) for c in r] for r in rows], + columns=columns or []) diff --git a/WillTools.extension/lib/vibe/joblocate.py b/WillTools.extension/lib/vibe/joblocate.py new file mode 100644 index 0000000..bc2f125 --- /dev/null +++ b/WillTools.extension/lib/vibe/joblocate.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""Which job's knowledge layer a HOST-SIDE tool should read, with no doc open. + +Inside Revit, `designspec.root_dir(doc)` finds a job's `_ticketqc` folder by +`doc.Title` -- WILLTOOLS_JOBS wins when it is set, APPDATA is the fallback for +a model with no path yet. That lookup needs a live document and cannot be +ported here: a terminal running `tools/snapshot_cycle.py` or +`tools/knowledge_drift.py` has no `doc.Title` to pick a folder by, and +guessing one from several candidates is a worse failure than asking, because a +wrong guess runs silently on the wrong job's settings and looks completely +normal -- the exact failure mode this whole codebase is built against +(see CLAUDE.md's "could not judge" discipline). + +THE RESOLUTION ORDER, deliberately, and in this order only: + + 1. an EXPLICIT file (a `--knowledge` flag, or an env var that names a + FILE) wins outright. The caller already knows which file; nothing + here second-guesses it, and a bad path is reported the ordinary way a + missing file is reported (by whoever tries to read it), not specially + here. + 2. WILLTOOLS_JOBS names a folder holding EXACTLY ONE job subfolder (one + that has a `knowledge.yaml` in it) -- unambiguous, so it is used with + no further input. + 3. a NAMED job (`--job "sample_job"`) picks one out of several by + name. + 4. otherwise: REFUSE, and name every candidate folder found. This is the + correct behaviour when several real job folders exist and none was + named -- not a hole to fill in later. Silently picking "the newest + one" or "the one that happens to set the key being asked for" is a + guess wearing the clothes of a rule, and the day it guesses wrong the + run looks completely normal while reading another job's tolerances. + +This is intentionally the only place this order is written down. Two tools +reading job knowledge off disk had two different half-answers to "where is +it" before this module existed -- `tools/snapshot_cycle.py` required an +explicit `--knowledge` and reported the resulting absence as if the setting +itself were missing, and `tools/knowledge_drift.py` walked a path hardcoded +to this checkout and ignored WILLTOOLS_JOBS entirely. Both import this now. + +`plantpath.py` is this file's sibling for the PLANT layer -- one FILE, chosen +by an env var that may name either a file or its folder. This is a different +shape (WILLTOOLS_JOBS names a folder that may hold several jobs, and there is +no `doc.Title` here to break the tie) so it earns its own module rather than +being bent to fit plantpath's shape, but the discipline is the same one: +never fall back to a plausible-looking wrong answer in silence, and say +which of "not configured", "not found" and "found but ambiguous" happened, +because they are different facts and read as different sentences. + +IronPython 2.7: no f-strings, no pathlib. (This module has no Revit import at +module scope -- or at all -- so it is also safe to load from host CPython the +same way `plantpath.py` is: `tools/snapshot_cycle.py` and +`tools/knowledge_drift.py` both do, via the `vibe` package stub documented in +`WillTools.extension/tests/conftest.py`.) +""" +# pylint: disable=invalid-name +import os + +JOBS_ENV = 'WILLTOOLS_JOBS' # same spelling as designspec.JOBS_ENV +KNOWLEDGE_FILENAME = 'knowledge.yaml' + + +class JobLocateResult(object): + """What `resolve_job_knowledge()` found, or precisely why it did not. + + `path` is set only on success -- callers should treat `bool(result)` (or + `result.path`) as the found/not-found test. `blocking` marks the + not-found cases that are a genuine misconfiguration worth stopping a run + over (an ambiguous WILLTOOLS_JOBS, a `--job` that names nothing, a + WILLTOOLS_JOBS that is not even a folder) as opposed to the ordinary + "nothing configured yet" case, which a caller may reasonably continue + past on DEFAULTS alone -- exactly as it always could when no `--knowledge` + was given. + """ + __slots__ = ('path', 'how', 'reason', 'candidates', 'root', 'blocking') + + def __init__(self, path=None, how=None, reason=None, candidates=None, + root=None, blocking=False): + self.path = path + self.how = how + self.reason = reason + self.candidates = list(candidates or []) + self.root = root + self.blocking = bool(blocking) + + def __bool__(self): + return self.path is not None + __nonzero__ = __bool__ # IronPython 2.7 + + def __repr__(self): + if self.path: + return 'JobLocateResult(path={0!r}, how={1!r})'.format( + self.path, self.how) + return 'JobLocateResult(reason={0!r})'.format(self.reason) + + +def job_candidates(jobs_root): + """Every subfolder of `jobs_root` that carries a `knowledge.yaml`. + + Sorted, so a refusal that lists them is stable across runs and a "single + unambiguous" check does not depend on directory-walk order. Nothing here + tries to tell a "real" job folder from a stray one (a leftover detach + copy, an old bootstrap) -- that would be exactly the judgement call this + module exists to refuse to make silently. Every folder with a + `knowledge.yaml` in it is a candidate; a human names the right one. + """ + out = [] + if not jobs_root or not os.path.isdir(jobs_root): + return out + for name in sorted(os.listdir(jobs_root)): + folder = os.path.join(jobs_root, name) + if not os.path.isdir(folder): + continue + candidate = os.path.join(folder, KNOWLEDGE_FILENAME) + if os.path.isfile(candidate): + out.append(candidate) + return out + + +def _job_name_of(knowledge_path): + return os.path.basename(os.path.dirname(knowledge_path)) + + +def resolve_job_knowledge(explicit=None, jobs_root=None, job_name=None, + env=None): + """Which job knowledge.yaml a host-side tool should read. A JobLocateResult. + + explicit a path stated outright -- a `--knowledge` flag, or an env var + that already names a FILE (`WILLTOOLS_JOB_KNOWLEDGE`, as + `tools/snapshot_cycle.py`'s argparse default already reads). + Wins over everything below. Not checked for existence here: + whoever reads the file reports a missing one the ordinary + way, and duplicating that check here would be a second, + possibly-drifted answer to "does this file exist". + jobs_root WILLTOOLS_JOBS, already resolved by the caller. Pass `None` + to have it read from `env` here instead. + job_name a folder name under `jobs_root`, stated explicitly (a `--job` + flag) to pick one out of several. + env `os.environ` by default; overridable so tests never touch the + real environment. + """ + env = os.environ if env is None else env + if explicit: + return JobLocateResult(path=explicit, how='explicit') + + root = jobs_root if jobs_root is not None else env.get(JOBS_ENV) + if not root: + return JobLocateResult( + reason='no job knowledge file was named (--knowledge) and {0} ' + 'is not set, so there is no folder to look for a job ' + 'under.'.format(JOBS_ENV)) + if not os.path.isdir(root): + return JobLocateResult( + root=root, blocking=True, + reason='{0} is set to {1}, but that is not a folder.' + .format(JOBS_ENV, root)) + + if job_name: + candidate = os.path.join(root, job_name, KNOWLEDGE_FILENAME) + if os.path.isfile(candidate): + return JobLocateResult(path=candidate, how='named', root=root) + candidates = job_candidates(root) + names = [_job_name_of(c) for c in candidates] + return JobLocateResult( + root=root, candidates=candidates, blocking=True, + reason='no job named {0!r} under {1} (looked for {2}). Job ' + 'folders found there: {3}' + .format(job_name, root, candidate, + ', '.join(names) or '(none)')) + + candidates = job_candidates(root) + if len(candidates) == 1: + return JobLocateResult(path=candidates[0], how='single', root=root) + if not candidates: + return JobLocateResult( + root=root, + reason='{0} is set to {1}, but no job folder under it has a ' + '{2}.'.format(JOBS_ENV, root, KNOWLEDGE_FILENAME)) + names = [_job_name_of(c) for c in candidates] + return JobLocateResult( + root=root, candidates=candidates, blocking=True, + reason='{0} is {1}, which holds {2} job folders ({3}) -- there is ' + 'no doc.Title here to pick one by, so nothing here guesses. ' + 'Say which with --job, or point --knowledge at the file ' + 'directly.'.format(JOBS_ENV, root, len(candidates), + ', '.join(names))) diff --git a/WillTools.extension/lib/vibe/jobrecon.py b/WillTools.extension/lib/vibe/jobrecon.py new file mode 100644 index 0000000..ad37d8b --- /dev/null +++ b/WillTools.extension/lib/vibe/jobrecon.py @@ -0,0 +1,1092 @@ +# -*- coding: utf-8 -*- +"""Job-level reconciliation: does the issued set of tickets cover the job? + +WHY THIS MODULE EXISTS. + +Everything else in this tool judges ONE ticket at a time. A per-ticket check +cannot see a missing ticket, because the missing ticket is not there to be +checked -- and cannot see a ticket issued for a piece that no longer exists, +because it never looks outside the ticket it was handed. The consultancy's +real exposure is that the SET is wrong. This module is the only thing here +that asks a question about the set. + +THE UNIT OF THE RECONCILIATION IS THE CONTROL NUMBER. + +A control number is one physical piece: one crew, one bed, one day. A mark is +a drawing, and several identical pieces share it. So: + + * the model side of the reconciliation is every control number carried by a + model element -- NOT every assembly. An assembly is a piece somebody has + already started drawing; a piece that nobody has assembled yet is still a + piece on the floor, and it is exactly the piece a ticket is most likely to + be missing for. Reconciling against assemblies alone reports a job clean + while most of it has never been looked at (measured on a real job: 755 + assemblies, 2480 control-numbered elements). + + * the ticket side is every control number PRINTED on a ticket, expanded -- + a title-block slot may hold one number, a comma list, or a RANGE + ('50120-50123' is four pieces). Reading a range as one opaque token + under-counts the ticket by three pieces and invents three orphans. + +WHAT THIS MODULE MUST NEVER DO. + + * Invent an exclusion. Nothing is filtered out for looking out of scope. + A family literally named '... (DO NOT USE)' is still reported, because + "this looked wrong to the tool so the tool dropped it" is the single + failure this project has already been bitten by -- one unauthorised + exclusion hid forty tickets and every check that ran came back clean. + + A DECLARED scope is the one exception, and it is not an exception to the + rule above -- it is the rule enforced. `reconcile(scope=...)` takes the + scope the JOB file declares (`vibe.scope`), sets aside the pieces and + ticket sheets outside it, and reports that in `out_of_scope`, which + `format_report` prints as its own section with the counts named by kind. + The tool never decides this for itself: no scope declared means every + piece is reconciled, and the same section says so. What is set aside is + stated in the report; what is dropped silently is the failure. + + * Report a clean set for a part of the job it could not see. Every result + carries `blind_spots`: the questions the inputs could not answer. A + reconciliation that cannot see part of the job says WHICH part. + + * Count without naming. '3 pieces unaccounted for' sends somebody hunting. + Every finding here carries the control numbers and marks individually. + +DATA CONTRACT (plain dicts -- no Revit anywhere in this file). + + piece {'control', 'mark', 'design', 'id', 'category', 'family', + 'in_assembly', 'ticket_created', 'ticket_detailed'} + `control` may be None or '' -- that is a finding, not a reason to + drop the piece. + + sheet {'id', 'number', 'name', 'design', 'controls' (list of raw + title-block slot strings, empties included), 'total_required', + 'sheet_count'} + +The Revit side that fills these in is `vibe.jobreconscan`, the same split +`ticketpage.py` / `ticketchecks.py` uses. + +THE REPORT FOLDS. `render()` returns `vibe.foldreport.Section`s: the verdict +in numbers as a headline that is never folded, every finding that has +anything in it as a section that opens ITSELF, and everything routine +collapsed to a header that still says what it holds. `format_report()` is the +same sections as plain text with nothing folded and nothing capped -- the +copy the button writes to disk, so a fold that will not open costs a file +open and never a sentence. The reasoning, section by section, is the comment +block above `render`. + +IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses. +""" +import re + +from vibe.compat import string_types +from vibe import foldreport as fold +from vibe import scope as scopelib + +# A sheet number that reads as a page of a ticket set: '1 OF 2', '2OF 2', +# '1 of 1'. Tolerant of the missing space, which real sheets have. +PAGE_RE = re.compile(r'^\s*(\d+)\s*OF\s*(\d+)\s*$', re.IGNORECASE) + +# One control number as printed. Kept deliberately loose -- a plant that +# letters its control numbers is not this plant's problem to reject. +_PLAIN_RE = re.compile(r'^[A-Za-z0-9_.]+$') +_RANGE_RE = re.compile(r'^([A-Za-z]*)(\d+)\s*-\s*([A-Za-z]*)(\d+)$') + +# Dashes a detailer's keyboard, autocorrect or a pasted PDF can produce, all +# meaning 'to'. They are normalised to a plain hyphen before the range is +# matched: an en dash in one slot must not turn four pieces into one +# unreadable token. +_DASHES = (u'‐', u'‑', u'‒', u'–', u'—', u'―', + u'−') + +# How far a printed range may run before it is treated as unreadable rather +# than expanded. A title-block slot holding '50120-50123' is four pieces; a +# slot holding '1-99999' is a typo, and materialising 99999 fake pieces would +# bury the real finding. 500 is far above any real casting run and far below +# anything that could be a mis-keyed year or dimension. +MAX_RANGE = 500 + +SEPARATORS = re.compile(r'[,;/]| and ', re.IGNORECASE) + + +def expand_control_field(value): + """One title-block control slot -> (controls, unreadable tokens). + + Handles the three forms real tickets use, and says so when it meets a + fourth rather than guessing: + + '20037' -> ['20037'] + '60011, 60018' -> ['60011', '60018'] + '50120-50123' -> ['50120', '50121', '50122', '50123'] + 'PL 1/4" X 28" X 4'-0"' -> [], ["PL 1/4\\" X 28\\" X 4'-0\\""] + + The last case is real: on this plant's non-ticket sheets the same + parameter slots carry a material list. A token that is not a control + number is RETURNED, never dropped -- a slot the tool cannot read is a + slot whose pieces it cannot vouch for. + """ + if value is None: + return [], [] + text = value.strip() if isinstance(value, string_types) else str(value) + if not text: + return [], [] + for dash in _DASHES: + if dash in text: + text = text.replace(dash, u'-') + controls, unreadable = [], [] + for part in SEPARATORS.split(text): + part = part.strip() + if not part: + continue + m = _RANGE_RE.match(part) + if m: + prefix_a, lo, prefix_b, hi = m.group(1), m.group(2), m.group(3), m.group(4) + width = len(lo) + lo_n, hi_n = int(lo), int(hi) + if prefix_a.upper() != prefix_b.upper() or hi_n < lo_n: + unreadable.append(part) + continue + if hi_n - lo_n > MAX_RANGE: + unreadable.append(part) + continue + for n in range(lo_n, hi_n + 1): + controls.append(prefix_a + str(n).zfill(width)) + continue + if _PLAIN_RE.match(part): + controls.append(part) + else: + unreadable.append(part) + return controls, unreadable + + +def sheet_controls(sheet): + """Every control number a sheet prints, plus what it could not read.""" + controls, unreadable = [], [] + for slot in (sheet.get('controls') or []): + got, bad = expand_control_field(slot) + controls.extend(got) + unreadable.extend(bad) + return controls, unreadable + + +def page_of(sheet): + """(page, of) from a sheet number, or (None, None).""" + m = PAGE_RE.match(sheet.get('number') or '') + if not m: + return None, None + return int(m.group(1)), int(m.group(2)) + + +def classify_sheet(sheet, known_marks): + """Is this sheet a page of a shop ticket? ('ticket'|'other', why) + + TWO SIGNALS, AND THE UNION OF THEM -- read this before narrowing it. + + A ticket page is recognised by (a) its NAME being a piece mark the model + carries, or (b) its sheet number reading as a page ('1 OF 2') AND its + title block carrying a design number. + + Requiring BOTH is the trap. A multi-page ticket's later sheets are + stripped of the title block -- no length, no weight, no f'c, no design + number -- so an and-gate silently drops them, and every finding that + depends on a page-2 disappears with them. Measured here: 28 real ticket + pages carry the mark but no design number. + + Requiring only (b) is the other half of the same trap: it drops the + stripped pages too. Only the union sees a whole ticket. + + A sheet reaching (a) alone or (b) alone is still a ticket, and the reason + string records which signal fired so a reader can see how sure the tool is. + """ + name = (sheet.get('name') or '').strip() + by_name = bool(name) and name in (known_marks or ()) + page, _total = page_of(sheet) + by_block = page is not None and bool(sheet.get('design')) + if by_name and by_block: + return 'ticket', 'named after a piece mark, with a page number and a design number' + if by_name: + return 'ticket', 'named after a piece mark (title block stripped or absent)' + if by_block: + return 'ticket', 'page number and design number, but its name is not a piece mark' + return 'other', 'neither a piece mark for a name nor a page-numbered title block' + + +def page_set_status(pages): + """Job-level version of the per-ticket page-set check. + + `pages` -- the (page, of) pairs of every sheet found for one mark. + Returns (status, message) where status is 'ok', 'unjudged' or 'broken'. + + 'unjudged' is not 'ok'. A set with no page markers at all has not been + checked; saying OK there is the exact bug this whole module exists to + catch. + """ + marked = [p for p in pages if p[0] is not None] + if not marked: + return 'unjudged', ('no sheet carries a page marker -- the ticket set ' + 'could not be judged') + totals = sorted(set(p[1] for p in marked)) + if len(totals) > 1: + return 'broken', ('pages disagree on how many there are: {0}' + .format(', '.join(str(t) for t in totals))) + total = totals[0] + have = sorted(p[0] for p in marked) + missing = [n for n in range(1, total + 1) if n not in have] + dupes = sorted(set(n for n in have if have.count(n) > 1)) + if missing and dupes: + return 'broken', ('page(s) {0} missing and page(s) {1} duplicated out ' + 'of {2}'.format(_join(missing), _join(dupes), total)) + if missing: + return 'broken', ('page(s) {0} missing out of {1}' + .format(_join(missing), total)) + if dupes: + return 'broken', ('page(s) {0} appear more than once'.format(_join(dupes))) + if len(marked) < len(pages): + return 'broken', ('{0} of {1} sheets carry no page marker' + .format(len(pages) - len(marked), len(pages))) + return 'ok', '{0}-page ticket'.format(total) + + +def _join(values): + return ', '.join(str(v) for v in values) + + +def _as_int(value): + if value is None: + return None + try: + return int(str(value).replace(',', '').strip()) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------- the recon + +def reconcile(pieces, sheets, sort_key=None, scope=None): + """Reconcile the model's pieces against the issued ticket set. + + pieces -- every control-numbered model element (see the data contract). + sheets -- every sheet in the document, ticket or not. Handing this + function only the sheets a caller already believes are tickets + would move the classification -- and its blind spots -- out of + the reconciliation, where nobody can see it. + scope -- a `vibe.scope.Scope`. Pieces and sheets outside the DECLARED + scope are set aside before anything is judged and reported + under `out_of_scope`. None, or an undeclared scope, reconciles + everything -- and still fills `out_of_scope` with the line + saying nobody declared one. + + Returns a dict; every list in it names its members individually. + """ + sort_key = sort_key or (lambda s: s) + if scope is None: + scope = scopelib.Scope() + + # ---- the declared scope, before anything is judged ------------------ + # + # A sheet is named for its mark, so the same predicate reaches both. A + # sheet with no readable name falls back to its design number, and one + # with neither stays IN -- 'could not tell' never removes work. + all_pieces, all_sheets = pieces, sheets + pieces, out_pieces = scope.split(pieces or [], + key=lambda p: (p.get('mark') or '')) + sheets, out_sheets = scope.split( + sheets or [], + key=lambda s: scopelib.as_mark(s.get('name') or s.get('design') or '')) + out_of_scope = { + 'declared': scope.is_declared, + 'label': scope.label(), + 'declaration': scope.declaration_lines(), + 'pieces': scope.tally([p.get('mark') for p in out_pieces]), + 'sheets': scope.tally([(s.get('name') or s.get('design')) + for s in out_sheets]), + 'piece_count': len(out_pieces), + 'sheet_count': len(out_sheets), + 'piece_line': scope.line( + [p.get('mark') for p in out_pieces], + total=len(all_pieces or []), unit='model element', + unreadable_names=[p.get('mark') for p in (all_pieces or []) + if scope.is_unreadable(p.get('mark') or '')]), + 'sheet_line': scope.line( + [(s.get('name') or s.get('design')) for s in out_sheets], + total=len(all_sheets or []), unit='sheet'), + } + known_marks = set() + for p in pieces: + mark = (p.get('mark') or '').strip() + if mark: + known_marks.add(mark) + + # ---- model side ---------------------------------------------------- + by_control = {} + no_control = [] + for p in pieces: + control = (p.get('control') or '').strip() + if not control: + no_control.append(p) + continue + by_control.setdefault(control, []).append(p) + + marks_of_control = {} + for control, group in by_control.items(): + marks_of_control[control] = sorted(set( + (g.get('mark') or '?').strip() for g in group)) + + model_marks = {} + for control, group in by_control.items(): + for mark in marks_of_control[control]: + model_marks.setdefault(mark, set()).add(control) + + # ---- ticket side --------------------------------------------------- + ticket_pages, other_sheets = [], [] + for s in sheets: + kind, why = classify_sheet(s, known_marks) + (ticket_pages if kind == 'ticket' else other_sheets).append((s, why)) + + issued = {} # control -> set of ticket names printing it + unreadable_slots = [] + pages_by_mark = {} + for s, _why in ticket_pages: + name = (s.get('name') or '').strip() or '(unnamed sheet)' + pages_by_mark.setdefault(name, []).append(s) + controls, bad = sheet_controls(s) + for token in bad: + unreadable_slots.append({ + 'sheet': name, 'page': s.get('number'), 'id': s.get('id'), + 'token': token, + }) + for c in controls: + issued.setdefault(c, set()).add(name) + + # A ticket page carrying no control number at all cannot vouch for + # anything -- and on a stripped page 2 that is CORRECT and expected, so + # it is reported as a fact about the set, not as a failure. + silent_pages = [] + for s, _why in ticket_pages: + controls, _bad = sheet_controls(s) + if not controls: + silent_pages.append({'sheet': (s.get('name') or '').strip(), + 'page': s.get('number'), 'id': s.get('id')}) + + model_controls = set(by_control) + issued_controls = set(issued) + + issued_by_mark = {} + for control, names in issued.items(): + for name in names: + issued_by_mark.setdefault(name, set()).add(control) + + # ---- 1. every piece accounted for, every ticket for a real piece ---- + # + # Two very different failures live in this one set, and merging them + # buries the worse one. A piece whose mark has NO ticket is work that + # visibly has not been drawn. A piece whose mark HAS a ticket that does + # not print its control number is work everyone believes is done: the + # ticket exists, the per-ticket checks pass on it, and the piece is still + # unaccounted for. `mark_has_ticket` separates them; the report leads + # with the second. + pieces_without_ticket = [] + for control in sorted(model_controls - issued_controls, key=sort_key): + group = by_control[control] + first = group[0] + pieces_without_ticket.append({ + 'control': control, + 'marks': marks_of_control[control], + 'design': first.get('design'), + 'ids': [g.get('id') for g in group], + 'ticket_created': first.get('ticket_created'), + 'ticket_detailed': first.get('ticket_detailed'), + 'in_assembly': any(g.get('in_assembly') for g in group), + 'mark_has_ticket': any(m in issued_by_mark + for m in marks_of_control[control]), + }) + + controls_without_piece = [] + for control in sorted(issued_controls - model_controls, key=sort_key): + controls_without_piece.append({ + 'control': control, + 'sheets': sorted(issued[control]), + }) + + # ---- 2. counts reconcile, per mark --------------------------------- + mark_count_mismatch = [] + for mark in sorted(set(issued_by_mark) | set(model_marks)): + printed = issued_by_mark.get(mark, set()) + modelled = model_marks.get(mark, set()) + if not printed: + continue # no ticket at all -- reported below + # Compared as SETS, not as counts. Two numbers that happen to be + # equal while naming different pieces is the quietest way for a + # count check to pass on a wrong ticket -- a ticket that prints one + # piece of this mark and one piece of another reconciles perfectly + # on arithmetic and is wrong on both pieces. + if printed == modelled: + continue + mark_count_mismatch.append({ + 'mark': mark, + 'issued': len(printed), + 'modelled': len(modelled), + 'issued_only': sorted(printed - modelled, key=sort_key), + 'modelled_only': sorted(modelled - printed, key=sort_key), + }) + + marks_without_ticket = [] + for mark in sorted(set(model_marks) - set(issued_by_mark)): + controls = sorted(model_marks[mark], key=sort_key) + marks_without_ticket.append({ + 'mark': mark, + 'pieces': len(controls), + 'controls': controls, + 'design': _first_design(by_control, controls), + }) + + # ---- 3. what the ticket itself claims ------------------------------ + quantity_disagreements = [] + for s, _why in ticket_pages: + stated = _as_int(s.get('total_required')) + if stated is None: + continue + controls, _bad = sheet_controls(s) + listed = len(set(controls)) + if listed and stated != listed: + quantity_disagreements.append({ + 'sheet': (s.get('name') or '').strip(), + 'page': s.get('number'), 'id': s.get('id'), + 'stated': stated, 'listed': listed, + }) + + design_disagreements = [] + for s, _why in ticket_pages: + printed = (s.get('design') or '').strip() + if not printed: + continue + controls, _bad = sheet_controls(s) + designs = set() + for c in controls: + for g in by_control.get(c, []): + d = (g.get('design') or '').strip() + if d: + designs.add(d) + if designs and printed not in designs: + design_disagreements.append({ + 'sheet': (s.get('name') or '').strip(), + 'page': s.get('number'), 'id': s.get('id'), + 'printed': printed, 'pieces_say': sorted(designs), + }) + + page_set_problems = [] + for mark in sorted(pages_by_mark): + pages = [page_of(s) for s in pages_by_mark[mark]] + status, message = page_set_status(pages) + if status != 'ok': + page_set_problems.append({ + 'mark': mark, 'status': status, 'message': message, + 'numbers': [s.get('number') for s in pages_by_mark[mark]], + }) + + stated_page_counts = [] + for mark in sorted(pages_by_mark): + group = pages_by_mark[mark] + stated = None + for s in group: + stated = _as_int(s.get('sheet_count')) + if stated is not None: + break + if stated is not None and stated != len(group): + stated_page_counts.append({ + 'mark': mark, 'stated': stated, 'found': len(group), + }) + + # ---- 4. the model's own claim about its tickets --------------------- + state_contradictions = [] + ticketed_marks = set(issued_by_mark) + for control in sorted(model_controls, key=sort_key): + group = by_control[control] + first = group[0] + created = _yes(first.get('ticket_created')) + covered = control in issued_controls + has_named_sheet = any(m in ticketed_marks + for m in marks_of_control[control]) + if created and not covered and has_named_sheet: + # The worst shape in the whole reconciliation: the ticket exists, + # the model says it was created, every per-ticket check passes on + # it -- and this piece is not printed on it. Nothing that reads + # one ticket at a time can see this. + state_contradictions.append({ + 'control': control, 'marks': marks_of_control[control], + 'design': first.get('design'), + 'problem': ('the model says its ticket was created, and the ' + 'ticket for its mark does not print this control ' + 'number'), + }) + elif created and not covered: + state_contradictions.append({ + 'control': control, 'marks': marks_of_control[control], + 'design': first.get('design'), + 'problem': 'the model says its ticket was created, and there is no ticket', + }) + elif covered and created is False: + state_contradictions.append({ + 'control': control, 'marks': marks_of_control[control], + 'design': first.get('design'), + 'problem': 'a ticket prints this piece, and the model says no ticket was created', + }) + + # ---- 5. one control number, several elements ------------------------ + shared_controls = [] + for control in sorted(model_controls, key=sort_key): + group = by_control[control] + if len(group) < 2: + continue + shared_controls.append({ + 'control': control, + 'marks': marks_of_control[control], + 'count': len(group), + 'ids': [g.get('id') for g in group], + 'families': sorted(set(g.get('family') or '?' for g in group)), + }) + + # ---- coverage, by design ------------------------------------------- + coverage = {} + for control, group in by_control.items(): + design = (group[0].get('design') or '(no design number)').strip() \ + or '(no design number)' + row = coverage.setdefault(design, {'pieces': 0, 'covered': 0, + 'uncovered': []}) + row['pieces'] += 1 + if control in issued_controls: + row['covered'] += 1 + else: + row['uncovered'].append(control) + for row in coverage.values(): + row['uncovered'].sort(key=sort_key) + + # ---- what could not be judged --------------------------------------- + blind = [] + if no_control: + blind.append('{0} model element(s) carry no control number and could ' + 'not be reconciled at all -- ids {1}' + .format(len(no_control), + _join([p.get('id') for p in no_control][:20]))) + if unreadable_slots: + blind.append('{0} title-block control slot(s) hold text this tool ' + 'cannot read as a control number -- the pieces behind ' + 'them are unaccounted for either way' + .format(len(unreadable_slots))) + weak = [s for s, why in ticket_pages if 'stripped' in why or 'not a piece mark' in why] + if weak: + blind.append('{0} ticket page(s) were recognised on one signal only ' + '(name, or title block, not both)'.format(len(weak))) + unjudged_sets = [p for p in page_set_problems if p['status'] == 'unjudged'] + if unjudged_sets: + blind.append('{0} ticket set(s) carry no page markers, so whether the ' + 'set is complete was not judged'.format(len(unjudged_sets))) + + return { + 'totals': { + 'elements': len(pieces), + 'pieces': len(model_controls), + 'pieces_covered': len(model_controls & issued_controls), + 'pieces_uncovered': len(model_controls - issued_controls), + 'controls_printed': len(issued_controls), + 'controls_without_piece': len(issued_controls - model_controls), + 'marks': len(model_marks), + 'marks_with_ticket': len(set(model_marks) & set(issued_by_mark)), + 'sheets': len(sheets), + 'ticket_pages': len(ticket_pages), + 'other_sheets': len(other_sheets), + 'ticket_sets': len(pages_by_mark), + }, + 'pieces_without_ticket': pieces_without_ticket, + 'controls_without_piece': controls_without_piece, + 'marks_without_ticket': marks_without_ticket, + 'mark_count_mismatch': mark_count_mismatch, + 'quantity_disagreements': quantity_disagreements, + 'design_disagreements': design_disagreements, + 'page_set_problems': page_set_problems, + 'stated_page_counts': stated_page_counts, + 'state_contradictions': state_contradictions, + 'shared_controls': shared_controls, + 'pieces_without_control': [ + {'id': p.get('id'), 'mark': p.get('mark'), + 'design': p.get('design'), 'family': p.get('family')} + for p in no_control], + 'unreadable_control_slots': unreadable_slots, + 'silent_ticket_pages': silent_pages, + 'coverage_by_design': coverage, + # What was LEFT ALONE, kept apart from what could not be judged. + # Both are printed; neither is a pass. + 'out_of_scope': out_of_scope, + 'blind_spots': blind, + } + + +def _yes(value): + """Tri-state read of a Yes/No parameter: True, False, or None for unset. + + None is not False. 'the model never said' and 'the model said no' are + different facts and a contradiction may only be raised on the second. + """ + if value is None: + return None + text = str(value).strip().upper() + if text in ('YES', 'TRUE', '1'): + return True + if text in ('NO', 'FALSE', '0'): + return False + return None + + +def _first_design(by_control, controls): + for c in controls: + for g in by_control.get(c, []): + if g.get('design'): + return g.get('design') + return None + + +# ------------------------------------------------------------------ report +# +# THE TAB-WIDE FOLD RULE, APPLIED HERE. Owner, 2026-08-30: the answer is the +# headline and is never folded; everything else follows it, collapsed; a +# section that carries a problem opens itself; a folded header still says +# what it holds. The reader's contract is that **nothing open below the +# headline means nothing needs him**. The rule itself, and why the HTML that +# implements it looks like 2003, live in `vibe.foldreport` -- not here, +# because it is not about reconciliation. +# +# WHAT THE ANSWER IS ON THIS PAGE, AND WHAT IT IS NOT. +# The verdict of a reconciliation is a set of NUMBERS: how many pieces are +# accounted for, how many printed control numbers have a piece behind them, +# and how many findings there are of each kind. It is NOT the findings +# themselves. On a job where four hundred pieces are uncovered the list runs +# to thousands of lines and the verdict is invisible inside it -- which is +# exactly the page this replaces. So the headline is the four totals plus one +# row per finding that has anything in it, and every finding's own list is a +# section underneath. +# +# WHAT COUNTS AS A PROBLEM HERE -- a non-empty finding, every one of them. +# Each names work somebody has to do or a contradiction somebody has to +# resolve; there is no finding in this module that is merely interesting. +# `findings()` is the single list they are all built from, so the counts in +# the headline and the sections below it cannot drift apart. +# +# A CHECK THAT FOUND NOTHING IS NOT AN EMPTY BLOCK. The old report printed +# thirteen headings and twelve 'none's, which is precisely the noise the +# owner asked to be rid of. It is not deleted either -- 'ran and found none' +# and 'never ran' must stay distinguishable -- so every empty finding is +# named, by name, in one folded line under CHECKS THAT RAN AND FOUND NOTHING. +# +# THE TWO SECTIONS THAT SAY WHAT THIS RUN COULD NOT SEE STAY SEPARATE, AND +# CARRY THEIR COUNTS ON THEIR HEADERS EVEN WHEN FOLDED. 'Not judged, out of +# scope' and 'could not be judged' are different states and neither is a +# pass; a fold that reduced either to the word 'details' would do exactly +# what this project has already been bitten by. So their summaries are the +# numbers themselves -- `OUT OF SCOPE: 1897 model element(s) -- BM 1056, ...` +# -- readable with the section shut. And a blind spot that is a REFUSAL +# (nothing was reconciled at all) is a problem: it opens itself, and it is +# said again in the headline, because a refusal reported quietly reads as a +# clean job. +# +# ASCII '--' IN A TITLE, A SUMMARY AND A PROBLEM. `foldreport` restores the +# reports' entities in those three as of 2026-08-30, so `—` would work +# now; this page keeps the plain dashes because `foldreport.md_text` leaves +# them alone and the panel renders them as themselves -- one string that is +# right in both outputs. The test below holds the page to it. + +def _piece_line(r): + return '{0} {1} {2}{3}'.format( + r['control'], '/'.join(r['marks']), r['design'] or '?', + ' [model says its ticket was created]' + if _yes(r['ticket_created']) else '') + + +def _control_line(r): + return '{0} printed on {1}'.format(r['control'], ', '.join(r['sheets'])) + + +def _mark_line(r): + return '{0} {1} piece(s) {2} ({3})'.format( + r['mark'], r['pieces'], r['design'] or '?', + ', '.join(r['controls'][:8])) + + +def _count_line(r): + return ('{0} ticket issues {1}, model has {2} ' + '(on the ticket only: {3}; in the model only: {4})'.format( + r['mark'], r['issued'], r['modelled'], + ', '.join(r['issued_only'][:6]) or '-', + ', '.join(r['modelled_only'][:6]) or '-')) + + +def _quantity_line(r): + return '{0} {1} says {2} required, prints {3}'.format( + r['sheet'], r['page'], r['stated'], r['listed']) + + +def _design_line(r): + return '{0} {1} prints {2}, its pieces are {3}'.format( + r['sheet'], r['page'], r['printed'], ', '.join(r['pieces_say'])) + + +def _page_set_line(r): + return '{0} [{1}] {2} ({3})'.format( + r['mark'], r['status'], r['message'], + ', '.join(str(n) for n in r['numbers'])) + + +def _page_count_line(r): + return '{0} states {1}, found {2}'.format(r['mark'], r['stated'], + r['found']) + + +def _contradiction_line(r): + return '{0} {1} {2}'.format(r['control'], '/'.join(r['marks']), + r['problem']) + + +def _shared_line(r): + return '{0} {1} {2} elements {3}'.format( + r['control'], '/'.join(r['marks']), r['count'], + ', '.join(r['families'])) + + +def _no_control_line(r): + return '{0} {1} {2}'.format(r['id'], r['mark'] or '?', + r['family'] or '?') + + +def _slot_line(r): + return '{0} {1} {2}'.format(r['sheet'], r['page'], r['token']) + + +def findings(recon): + """Every check that can find something, in the order it is reported. + + One list, six fields per row: key, title, the rows themselves, the phrase + saying what those rows ARE (used as the problem sentence when there are + any, and as the 'no ...' line when there are none), a renderer for one + row, and an optional lead paragraph. + + The headline's counts and the sections below it are both built from this, + which is the only arrangement in which they cannot disagree. + """ + without = recon.get('pieces_without_ticket') or [] + missing = [r for r in without if r.get('mark_has_ticket')] + never = [r for r in without if not r.get('mark_has_ticket')] + return [ + ('missing_from_ticket', 'PIECES MISSING FROM A TICKET THAT EXISTS', + missing, + 'piece(s) whose mark HAS a ticket that does not print them', + _piece_line, + 'The ticket exists and passes every per-ticket check. These pieces ' + 'are still not printed on it, so everybody believes they are ' + 'covered. Nothing that reads one ticket at a time can see this.'), + ('never_drawn', 'PIECES WITH NO TICKET FOR THEIR MARK AT ALL', + never, 'piece(s) whose mark was never drawn at all', _piece_line, + None), + ('orphan_controls', 'TICKET CONTROL NUMBERS WITH NO PIECE', + recon.get('controls_without_piece') or [], + 'control number(s) printed on a ticket with no piece behind them', + _control_line, None), + ('marks_without_ticket', 'MARKS WITH NO TICKET AT ALL', + recon.get('marks_without_ticket') or [], + 'mark(s) with no ticket anywhere in the set', _mark_line, None), + ('count_mismatch', 'COUNT DOES NOT RECONCILE', + recon.get('mark_count_mismatch') or [], + 'mark(s) where the pieces issued and the pieces modelled are not ' + 'the same pieces', _count_line, None), + ('quantity', 'TICKET QUANTITY vs CONTROL NUMBERS PRINTED', + recon.get('quantity_disagreements') or [], + 'ticket page(s) stating a quantity they do not print', + _quantity_line, None), + ('design', 'TICKET DESIGN NUMBER vs ITS PIECES', + recon.get('design_disagreements') or [], + 'ticket page(s) whose own pieces disagree with the design number ' + 'printed on them', _design_line, None), + ('page_sets', 'TICKET SET INCOMPLETE OR UNJUDGED', + recon.get('page_set_problems') or [], + 'ticket set(s) with a page missing, or with no page markers to be ' + 'judged by', _page_set_line, None), + ('stated_pages', 'SHEET COUNT THE TICKET STATES vs SHEETS FOUND', + recon.get('stated_page_counts') or [], + 'ticket set(s) whose stated page count is not how many pages are ' + 'there', _page_count_line, None), + ('contradictions', 'MODEL CONTRADICTS THE TICKET SET', + recon.get('state_contradictions') or [], + 'piece(s) where the model and the ticket set say opposite things', + _contradiction_line, None), + ('shared_controls', 'ONE CONTROL NUMBER, SEVERAL ELEMENTS', + recon.get('shared_controls') or [], + 'control number(s) carried by more than one element', _shared_line, + None), + ('no_control', 'ELEMENTS WITH NO CONTROL NUMBER', + recon.get('pieces_without_control') or [], + 'model element(s) that could not be reconciled at all', + _no_control_line, None), + ('unreadable_slots', 'CONTROL SLOTS THAT COULD NOT BE READ', + recon.get('unreadable_control_slots') or [], + 'title-block slot(s) holding text this tool cannot read as a ' + 'control number', _slot_line, None), + ] + + +def blind_refusal(recon): + """A sentence when this run reconciled NOTHING, or None when it ran. + + A refusal is not one blind spot among others and it is not a clean job: + it is the state in which every number on the page is meaningless. So it + opens its section and it is repeated in the headline. It is decided from + the totals as well as from the gatherer's own note, because a run can + reconcile nothing without anybody having written a note about it. + """ + for note in (recon.get('blind_spots') or []): + low = str(note).lower() + if 'nothing was reconciled' in low or 'is a refusal' in low: + # SHORT on purpose. The note itself is printed in full inside the + # blind-spot section, which this sentence forces open, and a + # header carrying two hundred characters is a header nobody + # reads. + return ('NOTHING WAS RECONCILED -- a refusal, not a clean job. ' + 'The blind spot below says which role is unmapped.') + totals = recon.get('totals') or {} + if not totals.get('pieces'): + return ('NOTHING WAS RECONCILED -- not one control-numbered piece ' + 'was found, so nothing on this page means the job is clean.') + if totals.get('sheets') and not totals.get('ticket_pages'): + return ('NO SHEET WAS RECOGNISED AS A TICKET -- {0} sheet(s) were ' + 'read and none of them classified as one, so every piece ' + 'below reads as unticketed for that reason alone.' + .format(totals.get('sheets'))) + return None + + +def _rows_blocks(rows, render_row, limit): + """One md block per row, and a line admitting the cap when it bites. + + A truncated list always says how many it did not print, so 'the rest' + never reads as 'there is no rest'. + """ + out = [(fold.MD, render_row(row)) for row in rows[:limit]] + if len(rows) > limit: + out.append((fold.MD, '... and {0} more (not printed)' + .format(len(rows) - limit))) + return out + + +def render(recon, limit=25, full_path=None): + """The reconciliation as `vibe.foldreport.Section`s, headline first. + + Pure: no Revit, no output panel, no file written. `limit` caps each named + list -- the cap belongs to the PAGE, which is read, never to the file, + which is searched. + + `full_path` is where the unfolded copy was written, if anywhere. It is + named on the page, so a folded section is never the only copy of a line. + """ + t = recon['totals'] + checks = findings(recon) + hit = [c for c in checks if c[2]] + clear = [c for c in checks if not c[2]] + refusal = blind_refusal(recon) + + # ---- the headline: the verdict, in numbers. Never folded. + head = fold.Section('headline', 'Answer', headline=True) + head.md('# JOB RECONCILIATION') + head.md('**model** — {0} control-numbered elements, {1} pieces' + .format(t['elements'], t['pieces'])) + head.md('**tickets** — {0} sheets, {1} ticket pages in {2} sets' + .format(t['sheets'], t['ticket_pages'], t['ticket_sets'])) + head.md('**coverage** — {0} of {1} pieces are printed on a ticket; ' + '{2} are not'.format(t['pieces_covered'], t['pieces'], + t['pieces_uncovered'])) + head.md('**marks** — {0} of {1} marks have a ticket' + .format(t['marks_with_ticket'], t['marks'])) + if refusal: + head.md('## ' + refusal) + if hit: + head.md('## {0} finding(s) need you'.format(len(hit))) + head.table(['What', 'How many'], + [[title, len(rows)] for _k, title, rows, _p, _r, _l in hit]) + head.md('Each is a section below, open. The other {0} check(s) ran ' + 'and found nothing.'.format(len(clear))) + elif not refusal: + head.md('## Nothing needs you') + head.md('All {0} checks ran and every one of them found nothing. ' + 'What this run did not judge, and what it could not judge, ' + 'are still below — folded, with their counts on the ' + 'header.'.format(len(checks))) + + out = [head] + + # ---- the findings. Non-empty is a problem, and a problem opens itself. + for key, title, rows, phrase, line, lead in hit: + section = fold.Section( + key, title, summary='{0} row(s)'.format(len(rows)), + problem='{0} {1}'.format(len(rows), phrase)) + if lead: + section.md(lead) + for block in _rows_blocks(rows, line, limit): + section.add(block) + out.append(section) + + # ---- coverage by design. A TABLE, not aligned text: `foldreport` folds + # runs of spaces inside a markdown block, so a column built out of them + # would collapse on the way to the panel. + coverage = recon.get('coverage_by_design') or {} + if coverage: + rows = [] + unticketed = 0 + for design in sorted(coverage): + row = coverage[design] + short = row['pieces'] - row['covered'] + unticketed += short + rows.append([design, row['pieces'], row['covered'], short or '']) + out.append(fold.Section( + 'coverage', 'COVERAGE BY DESIGN', + summary='{0} design(s), {1} piece(s) not on a ticket' + .format(len(coverage), unticketed), + blocks=[(fold.TABLE, + ['Design', 'Pieces', 'Ticketed', 'Not ticketed'], + rows)])) + + # ---- the ticket set, as inventory. A page printing no control number is + # a FACT about the set -- on a stripped continuation page it is correct + # -- so it is counted here and never reported as a failure. + silent = recon.get('silent_ticket_pages') or [] + inventory = fold.Section( + 'pageset', 'PAGE-SET INVENTORY', + summary='{0} ticket page(s) in {1} set(s); {2} print no control ' + 'number'.format(t['ticket_pages'], t['ticket_sets'], + len(silent))) + inventory.md('{0} of the {1} sheet(s) read classified as a ticket page; ' + '{2} did not.'.format(t['ticket_pages'], t['sheets'], + t['other_sheets'])) + if silent: + inventory.md('**Ticket pages printing no control number at all.** ' + 'Expected on a stripped continuation page; listed so ' + 'the set can be seen, not as a fault.') + for block in _rows_blocks( + silent, lambda r: '{0} {1}'.format(r['sheet'], r['page']), + limit): + inventory.add(block) + out.append(inventory) + + # ---- what was swept. The gatherer's inventory of categories, kept apart + # from its blind spots: 'I looked here' is not 'I could not look'. + searched = recon.get('searched') or [] + if searched: + out.append(fold.Section( + 'searched', 'CATEGORIES SEARCHED', + summary='{0} line(s) of what the sweep opened' + .format(len(searched)), + blocks=[(fold.MD, note) for note in searched])) + + # ---- the checks that found nothing. Folded is not gone, and a check + # that passed is not gone either: each is named, so 'found none' can + # never be mistaken for 'never ran'. + if clear: + section = fold.Section( + 'clear', 'CHECKS THAT RAN AND FOUND NOTHING', + summary='{0} of {1} checks{2}'.format( + len(clear), len(checks), + ' -- but nothing was reconciled, so this is not a pass' + if refusal else '')) + if refusal: + # A check finds nothing when there is nothing to check, too. On a + # refusal that is the only reason it found nothing, and a header + # counting twelve clean checks over an empty run is the exact + # shape of the bug this whole module exists to prevent. + section.md('**Nothing was reconciled on this run.** These checks ' + 'found nothing because there was nothing to check. It ' + 'is not a pass.') + for _k, title, _rows, _phrase, _r, _l in clear: + # 'ran, found none' rather than 'no ': + # half these titles are already negative and 'no marks with no + # ticket' is a sentence nobody parses on the first read. + section.md('**{0}** — ran, found none'.format(title)) + out.append(section) + + # ---- how long it took, and where the whole thing was written. + timing = recon.get('timing') or {} + if timing or full_path: + about = fold.Section( + 'run', 'ABOUT THIS RUN', + summary=('read the model in {0} s'.format(timing.get('total_s', + '?')) + if timing else 'where the full report was written')) + if timing: + about.md('Read the model in {0} s ({1} s pieces, {2} s sheets). ' + 'No geometry was built.' + .format(timing.get('total_s', '?'), + timing.get('pieces_s', '?'), + timing.get('sheets_s', '?'))) + if full_path: + about.md('The whole report, nothing folded and nothing capped, ' + 'was written to `{0}`.'.format(full_path)) + out.append(about) + + # ---- what was left alone, and then what could not be seen. Two states, + # two sections, never merged -- and both carry their counts on the header + # so a shut fold still says how much is behind it. + oos = recon.get('out_of_scope') or {} + if oos: + section = fold.Section( + 'out_of_scope', + 'WHAT THIS RUN DID NOT JUDGE -- OUTSIDE THE DECLARED SCOPE', + summary=_scope_summary(oos)) + section.md(oos.get('piece_line', '')) + if oos.get('declared'): + section.md(oos.get('sheet_line', '')) + for note in (oos.get('declaration') or []): + section.md(note) + out.append(section) + + blind = recon.get('blind_spots') or [] + spots = fold.Section( + 'blind_spots', 'WHAT THIS RUN COULD NOT JUDGE', + summary=('{0} blind spot(s)'.format(len(blind)) if blind + else 'none -- every piece and every sheet was read'), + problem=refusal) + if not blind: + spots.md('nothing -- every piece and every sheet was read') + for note in blind: + spots.md(note) + out.append(spots) + return out + + +def _scope_summary(oos): + """The out-of-scope header, with its counts on it. + + This one is read with the fold SHUT, so it carries the number and the + breakdown rather than the word 'details'. Not judged is not clean. + """ + if not oos.get('declared'): + return ('no scope declared -- every piece was judged, including work ' + 'this tool has never been validated against') + count = oos.get('piece_count') or 0 + if not count: + return 'none set aside -- nothing was removed from this run' + kinds = ', '.join('{0} {1}'.format(k, n) + for k, n in (oos.get('pieces') or [])) + sheets = oos.get('sheet_count') or 0 + return 'OUT OF SCOPE: {0} model element(s){1}{2}'.format( + count, ' -- ' + kinds if kinds else '', + '; {0} sheet(s)'.format(sheets) if sheets else '') + + +def format_report(recon, limit=None, full_path=None, show_all=True): + """The reconciliation as text lines. What a file and a terminal want. + + `show_all` prints every section, folded or not, which is what the copy + written for the record is FOR -- it is the reason nothing on the panel is + the only copy of anything. Pass False to read what the panel shows. + + `limit` caps each named list and defaults to no cap at all: the page has + a cap because a page is read; the file has none because it is searched. + """ + if limit is None: + limit = 10 ** 9 + sections = render(recon, limit=limit, full_path=full_path) + return fold.text(sections, show_all=show_all).split('\n') diff --git a/WillTools.extension/lib/vibe/jobreconscan.py b/WillTools.extension/lib/vibe/jobreconscan.py new file mode 100644 index 0000000..b9f38d8 --- /dev/null +++ b/WillTools.extension/lib/vibe/jobreconscan.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +"""The Revit half of the job-level reconciliation: gather, then hand over. + +`vibe.jobrecon` holds the reconciliation itself and knows nothing about +Revit, so it can be tested on host CPython. This module is the thin gatherer +that turns a live document into the plain dicts that module eats -- the same +split `ticketpage.py` / `ticketchecks.py` uses, for the same reason: the +arithmetic is where a wrong answer hides, and arithmetic must be testable +without a licence. + +NO OFFICE PARAMETER NAME APPEARS IN THIS FILE. Everything is read by ROLE +through `vibe.knowledge`, and a role nobody has mapped reads as a REFUSAL, +not as an empty job. That distinction is the whole point of this tool: a +reconciliation that cannot see the control numbers must say so, because +"found nothing" and "looked at nothing" are the same output otherwise, and +this project has already shipped that bug once. + +Roles this module needs, none of which the older checks used: + + member.control_number the piece's control number -- ONE PHYSICAL + PIECE. The unit of the whole reconciliation. + sheet.control_numbers the title-block slots holding the control + numbers a ticket covers. A list of parameter + names, or leave it unset and give + `nomenclature.control_slot_re` instead, a + regex matched against sheet parameter names -- + which is what a plant with numbered slots + (..._01 .. _30) wants. + sheet.total_required the quantity the title block states. + sheet.sheet_count how many pages the ticket says it has. + member.ticket_created the model's own claim that a ticket exists. + member.ticket_detailed ... and that it was detailed. Both optional. + +Already-mapped roles it reuses: `member.mark`, `panel.design_number`, +`sheet.design_number`. + +Cost, measured on a 2480-piece job: the piece sweep is about 6 s across six +categories and the sheet sweep about 1 s across 1105 sheets -- no geometry is +built anywhere, which is what keeps it in seconds rather than the 10 s a +690-piece geometry pass costs. + +IronPython 2.7: no f-strings, no pathlib, no typing. +""" +# pylint: disable=import-error,invalid-name,broad-except +import re +import time + +from Autodesk.Revit.DB import ( + FilteredElementCollector, BuiltInCategory, AssemblyInstance, ViewSheet, +) + +from vibe import jobrecon + +# Where precast pieces can live. Not a filter on what counts as a piece -- +# a piece is anything carrying a control number -- but the set of collectors +# the sweep opens. Every category here is reported with its tally, INCLUDING +# the zeros, so a reader can see what was looked at; categories outside this +# list are named in the result's blind spots rather than silently omitted. +# +# Never widen this to an unfiltered collector. A model-wide +# WhereElementIsNotElementType() walk killed Revit here on 2026-08-18. +PIECE_CATEGORIES = ( + 'OST_StructuralFraming', + 'OST_StructuralColumns', + 'OST_Walls', + 'OST_Floors', + 'OST_StructuralFoundation', + 'OST_GenericModel', +) + + +# WHAT WAS LOOKED AT IS NOT A BLIND SPOT. `gather` returns ONE list of notes +# -- everything it wants said about the run -- and two different kinds live in +# it: 'I could not see X', which is a blind spot, and 'I opened these six +# category collectors and here is the tally', which is an inventory. The +# report folds the second and opens the first, so `run` has to tell them +# apart, and these two constants are how: the inventory notes are written +# with them three lines below and recognised by them in `is_inventory_note`. +# +# `gather`'s signature is deliberately NOT changed to return them separately. +# `vibe.takeoffgather` calls `gather` too and collects the same notes; a +# different tuple there would be a change to another button's path for the +# sake of this one's formatting. +INVENTORY_LEAD = 'categories searched:' +INVENTORY_INDENT = ' ' + + +def is_inventory_note(note): + """True for a note that is an INVENTORY of what was swept, not a blind + spot. See the constants above.""" + text = str(note or '') + return text.startswith(INVENTORY_LEAD) or text.startswith(INVENTORY_INDENT) + + +def _eid(el): + try: + return el.Id.IntegerValue + except Exception: + return el.Id.Value + + +def _param_text(param): + """A parameter as text, whatever it is stored as.""" + if param is None: + return None + try: + value = param.AsString() + except Exception: + value = None + if value in (None, ''): + try: + value = param.AsValueString() + except Exception: + value = None + return value + + +def _read(el, names): + """First non-empty value across candidate parameter names.""" + for name in names or (): + value = _param_text(el.LookupParameter(name)) + if value not in (None, ''): + return value + return None + + +def _roles(know, overrides=None, _doc_for_knowledge=None): + """Resolve every role this module reads, once. + + `overrides` -- {role: name or [names]}, for a caller that knows the + template but whose knowledge file has not been taught these roles yet. + Explicit and visible beats a hardcoded office name in the repo. + """ + from vibe import knowledge as K + from vibe.compat import string_types + overrides = overrides or {} + # `know=None` used to mean "nothing is mapped", which made a caller who + # simply forgot the argument indistinguishable from an unconfigured + # plant: every role resolved empty and the whole reconciliation refused. + # It refused LOUDLY, which is the only reason this was survivable. Every + # other module in vibe loads knowledge itself when it is handed none, and + # so does this one now. A genuine refusal still happens -- it just has to + # be because the file really does not map the role. + if know is None and _doc_for_knowledge is not None: + try: + know = K.load(_doc_for_knowledge) + except Exception: + know = None + out = {} + for role in ('member.control_number', 'member.mark', 'member.ticket_created', + 'member.ticket_detailed', 'panel.design_number', + 'sheet.design_number', 'sheet.control_numbers', + 'sheet.total_required', 'sheet.sheet_count'): + if role in overrides: + value = overrides[role] + out[role] = ([value] if isinstance(value, string_types) + else list(value)) + elif know is not None: + out[role] = K.param_names(know, role) + else: + out[role] = [] + slot_re = overrides.get('control_slot_re') + if slot_re is None and know is not None: + slot_re = K.get(know, 'nomenclature.control_slot_re', None) + out['control_slot_re'] = slot_re + return out + + +def gather(doc, know=None, overrides=None, categories=None): + """Read the document. Returns (pieces, sheets, notes, timing). + + `notes` collects everything the gather itself could not do -- an unmapped + role, a category that could not be collected. It becomes part of the + reconciliation's blind spots; it is never swallowed. + """ + roles = _roles(know, overrides, _doc_for_knowledge=doc) + notes = [] + t0 = time.time() + + ctrl_names = roles['member.control_number'] + if not ctrl_names: + notes.append('the role member.control_number is not mapped -- no ' + 'piece could be identified, so NOTHING was reconciled. ' + 'Map it in the plant knowledge file.') + slot_names = roles['sheet.control_numbers'] + slot_re = roles['control_slot_re'] + if not slot_names and not slot_re: + notes.append('neither the role sheet.control_numbers nor ' + 'nomenclature.control_slot_re is set -- no ticket could ' + 'be read, so every piece will read as unticketed. This ' + 'is a refusal, not a finding.') + + # ---- which elements are already inside an assembly ------------------ + in_assembly = {} + for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance) + .WhereElementIsNotElementType().ToElements()): + name = asm.AssemblyTypeName + for mid in asm.GetMemberIds(): + member = doc.GetElement(mid) + if member is not None: + in_assembly[_eid(member)] = name + + # ---- the pieces ----------------------------------------------------- + pieces = [] + per_category = {} + for cat_name in (categories or PIECE_CATEGORIES): + bic = getattr(BuiltInCategory, cat_name, None) + if bic is None: + notes.append('this Revit build has no category {0}; it was not ' + 'searched'.format(cat_name)) + continue + try: + elements = (FilteredElementCollector(doc).OfCategory(bic) + .WhereElementIsNotElementType().ToElements()) + except Exception as exc: + notes.append('could not collect {0}: {1}'.format(cat_name, exc)) + continue + found = 0 + for el in elements: + control = _read(el, ctrl_names) + if control in (None, ''): + continue + found += 1 + family = None + try: + family = el.Symbol.Family.Name + except Exception: + try: + family = el.Name + except Exception: + family = None + pieces.append({ + 'id': _eid(el), + 'category': cat_name, + 'control': control, + 'mark': _read(el, roles['member.mark']), + 'design': _read(el, roles['panel.design_number']), + 'family': family, + 'in_assembly': in_assembly.get(_eid(el)), + 'ticket_created': _read(el, roles['member.ticket_created']), + 'ticket_detailed': _read(el, roles['member.ticket_detailed']), + }) + per_category[cat_name] = {'elements': len(elements), 'pieces': found} + t1 = time.time() + + # ---- the sheets ----------------------------------------------------- + # + # One pass over each sheet's own Parameters, not thirty LookupParameter + # calls per sheet: with numbered slots that is the difference between + # about a second and about ten across a 1105-sheet job, and the slots are + # discovered rather than assumed to be contiguous. + slot_pattern = re.compile(slot_re, re.IGNORECASE) if slot_re else None + sheets = [] + for sheet in FilteredElementCollector(doc).OfClass(ViewSheet).ToElements(): + slots = {} + if slot_pattern is not None: + for param in sheet.Parameters: + try: + name = param.Definition.Name + except Exception: + continue + if slot_pattern.match(name): + slots[name] = _param_text(param) + controls = [slots[k] for k in sorted(slots)] + for name in slot_names: + controls.append(_param_text(sheet.LookupParameter(name))) + sheets.append({ + 'id': _eid(sheet), + 'number': sheet.SheetNumber, + 'name': sheet.Name, + 'design': _read(sheet, roles['sheet.design_number']), + 'controls': controls, + 'total_required': _read(sheet, roles['sheet.total_required']), + 'sheet_count': _read(sheet, roles['sheet.sheet_count']), + }) + t2 = time.time() + + searched = set(categories or PIECE_CATEGORIES) + notes.append(INVENTORY_LEAD + ' {0}. A precast piece modelled in any ' + 'other category was not seen by this run.' + .format(', '.join(sorted(searched)))) + for cat_name in sorted(per_category): + row = per_category[cat_name] + notes.append(INVENTORY_INDENT + + '{0}: {1} element(s), {2} carrying a control number' + .format(cat_name, row['elements'], row['pieces'])) + + timing = {'pieces_s': round(t1 - t0, 2), 'sheets_s': round(t2 - t1, 2), + 'total_s': round(t2 - t0, 2)} + return pieces, sheets, notes, timing + + +def _numeric_sort(value): + """Sort control numbers the way a person reads them: 9 before 10.""" + text = str(value) + parts = re.split(r'(\d+)', text) + key = [] + for part in parts: + if part.isdigit(): + key.append((1, int(part), '')) + elif part: + key.append((0, 0, part)) + return key + + +def run(doc, know=None, overrides=None, categories=None, scope=None): + """Gather and reconcile. Returns the jobrecon result, plus timings. + + The DECLARED SCOPE is read from the job knowledge here and handed to + `reconcile`, which sets aside what is outside it and reports that. The + gather itself is deliberately unscoped: it reads the whole job, so the + count of what was left alone is a measured number rather than an + estimate of what was never looked at. + """ + pieces, sheets, notes, timing = gather(doc, know=know, + overrides=overrides, + categories=categories) + if scope is None: + from vibe import scope as scopelib + if know is None: + from vibe import knowledge as K + know = K.load(doc) + scope = scopelib.of(know) + recon = jobrecon.reconcile(pieces, sheets, sort_key=_numeric_sort, + scope=scope) + # Split, not merged: what the sweep opened folds away as routine, what it + # could not see opens itself. Both are printed either way. + recon['blind_spots'] = ([n for n in notes if not is_inventory_note(n)] + + recon['blind_spots']) + recon['searched'] = [n for n in notes if is_inventory_note(n)] + recon['timing'] = timing + return recon diff --git a/WillTools.extension/lib/vibe/knowledge.py b/WillTools.extension/lib/vibe/knowledge.py new file mode 100644 index 0000000..c77d6de --- /dev/null +++ b/WillTools.extension/lib/vibe/knowledge.py @@ -0,0 +1,8693 @@ +# -*- coding: utf-8 -*- +"""Hand-written checking knowledge that outlives any one project. + +The design spec says what *this* design calls for. The knowledge file says +what the shop knows: which families count as strand, what a lifter is rated +for, how close to an edge it may sit, what tolerance a dimension gets. It is +YAML so it can carry comments, and it is meant to be edited by hand and grown +over time. + +Everything here has a built-in default, so the checker runs before the file +exists. A value in the file always wins. + +WHICH LAYER WINS, AND WHAT THAT COSTS +------------------------------------- + +Job beats plant beats what everybody in the trade knows. Will 2026-08-30, +and it is not a tie-break rule -- it says where truth lives. The trade knows +that a bar mark is a recipe. The plant knows its own hardware. The job knows +what was actually drawn, and the job is the thing being built, so it wins. + +Whether P89 carries an attached bar is the example that produced the rule: it +depends on where the P89 sits. The manual describes a slotted insert with no +bar; a particular job may weld bars to it. Neither is wrong. The manual is +right about the part and the job is right about this piece. + +**A job entry that contradicts a higher layer needs permission to do it.** +Precedence and permission are different things, and only the first is +implemented. `_deep_update` below simply assigns -- the later layer replaces +the earlier one and nothing anywhere records that a plant rule was set aside. +Which means an override and a typo are indistinguishable: both parse, both +take effect, and neither says a word. + +That is the same failure this whole toolchain keeps meeting. A check that +cannot run reports OK; an exclusion removes findings and leaves a clean page; +a job value quietly displaces a plant one. Every time, the wrong outcome +looks exactly like the right one. + +So an override should have to declare itself -- carry a note saying what it +sets aside and why -- and the tool should say, in its output, that one is in +force. Not refuse it. Just never let it happen silently. + +Two more things that are true of the merge today and worth knowing before +relying on it: + +* **Lists replace, they do not merge.** A job file naming two families in + `interference.exempt_families` removes the plant's five. That is usually + not what the author meant. +* **The job file's path follows the document, not the model name.** Once a + model has been saved, the job knowledge is read from `_ticketqc` beside the + .rvt, and the copy under %APPDATA% -- which is where it lives for an unsaved + document -- stops being read at all. Measured 2026-08-30: a batch of job + edits was inert for exactly this reason and looked, from the outside, like + the edits had simply not worked. + +**A LAYER THAT FAILS TO LOAD LOOKS EXACTLY LIKE A LAYER THAT AGREES WITH THE +DEFAULTS.** Measured 2026-09-01: one duplicate key nested in one mapping of a +plant file -- pyRevit's reader is strict and raises on it -- silently threw +away that whole layer, and every report still rendered. So a file that EXISTS +and will not parse is skipped (a half-parsed layer must never merge) and +recorded: `_catalogue_unreadable`, `_plant_unreadable`, `_job_unreadable`, +`_config_unreadable`, each `(path, parser message)`, read back by +`unreadable_layers()` and turned into a refusal by `config_problems()`. Same +discipline as `_plant_missing` / `_config_missing`, and for the same reason: +whether a file could be READ is a fact about the moment it was opened, and by +the time anyone looks at the tree the only evidence left is values that are +missing -- which is indistinguishable from a layer that never set them. +`python tools/knowledgelint.py` is the host-side gate that catches it before a +button ever sees it. + +Computed checks -- the ones that need arithmetic rather than a comparison -- +are registered in ticketchecks.py and read their numbers from here. Adding a +new lifter model is a YAML edit; adding a new *kind* of calculation is a small +Python function. + +IronPython 2.7: no f-strings, no pathlib. +""" +# pylint: disable=import-error,invalid-name,broad-except +import fnmatch +import os +import io +import re + +from vibe.compat import string_types, as_text + +# `designspec` is imported inside the functions that need it, not here. It +# pulls in the Revit API at module scope (evidence scraping needs the live +# document), and this module has real logic -- _deep_update, _merge_decisions, +# _coerce, the family/lifter lookups -- that is worth testing on plain +# CPython with no Revit anywhere. A module-level import here would make the +# whole file un-importable off a Revit host, for the sake of four functions +# that only need it to find files on disk. + + +DEFAULTS = { + # ---- where this job is being made --------------------------------- + # + # WHICH PLANT produces this job. A JOB fact -- the same tool, the same + # plant knowledge folder, but this job is poured at one of them -- so it + # is declared in `/_ticketqc/knowledge.yaml` and NULL here. + # + # It is also the key that CHOOSES THE PLANT LAYER: load() reads it out of + # the job file before merging anything, and resolves the plant file to + # `//knowledge.yaml`. See the two-pass note in load(). + # + # Null means NO PLANT NAMED, which is not an error and is deliberately + # quiet: the machine-wide plant file (`/knowledge.yaml`) is read, + # exactly as it always was, and that is how every install that predates + # this key is set up. Naming a plant that has no folder IS reported -- + # `_plant_missing`, and `config_problems` says so in a sentence -- because + # then somebody asked for a specific plant and did not get it. + 'job': { + 'plant': None, # e.g. 'northgate' -- one folder name + }, + + # ---- whose work this is ------------------------------------------- + # + # The DECLARED SCOPE: which pieces of the job this engineer is answerable + # for. Several engineers work one job, and the knowledge below was + # validated against one discipline's pieces -- so a tool that judges all + # of them is issuing verdicts it has no basis for. + # + # It is a JOB fact (who is responsible for what, on this job), so it is + # declared in `/_ticketqc/knowledge.yaml` and EMPTY here. Empty + # means NO SCOPE DECLARED, which means judge everything and say so on + # every report. It never means judge nothing, and no prefix is ever + # written into this file -- an exclusion the code chose for itself is the + # one failure this project has already paid for. + # + # See `vibe/scope.py` for the predicate and why it is the mark prefix. + 'scope': { + 'mark_prefixes': [], # e.g. [PNL] -- the letters a mark starts with + 'declared_by': None, # who said so, so it can be argued with + 'note': None, # one sentence, printed with the scope + }, + + # ---- which producer's drawing habits the extractor may recognise ---- + # + # A PLANT fact: how an office titles its details and words its standing + # instructions. Empty here and empty is the honest default -- a machine + # told nothing about an office's habits should claim to recognise + # nothing, and every note the generic reader cannot place goes on landing + # in the spec's `unresolved` list under the generic reason. + # + # Declared, never inferred. `plant.id` exists and would be enough to + # guess with; a guess that is right today goes wrong silently the first + # time a job is drawn to another producer's standard on this plant's + # template. A name this build does not ship is refused loudly and NO pack + # loads -- a half-loaded rule set classifies some notes and not others, + # and the reader cannot tell which sentences to trust. + # + # A pack may only move a note from `unresolved` into `notes`, or leave it + # in `unresolved` with a better sentence beside it. Nothing it can say + # makes a line disappear. See `vibe/extractrules/__init__.py`. + 'extract': { + 'rule_packs': [], # e.g. [mc] -- see vibe/extractrules + }, + + # ---- checks this job has DECLARED it does not want asked ----------- + # + # Owner, 2026-08-30: "让我可以注释掉一些check。比如说这个项目里面3.3我就 + # 想注释掉。因为所有的panel都不会有这个,一直看着NOT_RUN我也糟心。" + # + # A job where no piece can ever carry what item 3.3 asks about prints + # NOT RUN on 3.3 for every piece, forever. A permanently red row is not + # a warning; it is training the reader to skip red rows, and that costs + # more than the row is worth. + # + # So: `muted` is a MAP from ticket-checklist number to the REASON it is + # muted on this job. A map, not a list, because the layers deep-merge + # (`_deep_update`) -- a job can mute one more item without restating the + # ones the plant layer already muted. The reason is MANDATORY + # (`configmodel.validate('checks.muted', ...)` refuses an empty one): a + # row that disappeared for a reason nobody wrote down cannot be told + # apart from a bug. + # + # EMPTY HERE, AND EMPTY IS THE ONLY HONEST DEFAULT. Nothing ships muted. + # A muted item STAYS on the checklist table, repainted grey as MUTED + # (see `ticketpage.apply_muting`), with the note saying what the check + # really reported -- the check still runs every time, only the + # presentation changes. It can never soften a FAIL, which keeps its own + # red and its own word, nor stop a finding reaching the machine-readable + # JSON. MUTED has its own tally bucket, separate from clean/failing/ + # warning. + # ---- checks that DO NOT APPLY to this kind of piece ---------------- + # + # Owner, 2026-08-31: "ticketqc 检查的内容需要进行分级。根据 plant 和 + # ticket 的具体内容进行分级 ... 所以把 ticketqc rules 写进厂知识, + # 让 ticketqc 工具读取厂知识来得知啥需要检查,啥不用检查。" + # + # THIS IS A FIFTH STATE AND IT IS NOT ANY OF THE FOUR ALREADY HERE. + # Getting the difference wrong is how one of them ends up doing a job it + # was not built for, quietly: + # + # `checks.muted` the check RUNS and the row is repainted grey. "I + # know, stop telling me" -- a PRESENTATION act, per + # JOB, and a FAIL is never softened. + # scope.mark_prefixes a whole PIECE somebody else is responsible for. + # Per piece, never per row, and the piece is not + # checked at all. + # a DORMANT check the knowledge that would enable it is unset, so it + # stands down with a reason. INVOLUNTARY: it is + # resolved by supplying a fact, not by a ruling. + # grades A-D WHO answers the question (code or a person), never + # WHETHER the question applies. + # `checks.not_applicable` the question DOES NOT APPLY to this kind of + # piece at this plant. VOLUNTARY and STATED (unlike + # dormancy), PLANT-level and conditional on the piece + # (unlike a job mute), and the check DOES NOT RUN + # (unlike a mute). + # + # THE SHAPE, and every level of it is a MAP on purpose: + # + # checks: + # not_applicable: + # '': # e.g. '3.3' + # '': + # applies: false # false -> the row is not asked + # not_applicable_because: # REQUIRED when false + # not_applicable_since: 'YYYY-MM-DD' # REQUIRED when false + # applicable_because: # REQUIRED when true + # applicable_since: 'YYYY-MM-DD' # REQUIRED when true + # declared_by: + # + # WHY THERE IS NOT A LIST ANYWHERE IN IT. Lists REPLACE across the + # knowledge layers and maps deep-merge (section 2.3). A list of row + # numbers in the job file would DELETE the plant's whole table the moment + # a job wanted to add one entry -- the rebar stock-length trap, on a + # safety switch this time. With maps all the way down, a job NARROWS one + # (row, panel type) pair by writing `applies: true` under exactly that + # pair, and EXTENDS by adding a pair, and in both cases every other entry + # the plant declared survives untouched. The wipe is not guarded against; + # it is unspellable. + # + # AND WHY THE REASON KEY IS NAMED FOR THE VALUE IT JUSTIFIES. After the + # merge nothing can tell which layer wrote which key, so a single + # `because:` would let a job flip `applies: true` and silently inherit the + # plant's sentence for the opposite ruling -- a reason that now reads + # backwards, over a re-enabled check. Turning a row back on needs + # `applicable_because`, which the plant's entry does not carry, so the + # narrowing states its own case or is refused. + # + # `any` IS THE WHOLE-PLANT AXIS. It matches whatever the piece is, + # including a piece nothing could classify -- that is a plant asserting a + # fact about every piece it makes ("we do not do that here"). A key that + # is a panel type id is the PIECE axis and needs a classification: while + # nothing can classify a piece, a type-conditioned entry is REFUSED and + # reported, and the row runs. Skipping a check that may not apply is + # noise; running one that does not apply is noise too -- but skipping one + # that DOES apply is a silent pass, and those are not symmetric. + # + # EMPTY HERE, AND EMPTY IS THE ONLY HONEST DEFAULT. Nothing ships + # switched off. A row switched off renders as its own state, + # `ticketpage.NOT_APPLICABLE_STATE`, in its own colour, with the reason + # and the layer that declared it; and a row that would have FAILED keeps + # its FAIL -- the declaration is refused and says so, exactly as a mute + # over a failure is. + # + # A NEW QUESTION IS STILL A CODE CHANGE. A plant declares applicability + # OVER the rows in `ticketpage.CHECKLIST_ITEMS`; it does not define new + # rows. See docs/knowledge-override.md for why that boundary is there. + # ---- and the same fact pointing the OTHER way ---------------------- + # + # `not_applicable` switches a row OFF for a kind of piece. Some rows only + # exist FOR one kind: the owner commissioned an insulated-only check on + # 2026-08-31 (every lifter on an insulated panel must have a solid zone + # through the insulation), and on a solid panel that question is not + # quiet, it is meaningless -- there is no insulation for a solid zone to + # interrupt. + # + # Enumerating "not applicable to every OTHER type" would be the same fact + # written the long way, and it breaks the moment a third type is + # declared: the new type inherits the row by default, which is the + # unclassified-piece-reads-as-ordinary failure again. + # + # checks: + # only_applies_to: + # '': + # '': + # scoped: true # the row is only about this + # scoped_because: # REQUIRED when true + # scoped_since: 'YYYY-MM-DD' # REQUIRED when true + # unscoped_because: # REQUIRED when false + # unscoped_since: 'YYYY-MM-DD' + # declared_by: + # + # A job WIDENS a row back to every piece with `scoped: false` under + # exactly that pair, stating its own case in its own key -- the same + # narrow/extend discipline, and the same reason the reason key is named + # for the value it justifies. + # + # `any` IS REFUSED HERE. Scoping a row to every type is not a scope. + # + # THE THREE ANSWERS A SCOPED ROW HAS, and the third is the one that had + # to be thought about: + # + # the piece IS one of the types -> the row is asked, normally + # the piece is ANOTHER type -> NOT APPLICABLE, in blue, saying + # which type it is and which the row + # is about + # the piece is UNCLASSIFIED, or + # nothing can classify at all -> NOT RUN, in RED + # + # THE THIRD ONE IS WHERE "decline to classify -> the FULL checklist" + # needed refining, because the full checklist now contains rows that are + # meaningful for one type only. Running such a row on a piece nobody + # could place would assert a solid-panel answer to an insulated-panel + # question; declaring it NOT APPLICABLE would claim the piece is not that + # type, which is exactly what could not be established. So it is NOT RUN + # -- nobody could ask it -- which is red, loud, and cured by making the + # classification work rather than by anybody's ruling. That is the + # dormant-check shape, arrived at from the other side, and it keeps the + # invariant intact: silence is never a pass, in either direction. + 'checks': { + 'muted': {}, # {'3.3': 'why this job never has one'} + 'not_applicable': {}, # {'3.3': {'insulated': {...}}} + 'only_applies_to': {}, # {'6.7': {'insulated': {...}}} + }, + + # ---- THE CHECKLIST ITSELF, PER PLANT AND PER KIND OF MEMBER ------- + # + # Owner, 2026-08-31: "按照 plant-member 这种级别,给 ticketqc 的 item + # 重新编号,让 ticketqc 自动识别 plant-member, 并且做正确的 check。" + # + # `checks.not_applicable` was the switch; this is the panel board. A + # PROFILE says which checks this plant asks of this kind of member, in + # what order, and under what numbers -- and a different plant, or a + # different member kind at the same plant, gets a different board. + # + # ================= THE ONE THING THAT MUST NOT MOVE ================ + # + # A CHECK'S IDENTITY IS ITS STABLE ID AND THE PROFILE ONLY SUPPLIES A + # DISPLAY NUMBER. The stable ids are the numbers `ticketpage. + # CHECKLIST_ITEMS` has always carried, FROZEN -- `CHECKLIST_IDS` is the + # written-down golden and a test holds the table to it. + # + # Everything that has ever referred to a check refers to it by that id: + # the owner's mutes ('3.3', '5.4', '1.4'), `checks.not_applicable`, the + # QNA records, `docs/checklist-code-map.md`, `qc-run.json` and its + # consumers, and the suite. If a number became profile-relative, every + # one of those would either break or -- far worse -- keep working while + # pointing at a different question. His mutes survived a renumbering + # today only because nobody renumbered. After this, a renumbering is a + # YAML edit that cannot reach them. + # + # WHY THE ID IS THE OLD NUMBER RATHER THAN A NEW SLUG. Minting slugs + # would mean translating every existing reference at once -- a mass + # rename is exactly the orphaning risk this design exists to remove, and + # it would be paid on day one for a tidier spelling. The number is + # already unique, already tested unique, and already what everyone + # writes. It is frozen, not renamed. The cost is that an id LOOKS like a + # display number, which is how it would get renumbered by accident -- + # hence the golden. + # + # =================== THE SHAPE ====================================== + # + # checklists: + # profiles: + # : + # member_kind: + # panel_type: + # title: + # because: + # since: 'YYYY-MM-DD' + # declared_by: + # unlisted: ask | not_applicable + # unlisted_because: + # unlisted_since: 'YYYY-MM-DD' + # items: # ORDERED. A LIST, on purpose. + # - {number: '1.1', check: '1.1'} + # - {number: '1.2', check: '2.3'} + # + # `items` IS THE ONE LIST IN ALL OF THIS, and it is a list because it is + # ORDERED and because order is the one thing a map cannot carry (a dict + # is unordered on the engine this runs on). Lists REPLACE across the + # layers -- which is right here and nowhere else in this branch: a + # checklist's order is a single statement, and a job that restates it + # means to restate it. Everything else is a map and deep-merges, so a + # job can retitle a profile, or change only its `unlisted` policy, + # without touching its board. + # + # ============ AND THE HOLE A CLOSED LIST WOULD OPEN ================= + # + # `unlisted` DEFAULTS TO `ask`. A profile names the checks that existed + # when somebody wrote it; the code universe grows. With a closed list + # and no policy, THE NEXT CHECK ANYONE BUILDS WOULD BE SILENTLY UNASKED + # at every plant with a profile -- a new safety check, shipped, running + # nowhere, and nothing on the report saying so. So a check the profile + # does not name is ASKED ANYWAY, appended under its own stable id, and + # the report says the profile is out of date with the code. + # + # `unlisted: not_applicable` is the deliberate closed board, and it + # needs a reason and a date like every other switch here, because it is + # a standing promise about checks nobody has written yet. + # + # ================== HOW IT COMPOSES WITH THE SWITCH ================= + # + # PROFILE FIRST, THEN `checks.not_applicable`. The profile says what is + # on the board at all; the switch says whether a question that IS on the + # board applies to this particular piece. A check the profile never put + # up cannot be switched off, and does not need to be -- so a profile + # should not restate what `not_applicable` already says. Both render as + # the same NOT APPLICABLE state and the report names which of the two + # did it. + # + # EMPTY HERE, AND EMPTY IS THE COMPATIBILITY CONTRACT. No profile + # declared -> the full table, in its own order, under its own numbers: + # byte-identical to what this tool printed before profiles existed, and + # a test pins it. + 'checklists': { + 'profiles': {}, + }, + + # ---- how this plant names things ---------------------------------- + # + # Nomenclature varies between plants more than any other assumption here, + # and it is the one thing that silently mis-reads a whole job when it is + # wrong -- so it is data, not code, and it belongs to the PLANT layer + # rather than the job (see load()). + 'nomenclature': { + # ---- WHAT KIND OF MEMBER A MARK NAMES ------------------------- + # + # A MAP from mark prefix to member-kind id: {'AAA': 'wall_panel'}. + # Plant knowledge, and it sits here with the rest of nomenclature + # because that is exactly what it is -- how this plant names the + # things it makes. A different plant spells the same kinds + # differently and this is the one key that has to change. + # + # THE PREFIX IS READ BY `scope.prefix_of`, NOT BY A MATCHER OF ITS + # OWN, and that is the whole reconciliation. `scope.py` already owns + # "what is the prefix of this mark", it takes the WHOLE leading + # letter run, and it argues at length against matching a declared + # prefix as a prefix -- so `AAA` does not reach `AAAB001`, whose + # prefix is `AAAB`. Two readers with two rules would disagree about + # which pieces a job's declared scope covers versus which checklist + # they get, and they would disagree silently. There is one reader. + # + # EMPTY MEANS NOBODY HAS DECLARED THE MAPPING. It never means "every + # piece is the same kind": an unrecognised prefix resolves to None, + # no profile is selected, and the FULL checklist runs under its own + # numbering. Which is what this tool did before profiles existed. + 'member_kinds': {}, + # A design packet's cover sheet carries the design number with a page + # suffix: design PNL1 has cover sheet PNL1.0, then PNL1.0A, PNL1.A and + # so on for the rest of the packet. A piece may carry either form. + # This pattern says which leading part actually identifies the design; + # the groups it captures are joined to make the key everything is + # matched on. Default: the letters and the first number. + 'design_number_re': r'^([A-Za-z]+)\s*(\d+)', + # Sheets whose number starts with one of these are dead and skipped. + 'ignore_sheet_prefixes': ['zvoid', 'zzvoid', 'xxx'], + # Project-browser folder holding the design packets. Empty string + # means do not filter by folder at all. + 'design_folder': '', + # A view whose name matches this is a stale duplicate of another view + # and is dropped when two views would otherwise both look like the + # summary. + 'stale_view_re': r'(\s+Copy\s+\d+|\s+old)\s*$', + # Checklist 1.4. design_number_re is ANCHORED -- it reads a value + # that IS a design number ('PNL1.0'). A view name carries its design + # label in the middle ('TICKET NOTE - WP2- STRAND JACKING FORCE'), so + # the same fact is needed unanchored. Left None it is DERIVED from + # design_number_re by dropping the leading '^', which is right + # whenever that pattern is the usual anchored form; a plant whose + # view names label the design differently sets it here rather than + # bending design_number_re, because the two are read by different + # checks and a change made for one silently moves the other. + 'view_design_token_re': None, + # What one exported TICKET covers, and therefore whether a folder of + # them is a count of pieces or a count of drawings. A JOB fact -- the + # same plant does it both ways -- so it is declared in the job layer + # and NULL here. Null means NOBODY HAS SAID: the takeoff withdraws + # the ticket source and says so, rather than assuming one ticket is + # one piece. 'control_number' | 'piece_mark'. + 'ticket_unit': None, + }, + + # ---- the casting bed ---------------------------------------------- + # + # A design with strands always dimensions where they sit: a chain running + # across the pattern, first segment from a rail to the first strand, then + # strand to strand, last segment back to the far edge. The numbers are + # meaningless without knowing which rail the chain starts from, and + # reading it from the wrong end mirrors the entire pattern -- so it is + # stated here rather than inferred from geometry. + 'rails': { + # Edge the strand dimension chain is measured from. + 'strand_datum': 'left_rail', + # Whether that rail is the bed's FIXED rail. Some plants cast against + # a fixed left rail and move the right one to the piece width; others + # do the opposite. It decides which edge a tolerance accumulates away + # from, so None means nobody has said -- never a guess. + 'left_rail_is_fixed': None, + # Clear width of the bed, in inches. The strands are stressed across + # the whole bed, so a piece narrower than the bed can sit anywhere in + # it -- and the amount the pattern may slide off the design chain is + # exactly what is left over: + # + # |offset| <= max_bed_width_in - panel width + # + # A full-width piece therefore has almost no freedom, which is what + # makes this checkable rather than advisory. + 'max_bed_width_in': None, + }, + + # ---- the design summary view --------------------------------------- + # + # `strandmatrix` reads the strand pattern off the drawing, not off text: + # it counts a drawn symbol in the strand-pattern section of the design + # summary and checks that count against the strands the summary already + # states a quantity for. WHICH symbol marks one strand is a drafting- + # standard choice, not a geometry fact -- one plant fills a small + # FilledRegion at each strand position, another places a named detail + # component instead -- so it has to be named here rather than guessed. + # Get this wrong and the reader does not read a wrong grid quietly: the + # symbol count will not match the stated quantity, and the whole matrix + # refuses with that mismatch as the reason, which is the point -- a + # wrong name should fail loudly, not draw a grid nobody drew. + 'design_summary': { + 'strand_symbol': 'FilledRegion', + }, + + # ---- what goes on which drawing ----------------------------------- + # + # Checklist 5.2. The manufacturer's standards place reinforcing by CLASS + # of bar, not by subtraction (section 10000, p804 notes 25/26/40; see + # ticketpage.bar_drawing_home). Telling the classes apart needs one + # number the standard never states: how much of the panel a bar must + # span before it is "full length" (and, across the piece, "full width"). + # + # That is plant knowledge. Null by default and NOT guessed: with it + # unset, 5.2 still checks the embeds, and its reinforcing half stands + # down and says why -- the same shape as rails.max_bed_width_in standing + # BED-OFFSET down. A wrong default here would put a full-length bar in + # the plan's column and flag every ticket that correctly omits it. + # + # A fraction in (0, 1]. 0.9 would read "a bar reaching 90% of the panel + # is drawn as a full-length bar"; the right number is the detailer's. + # Which modelled families ARE reinforcing. Revit's own category says so + # for real rebar, and that is used first; a plant that models bars as + # generic-model families has to name them, because then nothing in the + # model says it is a bar. Empty by default, matched as substrings. + # + # The cost of leaving it empty is stated rather than hidden: a bar this + # does not identify keeps going through the OLD residual and can still + # be reported as missing from the BED PLAN when it correctly lives in a + # section. Filling it in is what stops that. + # A LIST is an enumeration and it goes stale in silence: the day the plant + # adds a bar family and nobody re-lists it, 5.6 and 5.7 stop seeing those + # bars and report nothing, which reads exactly like nothing being wrong. + # `reinforcing_rule` says the same thing as a RULE instead -- a PARAMETER + # to read and a token to find in its value -- so a family added tomorrow + # is recognised without an edit. See `reinforcing_verdict` for how the + # three settings below resolve against each other. + 'drawings': { + 'full_length_bar_fraction': None, + + # THE ENUMERATION. Unchanged, still read, and it still WINS where it + # is set: a plant that would rather name its families one by one must + # be able to, and being overtaken by a pattern it did not write would + # be worse than the pattern is worth. A LIST, so it replaces. + 'reinforcing_families': [], + + # THE SAME ENUMERATION, WRITTEN AS A REGULAR EXPRESSION PER ENTRY, + # for a plant whose bar-family name is a fixed wrapper around an + # OPEN-ENDED CODE the wrapper alone cannot describe -- e.g. a family + # literally named `ACME-17_(SCHEDULED)`, `ACME-S4_(SCHEDULED)`, + # `ACME-T2_(SCHEDULED)` ... where the bend-type code in the middle is + # a growing series, not a fixed vocabulary. A plain substring token + # wide enough to catch every code (`ACME-` alone) would also swallow + # any future ACME-prefixed family that is not a bar; a token narrow + # enough to be safe (the wrapper's + # closing `_(SCHEDULED)`) would swallow every OTHER `(SCHEDULED)` + # family at the plant that happens to share that suffix (a strand + # family has been seen doing exactly that). Neither a fixed string + # nor `reinforcing_families`' plain "contains" match can express + # BOTH ends of the wrapper at once; a regex can. + # + # SAME EVIDENCE CLASS AS reinforcing_families, not a fourth way of + # knowing: `reinforcing_mode` treats a non-empty list here exactly + # like a non-empty `reinforcing_families` -- 'list' mode, wins over + # the rule -- and `reinforcing_verdict` checks the plain substrings + # first, then these patterns, before falling through to "no match". + # + # Matched with `re.search`, case-insensitively, against the family + # name AS WRITTEN (not upper-cased first, unlike the plain list) -- + # write the parenthesis and any other regex metacharacter escaped, + # e.g. `\\(SCHEDULED\\)`. A pattern that fails to compile never + # matches, silently, exactly like a plain-list token that never + # appears in any family name -- it is not reported as an error, + # because nothing here can tell a deliberately-inert entry from a + # typo. + 'reinforcing_family_patterns': [], + + # THE RULE. A MAP, so it deep-merges -- a job can correct the token + # without restating the parameter, and vice versa. + # + # IT WAS A CATEGORY PLUS A FAMILY-NAME TOKEN UNTIL 2026-08-30, AND + # THAT WAS WRONG. The census it rested on read `FamilySymbol` TYPES + # only, and this plant's categorisation is written on the INSTANCE -- + # so the census missed the entire reinforcing population and the + # category never resolved. Scoped to real assemblies the bars are + # right there: 112 members on one piece, 84 on another, none of them + # visible to a type read. + # + # The real rule is a PARAMETER and a token in its VALUE, and it is not + # an invention -- the plant has already encoded it in the model. + # Thirteen view filters key on this one parameter and every string + # rule among them is "contains", not "equals". + # + # parameter a parameter ROLE (see PARAMETER_ROLES). The role + # is named here; the plant maps it to its own + # parameter name in `parameters.`, so the + # office's parameter name never enters this + # repository. + # read_from ['instance', 'type'], in order. They are + # MUTUALLY EXCLUSIVE per element -- where the + # instance carries a value the type is empty and + # vice versa -- so reading one alone finds nothing + # on half the model and looks like a clean result. + # value_contains tokens to look for in that value, matched + # case-insensitively as SUBSTRINGS because that is + # what the plant's own filters do. A list, so it + # replaces. + # + # BOTH ARE REQUIRED for the rule to run, and that is the safety: a + # bare token with no parameter behind it has nothing to read. + # + # `parameter_verified` FALSE IS NOT "there is no such parameter". It + # is "nobody has confirmed it against a model", which has to stay a + # separate state: guessing it wrong un-recognises every bar at once, + # silently, which is precisely the dormancy this rule exists to end. + # `parameter_unverified_because` says what is missing, and + # `reinforcing_mode()` reports the rule unrunnable and quotes it. + 'reinforcing_rule': { + 'parameter': 'member.manufacture_component', + 'parameter_verified': False, + 'parameter_unverified_because': None, + 'read_from': ['instance', 'type'], + 'value_contains': [], + + # PER-CHECKLIST-ROW SCOPE, and it is DORMANT ON PURPOSE. + # + # The token is not a clean boundary and the exceptions are known + # in both directions. Some bar-formed HARDWARE carries the token + # (one of them is a strand loop); some genuine reinforcement + # carries no token at all. So 5.6 ("every bar on the ticket is + # marked") and 5.7 ("no bar is longer than its stock") may + # legitimately want DIFFERENT populations -- a strand loop has no + # bar mark and no stock length, and neither absence is a finding. + # + # Keyed by checklist item so the two rows can differ; `also` and + # `except` are lists of parameter VALUES matched the same way the + # token is. Both empty, because nobody has ruled which way each + # row should go, and inventing a scope here would decide it + # silently. `reinforcing_verdict(..., item=)` reads them; with + # nothing here every row sees the same population, which is + # today's behaviour exactly. + # + # THE MERGE COST, STATED: `scopes` is a map so a job can add one + # row's scope without restating the other's -- but `also` and + # `except` are LISTS INSIDE it, and a layer that sets one replaces + # it whole rather than adding to it. + 'scopes': {}, + 'scopes_unresolved_because': None, + }, + + # PER-FAMILY CORRECTIONS, applied before either of the above and + # therefore able to overrule both: `FAMILY_NAME: true` forces one in, + # `false` forces one out. A MAP, so a job adds one correction and + # inherits the plant's; as a list it could only replace them. + # Matched EXACTLY, case-insensitively -- not as a substring, or an + # entry for `SR1` would swallow `SR1101`. + 'reinforcing_family_overrides': {}, + + # ---- WHERE A BAR'S SIZE COMES FROM ------------------------------- + # + # Checklist 5.7 measures a bar against the stock length for ITS OWN + # SIZE, and at a plant there may be TWO FIELDS holding it, covering + # DISJOINT POPULATIONS. + # + # the size parameter the `member.bar_size` role. A string + # holding exactly `#4`. Measured over 24,539 + # rebar members: carried by the STRAIGHT + # family and by not one of the twelve bent + # ones. + # + # the part description the `member.identity_description` role, + # read off the FAMILY, whose value is + # `#4 BENT BAR (17)`. Carried by all twelve + # bent families, 100% on the type, and by no + # straight member. + # + # SO THERE IS NO PRECEDENCE HERE AND NO FALLBACK. Two readers for two + # populations. `bar_size_of()` tries the size parameter first purely + # because one of them has to be first; no bar has both, so the order + # has never decided anything and must not be read as a ranking. + # + # AND THE DESCRIPTION IS NOT PROSE. Owner, 2026-08-31: "assembly 的 + # identity description 是我的随笔。family 的不是。" -- the ASSEMBLY's + # identity description is his running notes (that is the field the + # reinforcing donor work reads, where `MASTER` is a stable token + # inside free text); the FAMILY's is structured plant data. Two + # different fields on two different kinds of element sharing one name. + # `description_read_from` is what keeps them apart, and reading the + # wrong one gets prose where structure was expected. + # + # `bar_size_of()` still returns WHICH FIELD ANSWERED as `basis` -- + # same discipline as `rebar_stock`'s basis and `rebar_fy`'s. Not + # because one is weaker, but because a verdict that cannot say what it + # read cannot be audited. + # + # `bent_bar_tokens` GATES the description parse as well as explaining + # an absence. A description that no longer reads as `#N BENT BAR` + # leaves the bar UNJUDGED rather than mis-sized -- the shape is the + # evidence that the value means what we think, so a shape that changes + # must stop the read, not be parsed through. + 'bar_size_rule': { + 'description_role': 'member.identity_description', + 'description_read_from': ['type'], + 'description_fallback': False, + 'description_verified': False, + 'description_unverified_because': None, + 'bent_bar_tokens': [], + }, + + # ---- checklist 6.6: a note required where a count has a parity ---- + # + # A MAP, so a job can correct the wording without restating the family + # and vice versa. Read by the 6.6 check through `K.get`, which is why + # the keys start working the moment a layer sets them. + # + # family_names the families whose members are counted. A LIST, + # so it REPLACES -- what a layer names is exactly + # what is counted. + # family_verified whether anyone has confirmed those names + # against a model. + # parity which parity of the count requires the note. + # require_text the note that must then appear. A string, or a + # list of acceptable spellings. + # + # `family_verified: False` IS NOT "THERE IS NO SUCH FAMILY". It is + # "nobody has confirmed the name against a model", and it has to stay + # its own state, because the failure mode here is worse than usual: a + # family name that matches nothing counts ZERO on every piece, zero is + # even, and the row reads a permanent invisible OK. That is + # silence-is-a-pass wearing a parity costume -- the check must report + # the rule unrunnable and name the reason instead. Same discipline, + # and the same reason, as `reinforcing_rule.parameter_verified`. + 'parity_note_rule': { + 'family_names': [], + 'family_verified': False, + 'family_unverified_because': None, + 'parity': None, + 'require_text': None, + + # WORDINGS THAT ARE REPORTED AND NEVER ACCEPTED. + # + # A piece whose note says nearly the right thing is a DIFFERENT + # DEFECT from a piece with no note at all: one needs a wording + # corrected, the other needs a note written. A report that reads + # identically for both sends the reader to the wrong job. + # + # It is NOT a second accepted spelling and must never become one. + # Where an owner names one phrase, widening the rule to accept a + # near-miss quietly overrules him -- so a piece carrying one of + # these still FAILS, and the finding names the wording it found + # instead of saying nothing was there. + # + # A LIST, so it replaces. Every entry should be a wording actually + # seen in a model, never a guess at what somebody might type: this + # key's whole value is that it describes the real corpus. + 'require_text_also_seen': [], + # A COUNT IS NOT A RECOGNITION, and this key is where the two part + # company. `families.lifter_include` may safely hold every panel + # type's face lifter at once -- an extra family there produces a + # visible finding about a real insert. A parity COUNT cannot: a + # piece carrying two families where one is the lifter and the + # other is present for some unrelated reason counts wrong, and a + # wrong parity is a wrong verdict with nothing to show for it. + # So this list must be narrowed per panel type before it is + # filled, and `families_for()` is what narrows it. + 'family_names_by_panel_type': {}, + }, + }, + + # ---- lateral extraction ------------------------------------------- + # + # Which members resist lateral load is an engineering judgement, and + # this tool does not make it. The user marks the members BY HAND, by + # typing a token into the element's identity description. Everything + # here is about reading that mark, never about deciding it. + 'lateral': { + # The token, and its spellings. The owner types 'lateral-resistent'; + # the conventional English is 'lateral-resistant'. Both are accepted + # because a hand-typed field is not a controlled vocabulary. Matched + # case-insensitively, tolerant of the separator (-, _, space) and of + # surrounding text; NOT tolerant of a missing separator. It is a + # LIST so a plant with its own word ('LFRS') adds it here rather + # than editing code -- see latmodel.match_marker for exactly what + # each spelling accepts and rejects. + 'marker_tokens': ['lateral-resistent', 'lateral-resistant'], + # + # WHICH FIELD the mark is written into is NOT configured here. It is + # a parameter name, so it is a ROLE: `parameters.member.identity_ + # description`, mapped in the plant file like every other parameter + # this codebase reads. Naming it here as well would be a second + # place to set the same fact, and the one that drifts is always the + # copy. Unmapped, Lateral Extract stands down and says so; it never + # falls back to a guessed name, because a guess that happens to be + # right on one template does nothing at all on the next one. + # + # Scanned when nothing is preselected. Always a list -- an + # unfiltered collector took Revit down once already. + 'categories': ['OST_StructuralFraming', 'OST_StructuralColumns', + 'OST_Walls', 'OST_StructuralFoundation', + 'OST_GenericModel'], + # How close two member ends must be to become one analysis node, + # in FEET. Null uses latmodel.DEFAULT_NODE_TOL_FT (1/8"). + 'node_tolerance_ft': None, + # What to do with a member that has no location curve -- which, in + # a point-placed precast model, is all of them. Null means REFUSE: + # the member is skipped and the reason is reported. The only other + # value implemented is 'bounding_box_longest', which is opt-in + # precisely because it is a guess: measured on this model, a 90 ft + # column has a 140 ft bounding box, so the axis it yields is wrong + # by tens of feet while looking entirely plausible. Same shape as + # rails.left_rail_is_fixed -- a wrong default here is invisible. + 'point_member_axis': None, + # Where the extracted files go. Null puts them beside the MODEL, in + # its `_ticketqc/lateral/` folder -- never in the repository. + 'output_dir': None, + }, + + # ---- what this shop calls things ---------------------------------- + # + # Every Revit parameter this tool reads is named by *role*, never by the + # literal name a particular office template happens to use. The mapping + # from role to real name lives in knowledge.yaml, which sits beside the + # model and is not part of this repository. + # + # The defaults are deliberately empty. A wrong default would be read as a + # missing value and the check that needed it would quietly not run, which + # is the exact failure this whole design exists to prevent. Unmapped means + # unmapped, and Self Check says so. + # + # A value may be one name or a list of candidates tried in order: + # length: PANEL_LENGTH + # length: [PANEL_LENGTH, PIECE_LENGTH, Length] + 'parameters': { + 'panel': { + 'design_number': None, # groups pieces to one design summary + 'length': None, # long dimension, a Revit length + 'width': None, # short dimension, a Revit length + 'unit_weight': None, # concrete density, pcf + 'weight': None, # cast weight of the piece, lb + }, + 'panel_type': { + 'thickness': None, # on the type, not the instance + }, + 'strand_type': { + 'diameter': None, # nominal strand diameter + 'description': None, # carries the grade, e.g. '... 270 KSI' + }, + 'wwf_type': { + 'description': None, # carries the mesh designation + }, + 'sheet': { + 'design_number': None, # design number as printed on the ticket + 'length': None, + 'width': None, + 'depth': None, + 'plant': None, # producing plant, optional + 'fc': None, # final strength, as printed (title block) + 'fci': None, # release strength, as printed (title block) + 'unit_weight': None, # unit weight, as printed -- many plants + # do not print it at all, and then the + # PCF-NOTE check says so rather than + # failing the ticket (see ticketqc) + }, + }, + + # ---- the reinforcing-suggestion donor pool ------------------------- + # + # `TICKET_IS_REINFORCED` says somebody drew reinforcing on a piece. It + # does not say anybody CHECKED it, and the suggestion feature had no way + # to tell a reviewed template from a merely-flagged one. This plant types + # a word into the assembly's identity comment to mark the reviewed ones, + # and the suggester ranks those first BETWEEN OTHERWISE EQUAL donor sets + # -- a tie-break, never a filter, and never ahead of a better geometric + # fit (owner's ruling, 2026-08-30). + # + # The word is the plant's, so it is a key rather than a literal in the + # code. Matched case-insensitively and as a whole word anywhere in the + # field, because the field is typed by hand: see + # `reinforcecore.is_master_text` for exactly what that accepts. + # + # Set it to '' or null to switch the signal off entirely; the ranking + # then falls back to what it was before the signal existed. + 'reinforce': { + 'master_token': 'MASTER', + }, + + # ---- the once-a-day snapshot ------------------------------------------- + # + # Snapshot & Close: the engineer syncs, presses one button, the tool + # indexes the OPEN model (phase A), Revit closes (phase B), and -- only + # if this section is filled in -- a deferred process opens the cloud model + # detached ONCE REVIT HAS EXITED and writes a timestamped copy (phase C). + # + # EVERY PATH KEY DEFAULTS TO None AND None MEANS NOT CONFIGURED. It never + # means "use a sensible folder": a tool that invents somewhere to put a + # 300 MB model is a tool that fills a disk nobody is watching. The button + # says the feature is not set up and stops after phase B, which is the + # useful half and needs no configuration at all. + # + # These are JOB facts -- where this job's archive lives, which Revit this + # job opens in -- so they are declared in the job layer and are empty + # here. See `vibe/snapshotmeta.py` for the naming and discovery rules and + # for why the timestamp goes in the FOLDER name by default. + 'snapshot': { + # Where timestamped detached copies are written. None = phase C off. + 'detached_dir': None, + # True to chain phase C from the button press. Ignored (and reported) + # when `detached_dir` is None, so turning it on is two deliberate + # edits rather than one that half-works. + 'after_close': False, + # 'folder' (default) puts the stamp in the containing folder name and + # keeps doc.Title -- and therefore the job folder -- constant. + # 'filename' puts it in the model's own name, as first asked for; that + # moves the job folder every day when WILLTOOLS_JOBS is set, and + # `snapshotmeta.FILENAME_WARNING` is what the tool prints about it. + 'layout': 'folder', + # Which Revit the deferred run opens, e.g. 2025. None lets the pyRevit + # CLI choose, which is only safe on a machine with one Revit. + 'revit_year': None, + # 'preserve' keeps worksets in the detached copy (the copy becomes a + # new central); 'discard' makes a plain non-workshared file. Preserve + # is the default because it loses nothing. + 'worksets': 'preserve', + # How long the deferred process waits for Revit to exit before giving + # up and logging a failure. A closing Revit with a 10 GB model takes + # minutes; a Revit that is waiting on a save prompt takes as long as + # the engineer does. + 'wait_minutes': 20, + # The pyRevit CLI. None = look where pyRevit installs it. + 'pyrevit_exe': None, + + # ---- the CYCLE (tools/snapshot_cycle.py), added 2026-09-01 -------- + # + # The copy stopped being an archive and became a SANDBOX: the + # pipeline writes it, closes the Revit that was on the last one, + # reopens on the new one, runs a batch inside it and closes it again. + # These are the keys that step needs, and they are all still None or + # empty here for the same reason as everything above -- a path this + # code invents is a path nobody chose. + + # Revit itself, for opening the sandbox. `pyrevit run` is not usable + # for this: it plays a journal and quits, which is right for the + # one-shot detach and wrong for a session that has to stay up while a + # sweep drives it. None = derive from `revit_year`, which is a GUESS + # at the install location and is checked before it is used. + 'revit_exe': None, + # What to run inside the sandbox once it is up, in order. Each entry + # is a host command line; each gets its own log in the snapshot + # folder; the first non-zero exit stops the rest. + 'batch': ['python tools/sweep.py'], + # How long the sandbox gets to start Revit, load pyRevit, open a + # 300 MB model and answer one request. A timeout here is REPORTED, + # never assumed away -- see `snapshot_cycle.wait_for_sandbox`. + 'sandbox_wait_minutes': 15, + + # WHICH MODEL IS COPIED, for a run that has no Revit to ask. + # + # A cloud model is found by its region and two GUIDs, and nothing on + # the machine knows those except `doc.GetCloudModelPath()` inside a + # session that has the model open. So the identity has to be written + # down once or every run needs somebody to press a button first -- + # which is the thing the owner said was too slow to be in the loop. + # + # Normally these stay null and the identity arrives the easy way: the + # button caches it to `/source.json` on the first press + # and `tools/snapshot_cycle.py` reads it from there ever after. They + # are here for a job that would rather state it outright. + # + # `source_cloud` is {region, project_guid, model_guid}. `source_path` + # is the alternative for a file-based job. Neither is a fallback for + # the other -- a job is one or the other and guessing which was never + # safe. + 'source_cloud': None, + 'source_path': None, + 'source_title': None, + }, + + # ---- the bridge --------------------------------------------------------- + # + # WHICH MODELS THE ROUTES API REFUSES TO SERVE. This is the owner's + # standing instruction -- 不要碰我的live -- written as code rather than + # carried in an agent's memory. + # + # The hole it closes is a port race. pyRevit takes the configured routes + # port if it is free and walks UP if it is not, so which Revit owns 48884 + # is decided by which one started first. Restart the sandbox while the + # live session is up and every request an agent sends afterwards lands in + # the live model -- and it all looks normal, because the snapshot copy + # deliberately carries the SAME TITLE as the model it was copied from. + # + # Which is exactly why the title alone cannot decide it, and why a + # document on the list can still prove itself: `startup.py` lets it + # through when its file is under a snapshot root, or when it is the model + # named by WILLTOOLS_SANDBOX_MODEL in that Revit's own environment (which + # only `tools/snapshot_cycle.py` sets, on the session it launched). All + # of the matching is in `vibe/bridgeguard.py`. + # + # EMPTY BY DEFAULT, and empty means "no model is protected" -- the tool + # does not know which of a machine's models is somebody's live work. The + # VALUE is a model name, which is job information, so it belongs in the + # job (or plant) knowledge layer and never in this file. + 'bridge': { + # Document titles the bridge must refuse to serve. A list of names, + # matched without the extension and without regard to case. + 'refuse_titles': [], + # Document titles the bridge may RUN ON but never WRITE TO. Reversed + # from refuse_titles on 2026-09-01 -- the owner's own words: "我要把 + # revit桥给你。你直接来sample_job。铁律是在live里面只读不写。" A title + # here answers /status, /collect, /params, /selection and any + # /exec snippet that only reads, normally; /exec refuses outright + # when the request carries a transaction or the snippet matches + # known write-capable Revit API surface. See `bridgeguard.py`'s + # `WRITE_PATTERNS` for exactly what that covers, and what it does + # not -- this is a guard rail, not a sandbox. A title on + # refuse_titles is refused outright regardless of this list; the + # two are not layered, refuse_titles simply never reaches here. + # EMPTY BY DEFAULT, same reasoning as refuse_titles below. + 'readonly_titles': [], + # Folders whose models are sandboxes whatever their title says. + # `snapshot.detached_dir` is always treated as one; this is for the + # rest. + 'allow_roots': [], + # Where `tools/snapshot_cycle.py` starts looking for the routes + # server, and how many ports up it walks. Not the port pyRevit is + # configured with -- that is pyRevit's own setting; this is only the + # range the pipeline scans to work out which session is which. + 'port': 48884, + 'port_span': 4, + }, + + 'tolerances': { + # Dimensional comparisons, in inches. + 'dimension_in': 0.375, + # Strengths and tensions, absolute. + 'psi': 1.0, + # How close the ticket's printed jacking force must come to the design + # intent's strand initial tension, in POUNDS. 100 lb, ruled by Will + # 2026-08-30 -- a DECISION, not a measurement, and the exact + # equivalent of the `> 0.1` kips literal that checklist 4.3 carried + # hardcoded before the key was wired. It was 1.0 here when the literal + # was deleted, which would have made the check a hundred times + # stricter than anybody intended and flagged very nearly every ticket. + 'it_lb': 100.0, + # 5.9: how far the widest gap between connectors may exceed the + # design's stated on-centre spacing. A TOOL DEFAULT, not a ruling -- + # the provenance is written out in + # `connector_spacing_tolerance()`, beside the code that reads it. + 'connector_spacing_in': 0.5, + }, + + 'families': { + # Instances of these count as one strand each. Matched as substrings + # against the family name, case-insensitive. + 'strand_include': ['STRAND'], + 'strand_exclude': ['LOOP', 'GROMMET'], + # Welded wire fabric. + 'wwf_prefix': 'WWF', + # Anything matched here is treated as a lifting insert. + 'lifter_include': ['LIFT', 'LPA', 'ANCHOR'], + 'lifter_exclude': ['SHEAR'], + # Separate families that are cast concrete of the piece itself -- + # a slab-seat ledge, a haunch -- not inserts, not voids. Their + # volume is real weight: WP371's ledge carried 8.99 ft3, and + # leaving it out understated the piece by 7.5%. + 'cast_include': ['LEDGE'], + }, + + 'legend': { + # A ticket's strand/reinforcing pattern is a legend VIEW on plants + # that draw one. This one does not -- 0 of 204 sheets here carry a + # STRAND/REINFORCING PATTERN view -- so the DEFAULT is empty and the + # legend checks must not fail a ticket for the absence of + # something the plant never draws (docs/ticket-audit-wp1-12.md). + # A plant that does use legend views names its prefixes here and the + # legend checks re-engage. + 'pattern_prefixes': [], + # When the legend name ends in a width, it must match the panel. + 'width_must_match_panel': True, + # Legend text prints one strand count for a whole design; set true if + # that printed count must equal the strands actually modelled. + 'printed_strand_count_is_per_piece': True, + # The view whose text carries the per-piece strand count, printed as + # `(n) 0010001 (E.L.)` inside section A-A on sheet 2. Empty when this + # plant has no such section view; the count is then read from the BOM + # linear-feet row instead (ticketqc.read_ticket). + 'section_strand_view': '', + }, + + # ---- the ticket's written instructions ---------------------------- + # + # Checklist 6.5. A ticket carries standing instructions -- how to stack + # it, how to erect it, where to pick and strip it -- each drawn as its + # own small VIEW placed on the sheet. The audit found those views + # containing their own title and nothing under it, which prints as a + # heading over blank paper and reads exactly like an instruction that + # was given. + # + # The names are plant nomenclature, so the DEFAULT IS EMPTY and the + # check reports NOT RUN with that reason rather than passing a ticket it + # never looked at. Naming them here is the whole configuration: + # + # notes: + # instruction_views: [DUNNAGE, ERECTION, PICKING-STRIPPING] + # + # A LIST REPLACES the layer below it -- naming two here removes any + # others, which is the merge rule for every list in this file. A MAP + # DEEP-MERGES, so a job can add one group without restating the rest. + # + # A declared entry is a GROUP OF ALTERNATIVES, because a plant draws + # several variants of the same note and a ticket places ONE of them. + # The group wants at least one of its names placed; only a group with + # none of them reports "not placed", and it reports the group, not + # every variant. Four shapes are accepted (ticketpage._normalise_groups): + # + # instruction_views: [DUNNAGE, ERECTION] one group per name + # instruction_views: [DUNNAGE, [ERECT, ERECT2]] a group of two + # instruction_views: {ERECTION: [ERECT, ERECT2]} the same, named + # + # A fifth shape marks a group the piece carries only SOMETIMES -- a + # note the manufacturer's own standard calls conditional: + # + # instruction_views: + # ROTATE: + # optional: true + # views: [ROTATE-A, ROTATE-B] + # + # Absent, an optional group is SILENCE, not a finding; placed, it is + # judged for content like any other. Declaring a conditional note as + # an ordinary group would report it missing on every ticket that does + # not need it, which is a permanent false alarm. MERGE: the group is + # a MAP so `optional` and `views` DEEP-MERGE independently, but + # `views` is a LIST and REPLACES the layer below -- restate it whole. + # + # and instruction_view_sets, a MAP of product family -> those groups. + # Only the family this ticket actually carries is required of it, so a + # slab ticket is never asked for the panel erection note. When no + # declared family appears on a ticket, that is an undeclared product + # type: NOT RUN with that reason, never a pass and never a finding. + 'notes': { + 'instruction_views': [], + 'instruction_view_sets': {}, + }, + + # ---- which views a model is allowed to be shown ---------------------- + # + # Ticket QC escalates a check code cannot compute by rendering ONE VIEW + # and asking a small vision model about it (vibe.ticketvision, and + # docs/ticket-checklist.md section 8bis for what its answer may claim). + # + # Which views carry bar marks is plant nomenclature, exactly like + # notes.instruction_views, so the DEFAULT IS EMPTY and checklist 5.5 + # reports NOT RUN naming that -- never a pass on a drawing nobody looked + # at. Naming them is the whole configuration: + # + # vision: + # barmark_views: [BED PLAN, SECTION] + # + # Matched as a case-insensitive name PREFIX, and a list REPLACES the + # layer below it. Nothing here is rendered unless the run-time option + # vision_escalation is also on: naming a view is permission, not a + # decision to spend. + 'vision': { + 'barmark_views': [], + }, + + # ---- what the ticket SAYS about debonding -------------------------- + # + # Checklist 4.5. 4.7 derives from the geometry WHERE debonding is + # required (strand.debond_below_continuous_ft, which has no default on + # purpose -- it is the plant's number and a wrong one is worse than + # none). 4.5 asks the other half: where the requirement is met, does the + # ticket CALL IT OUT? Owner's ruling 2026-08-30 -- required and not + # called out is a WARN. + # + # These are the words a call-out is printed in, matched as an upper-case + # substring anywhere in a BOM row or a placed view's text. They are + # nomenclature, so THE DEFAULT IS EMPTY and 4.5 reports NOT RUN with + # that reason rather than passing a ticket whose language it does not + # speak. A list of alternatives, because the same instruction is printed + # short on a bed plan and long on a note sheet. + # + # strand: + # debond_callout_tokens: [DEBOND] + # + # NOT the manufacturer's own fixed-length-each-end debond standard, + # which is keyed on strand diameter and is a different rule (QNA, the + # 1000-2000 plank extraction, A4). Do not merge the two. + 'strand': { + 'debond_callout_tokens': [], + }, + + 'sheet': { + # A ticket's printed length is nominal and legitimately runs over + # the modelled length on stemmed pieces, so this comparison is off + # unless a project turns it on. + 'check_tkt_dimensions': False, + }, + + # ---- the manufacturer catalogue ----------------------------------- + # + # Published product data: what a given insert is rated for. It is not + # secret and not job-specific, but it is still DATA rather than code -- + # a rated load written into a Python file is a number nobody can correct + # without a release, and it will be wrong eventually. + # + # Two hops, because a plant rarely models the manufacturer's designation + # directly. The plant layer says which catalogue item its own part number + # means; this layer says what that catalogue item can do: + # + # family name in the model -> catalogue_alias -> catalogue entry + # + # so a plant renaming its parts is a two-line edit and the ratings are + # untouched. + 'catalogue': { + 'inserts': { + # 'SAMPLE_ANCHOR_4T': {'working_load_lb': 8000}, + }, + # Safety factors on a lifter's rated capacity -- lifter properties, + # so they live with the ratings rather than in a per-job rigging + # block (Will 2026-08-30). They apply ONLY where the catalogue + # quotes an ULTIMATE load; every working_load_lb is a published SWL + # that already contains its factor, and panellift applies these only + # when `capacity_is_ultimate` is set (nothing sets it). + # + # NULL, AND NOT 4.0 / 2.67 AS IT WAS UNTIL 2026-08-31. The 2.67 had + # no source -- no page, no ruling, no transcript -- and the + # manufacturer's own sheet for the parts this reached contradicts + # it: ALP catalog p53 (02/2024) heads the capacity table "Capacity + # in Concrete 4:1 SWL" across BOTH load columns and prints shear + # equal to tension on every row. A factor is a property of the part + # that was tested, so the numbers now live inside each + # `catalogue.inserts` entry with the page that publishes them, and + # `lifter_safety_factor()` resolves them per part rather than + # letting one manufacturer's factor reach another's insert. There + # is no trade-wide value to put here; a part with no entry is + # reported as unjudged, never given a borrowed number. + 'safety_factors': { + 'tension_fos': None, + 'shear_fos': None, + }, + # ---- rebar stock ---------------------------------------------- + # + # Will, 2026-08-30: "rebars come in 40ft stock. unless overwritten + # by other knowledge, all rebars pieces must be less than 40ft." + # Industry stock, not a plant habit, so the NUMBER is published in + # `knowledge/catalogue.yaml` (40.0) and merges over this null. + # + # Null here on purpose: with no number the check reports NOT RUN + # naming the key, never a pass. A plant that stocks 60 ft, or a job + # spliced differently, overrides it the normal way. + 'rebar': { + # THE GENERIC STOCK LENGTH. One number for every size, and it is + # a FALLBACK, not the answer. Real stock is per bar size and two + # sizes are commonly nothing like 40 ft -- a #3 comes in 23'-4" + # and a #4 in 20' at one plant measured 2026-08-30 -- so a check + # that compares every bar against one number passes a 30 ft #4 + # that cannot be cut from 20 ft stock. Silently. + # + # Kept because it is a reasonable industry default for the sizes + # nobody has listed, and it is read ONLY when `stock_by_size` has + # no entry for the size in hand. See `rebar_stock()`. + 'stock_length_ft': None, + + # ---- STOCK LENGTH PER BAR SIZE -------------------------------- + # + # `'#4': {stock_length_in: 240, stocked: true, astm: A615}` + # + # PLANT KNOWLEDGE and EMPTY HERE. Owner 2026-08-30: 这个手册里面 + # 的东西是厂知识 -- what a producer's manual lists is that + # producer's, not the trade's. Nothing about which sizes are + # stocked, or how long they come, is ever written into this file + # or into catalogue.yaml. + # + # A MAP, keyed by the size as it is printed ('#4'), and that is + # the load-bearing choice. Maps deep-merge: a job that special + # orders one size adds one key and INHERITS the plant's other + # six. A list would replace, so a job adding one bar would delete + # the plant's stock table and every check would go on looking + # entirely normal. + # + # The cost of a map, stated so nobody is surprised: a higher + # layer can override a size but cannot REMOVE one. That is the + # right trade, because removal is not what anyone means -- a size + # the plant has stopped stocking is `stocked: false`, which is a + # fact worth printing, not an absence. + # + # INCHES, and exactly. Two of the lengths measured are not whole + # feet: 23'-4" is 23.3333... ft and no decimal written in a file + # is equal to it, so a bar cut to exactly 23'-4" would fail a + # comparison against its own stock length. Integer inches has no + # such rounding. `rebar_stock()` returns both units so no caller + # ever converts. + # + # `stocked` IS ITS OWN FACT, separate from the length. A size can + # be listed with a length AND marked not stocked -- the length is + # what would arrive if it were ordered, and `stocked: false` is + # why a ticket calling for one deserves a human. Neither is + # modelled as the absence of the other. + 'stock_by_size': {}, + + # ---- WHAT THE STOCK IS MADE OF ------------------------------- + # + # PLANT KNOWLEDGE, empty here. `{astm, fy_ksi, basis}` stated + # once for the plant rather than repeated on every size row -- + # binding a material to a length is how a stale designation + # outlives the page it came from, and one producer's manual has + # already been found filing its live stock lengths under a + # designation it no longer stocks. + # + # `basis: stated` means somebody asserted this about THIS PLANT. + # It outranks the ASTM default in `astm_grades` below, which is + # only "what that designation usually means". `rebar_fy()` + # reports which of the two it used, every time, so a default can + # never be read as a measurement and a stated fact is never + # quietly downgraded to one. + 'stock_material': {}, + + # ---- the ASTM headings a producer's stock list is written under + # + # PLANT KNOWLEDGE, empty here. PROVENANCE, not a live fact -- a + # record of what a page said, which is why entries carry + # `superseded`. Recorded because a heading with NOTHING UNDER IT + # is a fact, and a different fact from a heading that is not + # there at all: + # + # sizes_listed: true sizes are listed; look them up + # sizes_listed: false NAMED, with no sizes under it. Nothing + # is known about it. This is NOT "the + # plant has none". + # no entry never mentioned. + # + # That middle state earned itself within a day. One manual named + # A706 with the page ending under it; read as "the plant has no + # A706" it would have been WRONG -- the owner's correction was + # that A706 is exactly what the plant stocks and the manual was + # simply out of date. Read as "nothing is known", which is what + # it says, it was right and became answerable. + # + # `rebar_astm()` returns all three distinctly. Collapsing the + # middle one into either neighbour invents a fact. + 'astm_designations': {}, + + # ---- what an ASTM designation says about yield strength ------- + # + # PUBLIC, and published in catalogue.yaml: this is what the + # standard says, not what a producer stocks. + # + # A designation DOES NOT DETERMINE fy. Current A615 covers Grades + # 60, 80 and 100 (historically 40 and 75 as well), so "A615" on a + # stock list says nothing at all about strength. Current A706 + # defines Grade 60 and Grade 80; 60 is the right default and it + # is a DEFAULT, never an identity. + # + # So `default_fy_ksi` is null for a designation that has no + # defensible default, and `rebar_fy()` reports WHERE its answer + # came from -- stated, or an ASTM default -- so a default can + # never be read as a measurement. A check that needs fy and gets + # None must stand down and say why. Nothing here ever guesses 60. + 'astm_grades': {}, + + # ---- bar grades KNOWN to be on this job ----------------------- + # + # JOB KNOWLEDGE, empty here. A human saying "there is a #9 at + # 80 ksi on this job" -- an existence fact about one call-out, NOT + # a statement that every #9 on the job is 80 ksi. + # + # A MAP keyed by a short id, for the same reason `stock_by_size` + # is one: another such fact is another key. As a list, a second + # declaration would replace the first. + 'grades_present': {}, + + # WHICH MARKS ARE ALLOWED TO EXCEED STOCK. Empty by default and + # NEVER filled in here or in catalogue.yaml -- a mark is this + # plant's word for its own part, so the list belongs in the + # plant or job layer, which is not in this repository. The + # catalogue branch owns the stock NUMBER (public); the + # exemptions sit beside it so a reader finds both in one place. + # + # BY MARK, NOT BY FAMILY, and that is a deliberate departure + # from how `_is_reinforcing` identifies a bar. That function + # asks "is this a bar at all", and family is the right grain + # for it. This asks "is this bar allowed to be longer than a + # stick", which is a property of the PART: measured 2026-08-30, + # one bar family at this plant carries both 11 ft pieces and + # 60 ft ones, so exempting by family would exempt every bar at + # the plant and switch the check off. + # + # Matched EXACTLY, case-insensitively -- not as a substring, or + # an entry for `SR1` would silently swallow `SR1101`, the same + # trap `drawings.called_out_on_detail` already documents. + 'stock_exempt_marks': [], + }, + }, + + # ---- what the parenthetical suffix on a call-out means ------------- + # + # A ticket calls a bar out as `SR324 (F.F.)`, `SR310 (E.L.)`, + # `P76 W/ SR5602 (S.I.F.)`. The suffix is a FACE DESIGNATION: it says + # which face of the piece the bar or embed goes on. Checklist 5.3 asks + # whether the designation printed on the ticket agrees with the side the + # model actually puts the part on. + # + # THE ABBREVIATIONS ARE PUBLIC and identify nobody -- they are the trade's + # -- so the VOCABULARIES ship in `knowledge/catalogue.yaml`, the one layer + # in this repository. This branch is only their shape and its default. + # + # WHICH ONES A PLANT USES IS PLANT KNOWLEDGE (owner, 2026-08-30: 不同的 + # plant 对这个东西的称呼不同 -- TIF/BIF is just as common as NF/FF). So + # the plant layer SELECTS by id, in `face_designations.use`, and does not + # copy the tokens. A plant may select more than one: near/far names a + # LAYER POSITION and in-form names a CASTING ORIENTATION, and a ticket + # that prints `(E.L.)` on one line and `(S.I.F.)` on the next is using + # both at once. + # + # WHY EACH VOCABULARY DECLARES ITS FRAME OF REFERENCE, and why the two + # families must not be flattened into one token -> meaning table: + # + # frame: view near / far is measured from THE VIEWING DIRECTION. It + # flips when the view flips. An elevation that looks at + # the panel from the opposite side than was assumed makes + # every N.F./F.F. on the sheet backwards, and each one + # still reads perfectly normal on its own. + # frame: form top-in-form / bottom-in-form is measured from THE + # CASTING BED. It is absolute for the piece and does not + # care which way the view looks. + # + # Resolving a token therefore needs different evidence depending on which + # vocabulary it came from, and a flat map cannot say which. That + # distinction IS the thing that makes 5.3 decidable, so it is structural + # here rather than a comment. + # + # EMPTY `use` MEANS NO VOCABULARY DECLARED. The 5.3 check then stands + # down and says why -- it never guesses a vocabulary, because guessing + # `view` where the plant meant `form` produces confident, wrong verdicts. + # A LIST, so it REPLACES rather than merges: a job saying it uses one + # vocabulary means one, and a leftover second vocabulary silently still + # in force is precisely how a token gets resolved in the wrong frame. + # + # THE MODEL-SIDE HALF, added 2026-08-31. A face designation is printed on + # a ticket AND recorded on the element, and 5.3 asks whether the two + # agree, so it needs both. Everything below is the second one. + # + # model_tokens what the element's field holds -> the printed + # designation. A MAP, so it deep-merges. It is a + # SEPARATE KEY rather than an alias on a token + # because `tokens` is a list and lists replace: a + # plant aliasing a published token would replace the + # whole published vocabulary. + # read_from ['instance'] and NOT ['instance', 'type']. + # Measured over 180 real tickets: the SAME part code + # carries different tokens on different sheets (24 + # codes do), so the face is a property of the + # PLACEMENT. A type read is not a weaker version of + # an instance read, it is a wrong one -- it collapses + # distinct facts into one and looks clean doing it. + # modifiers tokens that STACK AFTER a face token in the same + # call-out (` (N.F.) (E.S.)`) and say something + # other than which face. A modifier is not a face and + # has no (frame, side); a reader strips it and + # resolves the face underneath. + # not_face_tokens parenthesised groups that look like a face token + # and are not one. The negative has to be written + # down or it comes back as a false positive. + # attaches_to token -> the member kinds it may attach to + # ('embed', 'bar'). Measured, and a real check: at + # one plant S.I.F. is on embeds 177 times out of 177 + # and never on a bar. DATA, not a rule inside a + # check. + # recorded_on the member kinds whose elements record a face at + # all. Empty means NOBODY HAS SAID -- it never means + # "none". It is what tells a blank field on a kind + # that does record faces (unknown) from a blank on a + # kind that never does (does not apply). + # top_in_form where to read the PER-PIECE value saying which + # building face ends up on top in the form. See + # `top_in_form_rule()`; dormant until somebody reads + # it, and loudly so, because a check that resolves a + # form-anchored token to a building face without it + # is exactly backwards on every piece of the opposite + # handedness. + # + # NOTHING HERE EVER READS ABSENCE AS A VALUE. There is no key for what a + # blank means, deliberately: measured on both sides, a blank is + # annotation that was not repeated (219 of 461 unmarked ticket call-outs + # use a code tagged elsewhere on the same sheet) or a kind that does not + # use the field. `same_face()` answering None rather than False is the + # instrument for that and it already does. + 'face_designations': { + 'use': [], # e.g. [near_far, in_form] -- ids only + 'frames': {}, # published in catalogue.yaml + 'vocabularies': {}, # published in catalogue.yaml + 'model_tokens': {}, + 'read_from': ['instance'], + 'modifiers': {}, # shape published in catalogue.yaml + 'not_face_tokens': {}, + 'attaches_to': {}, + 'recorded_on': [], + 'recorded_on_unverified_because': None, + 'top_in_form': { + 'label': None, + 'source': None, + 'values': [], + 'verified': False, + 'unverified_because': None, + }, + }, + + # ---- THE PREFIX ON A PART NUMBER SAYS WHICH LAYER DEFINES IT ------ + # + # Owner, 2026-08-31: "厂知识:P 是 plates,SP 是 special plates,P 能从厂 + # 知识里面直接找到是啥。SP 是 job 级别的特殊 plate。" -- P is a standard + # plate, resolvable from plant knowledge; SP is a special plate, made for + # one job. + # + # A LAYER-PLACEMENT RULE EXPRESSED IN THE PART NUMBER ITSELF, which is + # why it is worth a key: everything else in this file has to be told which + # layer owns a fact, and here the name says it. + # + # WHAT IT MAKES CHECKABLE. A part printed on a ticket that nothing can + # look up is TWO DIFFERENT PROBLEMS WITH TWO DIFFERENT OWNERS: a `P` part + # nobody can resolve is a gap in PLANT knowledge, an `SP` part nobody can + # resolve is a gap in JOB knowledge. Reporting both as "unknown part" + # sends half of them to the wrong person. + # + # prefixes prefix -> {means, layer, verified, ...}. A MAP, so a third + # prefix is one added key and a job may add its own. + # + # LONGEST PREFIX WINS -- `SP78` is an SP, not a P that starts with S -- + # and that is not a tie-break, it is the safety. Read the other way it + # merges a job special plate into the plant's standard-plate namespace, + # which is exactly what a substring match on a bare part number does and + # exactly what it cost on the face lifter. + # + # `verified: False` is the usual state: a meaning read off a manual is a + # reading, not a ruling, and it says which it is. A prefix nobody can + # describe at all is left OUT rather than given a placeholder -- an entry + # here is a claim about whose desk a missing definition belongs on. + 'part_numbers': { + 'prefixes': {}, + }, + + # ---- WHAT KIND OF PANEL THIS IS, AND WHY IT IS A KNOWLEDGE KEY ----- + # + # A panel type changes WHICH PART PLAYS WHICH ROLE. That sounds like a + # detail and it is not: on 2026-08-31 the owner said the face lifter on an + # insulated wall is a different part from the one on a solid wall, that + # part was named nowhere in any knowledge layer, and so on every insulated + # panel the tool found ZERO face lifters -- and 6.1, 6.2, 6.3, the + # handling result and Panel Lift Check's face stages all passed in + # silence. It is the same failure the plant file's `lifter_include` + # comment already records from an earlier day, in a population nobody had + # thought to separate. + # + # THE SHAPE, so a third panel type is an addition and not a redesign: + # + # panel_types.types id -> whatever identifies that type. A MAP, so + # a job adds one without restating the plant's. + # panel_types.default the type to assume when nothing classifies a + # piece. **null**, and it should stay null: + # reading an unclassified piece as the ordinary + # type is precisely how an insulated panel came + # to be checked for a lifter it does not carry. + # _by_panel_type a map beside any role's family LIST, narrowing + # it. `families_for()` resolves the pair. + # + # `detect_verified: False` IS NOT "there are no panel types". It is + # "nothing can tell them apart yet", and while it is false every role + # resolves to the UNION of every declared type. The union is the safe + # direction for RECOGNISING a part -- a missing family is a silent pass on + # a safety check, an extra one is a visible finding about a real insert -- + # and it is NOT safe for COUNTING one, which is why + # `drawings.parity_note_rule` stays dormant rather than taking it. + # + # ---- HOW A PIECE IS RECOGNISED AS A TYPE: `signals`, added 2026-08-31 -- + # + # A type may declare `signals`, a MAP of MEASURABLE piece facts. Prose + # (`model_signals`) records what somebody measured and is for a reader; + # `signals` is what `classify_panel()` evaluates, and the two are kept + # apart deliberately -- a sentence a human wrote is not a predicate, and + # a file that let one masquerade as the other would classify a piece off + # a description. + # + # signals: + # body_family_contains: [, ...] # case-insensitive substring + # # of the body family's name + # thickness_in: {min: , max: } # inclusive, INCHES + # member_family_contains: [, ...] # a member only this type has + # min_signals: 2 # per type; `panel_types.min_signals` is the + # # table-wide default, and it is 2 + # + # THE RULE, and each half of it was chosen against a specific failure: + # + # * a type is recognised only when EVERY signal that could be evaluated + # AGREES and at least `min_signals` of them could be evaluated. Two, + # not one: the plant file's own note says any single signal could be + # a coincidence of how one job was modelled and that it is three + # mutually independent ones that make it a convention. + # * a fact that was never measured makes its signal UNEVALUABLE, never + # False. Absence is not a value here either. + # * A TYPE THAT DECLARES NO SIGNALS CAN NEVER BE CLASSIFIED INTO. + # Recognising `solid` as "not insulated" is exactly the default this + # branch exists to refuse: it reads an unclassified piece as the + # ordinary type, which is how an insulated panel came to be checked + # for a lifter it does not carry. + # * two types both recognised, or none, is a DECLINE -- said out loud, + # naming every signal and what it said. A piece whose type cannot be + # told gets the FULL checklist. + 'panel_types': { + 'types': {}, + 'default': None, + 'min_signals': 2, + 'detect_verified': False, + 'detect_unverified_because': None, + }, + + # ---- CONNECTORS: what ties an insulated panel's two wythes --------- + # + # The public half -- what a connector IS, and the products -- is in + # `knowledge/catalogue.yaml` under `connectors`. What lives in the PLANT + # layer is this plant's own part numbers and the mapping onto those + # products, exactly as `lifters.catalogue_alias` maps an insert number + # onto `catalogue.inserts`. + # + # RECOGNITION IS BY MARK ON THE TYPE, NOT BY FAMILY NAME, and that is + # measured rather than chosen. Two traps, both live on this job + # (2026-08-31): + # + # 1. THE PART NUMBER IS NOT IN THE FAMILY NAME AT ALL. The pins sit in + # one family whose name says nothing about which connector it is -- + # the inverse of the face-lifter lesson, where the family name was + # the only thing that DID carry it. A family-name key finds nothing. + # 2. A DECOY FAMILY EXISTS, literally named for the part number, + # carrying the same mark, WITH ZERO INSTANCES. Matching on family + # name finds the empty decoy and misses every real pin -- a silent + # ZERO on a check whose whole job is to notice zero. That is the + # worst failure this key could have. + # + # So `marks` are matched against the MARK ROLE READ FROM THE TYPE, and + # the check COUNTS INSTANCES. Counting instances is immune to the decoy + # (it contributes none); anything enumerating TYPES would double-count. + # + # `component_gate` is an optional second signal on the same type -- the + # plant's manufacture-component value -- kept because it is what solved + # the census token, and left optional because a plant that does not fill + # it in must not lose the check. + 'connectors': { + # {group name: [part number, ...]}. A MAP of groups, because the + # owner named the numbers in two runs and did not say why they are + # two; recording them as one flat list would erase a distinction he + # made, and inventing a reason for it would be worse. + 'marks': {}, + # Read the mark from the TYPE, the INSTANCE, or both, in order. + # Defaults to the type because that is where it was measured. + 'read_from': ['type'], + # Optional: only count a member whose manufacture-component role + # holds this. None means do not gate. + 'component_gate': None, + # This plant's part number -> the `catalogue.connectors.products` + # key it really is. EXACT match, not a substring: R8 must never + # swallow R80. + 'catalogue_alias': {}, + # A plant-wide minimum, SECOND-CLASS on purpose. The published + # per-product number wins; this is used only when there is none, and + # a finding that used it says so -- "6 inches" and "6 inches for + # this product" are different claims. + 'min_edge_distance_in': None, + # False means NOTHING HAS CONFIRMED how connectors are recognised in + # the model, and the three connector checks stand down saying so + # rather than reporting a count nobody has validated. Same idiom as + # `drawings.parity_note_rule.family_verified`. + 'recognition_verified': False, + 'recognition_unverified_because': None, + }, + + # ---- data for computed checks ------------------------------------- + # + # Lifter capacity is a working load in pounds per insert. Keys are matched + # as substrings against the family name, longest match wins. + 'lifters': { + # A direct family-name -> pounds table. Checked first, so it always + # wins over the catalogue; use it for a one-off nobody has catalogued. + # + # A value here (or in `lifting_anchors.parts`, the plant's own + # tested chart -- see `lifter_capacity`'s docstring) is a bare + # number for either kind, OR a dict {tension_lb, shear_lb} (or the + # angle-split `tension__lb` shape) when the two differ. THREE + # STATES a `tension_lb` / `shear_lb` FIELD can hold, added + # 2026-09-04 -- see `_field_rating_state` and `lifter_capacity`'s + # own "THREE STATES" note for the full story: + # + # a number taken as stated, including a real published + # zero -- some inserts genuinely rate zero on one + # axis. + # 0 / None / '' / '-' NOT RATED HERE, not a real zero. A plant + # chart that prints a bare Zero for a face + # (back-cast) lifter is usually stating the + # EDGE-cast figure, not "no capacity" -- reading + # it as a rating once reported a real anchor as + # zero-shear and failed a piece its own hardware + # carries fine. `lifter_capacity()` tries the + # back-cast footnote first (shear only), then the + # next layer (override -> plant chart -> published + # catalogue), before conceding `None`. + # 'forbidden' / false an explicit, deliberate zero -- this part + # may never be loaded that way at all. The ONLY + # way to get a real `0` back out of a dict-shaped + # entry; a bare printed `0` is never one on its + # own any more. + 'capacity_lb': { + # 'ANCHOR_FACE_4T': 8000, + }, + # This plant's part number -> the catalogue designation it really is. + # Matched as substrings against the family name, longest match wins. + 'catalogue_alias': { + # 'P000': 'SAMPLE_ANCHOR_4T', + }, + # Global factor applied to the piece weight before comparing. + 'dynamic_factor': 1.5, + # A lifter closer than this to an end, in inches, gets flagged. + 'min_edge_distance_in': None, + }, + + 'concrete': { + # Release strength must be at least this fraction of f'c. + 'min_fci_ratio': None, + }, + + # Lifting and handling analysis (panellift). + 'handling': { + # Where the HTML reports are written. None puts them in a 'handling' + # folder beside the model, with the rest of the QC data. An absolute + # path sends them anywhere -- a job folder on a share, say. + 'report_dir': None, + # The load multipliers live on the stages below, one each, because + # that is where they are used. A single pair of keys here could not + # describe a plant that runs four stages, and it matters which number + # is which: another program on the same panel used 1.4 for both + # stripping and erection where PCI Table 8.3.1 gives 1.3 and 1.2. + # The handling tensile limit, as a MULTIPLE OF sqrt(f'c) in psi: the + # allowable is this number times the square root of the concrete + # strength at the stage, so 5.0 on 4000 psi concrete is 316 psi. + # + # There are two schools and both are defensible: + # + # 5.0 the modulus of rupture over a 1.5 safety factor -- "no + # discernible cracking". Conservative, and what this has + # always defaulted to. + # 7.5 the rupture modulus itself: first cracking, no margin. + # PCI-permitted, and what a commercial check of one of these + # panels was set to. + # + # The difference is a factor of 1.5 on EVERY stress verdict, which + # decides whether a piece passes far more often than any refinement + # of the analysis does -- it accounted for most of the disagreement + # between this tool and that commercial check on the same panel. + # + # The owner declined to pick one for everybody, 2026-08-30: + # + # "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去, + # 让用户自己填写。I can see it both ways." + # + # So the engineer fills it in per job: Project Config -> Lifting and + # handling -> Stress limit, which writes it to the job layer. This + # 5.0 is only what happens when nobody has said anything, and it is + # the behaviour every existing job file already has. A stage in + # `handling.stages` may state its own, which beats this for that + # stage; the report names which one it used. + # + # THIS 5.0 IS THE SHIPPED DEFAULT, NOT THE ONLY DEFENSIBLE ANSWER, + # and the difference matters because the two numbers are two real + # schools rather than a value and a mistake. A plant layer stating + # 7.5 is not one edit away from wrong -- the owner ruled 7.5 for the + # plant this tool was built against, on 2026-08-19, before the + # 2026-08-30 ruling above made the knob per-job. The later ruling + # decided WHERE THE SETTING LIVES; it did not retract the value, and + # the two stand together. So: + # + # - do not "align" a layer holding 7.5 down to this 5.0; + # - do not raise this 5.0 to 7.5 either. With no ruling from + # anybody, the conservative school is the one to ship. + # + # Both numbers are attributed where they are set. If you are about to + # ask which one applies, search the transcripts first -- CLAUDE.md 7. + # That question has already been asked once after it was answered. + 'tension_limit_sqrt_fc': 5.0, + # The compression allowable, ratio*f'c in psi -- the exact sibling of + # `tension_limit_sqrt_fc` above, per the owner's ruling closing the + # question, 2026-09-01: "0.6保持可调" ("0.6 stays adjustable"), same + # per-job field, same place in Project Config. + # + # 0.6 is ACI 318-19 Table 24.5.3.1 ("all other locations", 0.60*f'ci + # at transfer) and Sec. 24.5.4.1 (0.60*f'c in service) -- found in + # `knowledge/library/notes/standards/ACI318-19-ch24.md` at the + # owner's direction, 2026-09-01. ACI Table 24.5.3.1 also permits a + # higher 0.70*f'ci AT MEMBER ENDS; this tool does not implement that + # distinction on the owner's own ruling the same night -- + # "先不用实现0.7...别搞这么复杂" ("don't implement 0.7 for now, no + # need to make it that complicated") -- so the flat 0.6 is used + # everywhere on the piece, and that is DELIBERATELY the more + # conservative choice, not an oversight. + # + # THIS 0.6 IS THE SHIPPED DEFAULT, not the only defensible answer, for + # the same reason the tension default above is not: an engineer who + # has a reason to relax it per job sets it in Project Config -> Lifting + # and handling -> Compression limit, which writes it to the job + # layer. A stage in `handling.stages` may state its own, which beats + # this for that stage; the report names which one it used. + 'compression_limit_ratio': 0.6, + 'sling_angle_deg': 60.0, + # Where inserts and strands are located, and it is not one answer. + # + # 'fast' takes each element's centre off its cheap cached bounding box + # (get_BoundingBox, no geometry solve), which is most of the time + # spent finding the lifters. 'precise' solves the geometry for a solid + # centroid: identical on a symmetric embed, a few inches away on an + # L-shaped one -- and a face lifter's position IS the moment arm. + # An order of magnitude slower. + # + # The two run modes want opposite answers. A batch runs unattended, so + # the time costs nobody anything and it should be right. One piece at + # a time is somebody waiting at the screen, and a piece that lands + # close enough for a few inches to matter is a piece for Eriksson -- + # not for a sharper number out of this tool. + 'fast_lifter_box': True, # one piece, on demand + 'fast_lifter_box_batch': False, # sweep, batch, Ticket QC's own run + # Equalising blocks ('rolling') or fixed-length slings ('static'). + # Two different mechanics, not a coefficient: on a three-line pick the + # middle reaction differed by 22%. + 'rig_mode': 'rolling', + # How far along the PIECE two inserts of one row may sit and still + # hang from it as one row. Too small splits a staggered row into a + # phantom extra support -- one line goes negative, its neighbour + # overloads; too large merges genuinely separate rows. Set from the + # project config file (decisions.group_tolerance_ft); 1.0 covers + # every stagger measured in the model so far. The Project Config + # template carries a diagram of exactly what it measures. + 'group_tolerance_ft': 1.0, + # Where the piece sits on the trailer in the transportation stage. + # Transportation is supports, not a lift, and the plant says where + # the dunnage goes: + # + # mode: null nobody has said -- the stage keeps the old + # lift approximation and the report says so + # mode: lifter_rows the same support lines as the stripping rows + # mode: fraction inset from each end by `fraction` of the span + # + # Nothing here guesses: null is the old, labelled approximation. + 'dunnage': { + 'mode': None, + 'fraction': None, + }, + # PCI Table 8.3.1 is indexed by these two. They describe how the piece + # comes out of the form, which is a property of the product, not of + # the panel, so they are stated here rather than guessed per piece. + # product_type: flat | flat_reveal | fluted | sculptured + # finish: smooth | exposed + 'product_type': 'flat', + 'finish': 'smooth', + + # ---- what part each insert plays in a lift ---------------------- + # + # 'families' are matched as substrings against the family name. + # 'at' says where the insert acts along the piece: + # + # measured where it is modelled (the default) + # top the top end of the piece as erected + # bottom the bottom end + # + # 'top' is there because an anchor at the panel's top edge acts at the + # edge, while its modelled box centre sits inboard by however long the + # bar welded to it happens to be. On one real piece that was 7 inches, + # and 7 inches of lever arm on the erection pick is not a rounding + # error. Which end is the top is read from the model: the end at the + # higher global elevation. + # + # Empty here: which part number is a face lifter and which is an edge + # anchor is a fact about one plant's hardware. + 'lifter_groups': { + # 'face': {'families': ['ANCHOR_FACE_4T'], 'at': 'measured'}, + # 'edge_top': {'families': ['ANCHOR_EDGE'], 'at': 'top'}, + }, + + # ---- the handling stages a piece is checked through ------------- + # + # Each stage is a load multiplier, the concrete strength at the time, + # and which insert groups carry the piece. 'exclude_top_rows' drops + # that many rows from the top of a group -- a crane that cannot reach + # the top row of face lifters makes erection a different pick from + # stripping, and that is a fact about the site, not the panel. + # + # strength: fci | fc, read off the design summary for the piece. + # + # pci_case says which row of PCI Table 8.3.1 the stage is, and is used + # when multiplier is null. It is one of stripping / transportation / + # yard -- NOT the stage's own name, because a plant may run a stage + # called something else and still be reading the yard row. + # 'rig_mode' on a stage overrides the one above for that stage + # only; None inherits it. Left None everywhere, the piece hangs + # the same way all the way through, which is what a plant running + # one rig wants and is the behaviour this tool has always had. + # + # It is not defaulted to a guess. A commercial check of one real + # piece stripped on fixed slings and erected on an equalising + # block, and its stripping moment was 14.1 k-ft where one rolling + # mode everywhere gives 23.8 -- a factor of 1.7 that follows from + # a plant's rigging practice, not from anything in the model. So + # the engineer sets it, in the project config file where it is + # visible, rather than the tool assuming somebody else's yard. + 'stages': [ + {'name': 'stripping', 'pci_case': 'stripping', 'multiplier': None, + 'strength': 'fci', 'groups': ['face'], 'rig_mode': None}, + {'name': 'transportation', 'pci_case': 'transportation', + 'multiplier': None, 'strength': 'fc', 'groups': ['face'], + 'rig_mode': None}, + {'name': 'erection', 'pci_case': 'yard', 'multiplier': None, + 'strength': 'fc', 'groups': ['face', 'edge_top'], + 'exclude_top_rows': 1, 'rig_mode': None}, + ], + + # CPython 3.9+, which runs the calculation. Revit's IronPython cannot, + # and porting it there would give up the test suite that makes the + # numbers checkable. None searches the usual install locations. + 'python_exe': None, + # How far the geometry we read may be from the weight the model + # states before the whole run is refused. This is the gate that + # catches a misread blockout, and it has to be a refusal rather than + # a warning: an opening we did not see makes the section modulus the + # wrong number in the direction that says a panel is fine. + 'weight_tolerance': 0.02, + # Open the report when it is finished. On by default -- the gap + # between a report being written and being read is one double-click + # that is easy not to take. Turn it off when working through a lot of + # pieces in a row, or forty browser tabs is the result. + 'open_report': True, + + # ---- prestress ------------------------------------------------ + # + # Everything lost between the jack and the stage being checked, as one + # percentage. It is NOT calculated: a time-dependent loss model needs + # an age, a humidity and a curing schedule that none of this knows, + # and a plausible-looking number derived from guessed inputs is worse + # than a stated assumption an engineer can argue with. + # + # It goes in the project config file so it can be argued with. 8% is + # the default and it is a decision, not a measurement: a commercial + # check of one real piece computed 1.6% at release, so 8% is the + # conservative side of what a job might see once relaxation, creep + # and shrinkage are in. Lower it and every piece gets more + # precompression credited, which is the direction that makes a panel + # look safe. + 'prestress_loss_pct': 8.0, + # Where a cut strand has handed its force back to the concrete, in + # inches. None uses ACI 318's f_se * db / 3, computed from the pull + # actually on the design rather than assumed. + 'transfer_length_in': None, + }, + + # Strand section areas by nominal diameter, in inches. Published, and the + # same everywhere; here so the tool can turn '1/2"' on a design summary + # into an area without asking anybody. + 'strands': { + 'area_in2': { + '0.375': 0.085, + '0.4375': 0.115, + '0.5': 0.153, + '0.5625': 0.192, + '0.6': 0.217, + }, + }, + + # Reinforcing running through other reinforcing, within one assembly. + # Off by default: it is the only check that solves geometry, and on a + # model that draws bars continuously through strands it reports dozens of + # by-design crossings per piece until ignore_family_pairs is filled in. + 'interference': { + 'check': False, + # Two bodies must overlap by more than this, on every axis, to count. + 'tolerance_in': 0.125, + # Confirm every box hit with a real solid intersection. Leave this on: + # a bounding box is the product of three intervals, so a bent bar's + # box is the whole U INCLUDING the hole, and anything passing through + # that hole reads as an interference. On one real piece boxes claimed + # 265 and only 86 were real. Turning it off is faster and wrong. + 'exact': True, + # Categories in which two instances of ONE family may legitimately + # overlap. Some hardware is modelled as several instances placed to + # touch on purpose, so that creating the assembly picks them up + # together -- a modelling technique, not a defect. Empty by default: + # which categories those are is a fact about how a plant models, and + # nothing here should assume it. + 'same_family_ok_categories': [], + # Only report pairs from the SAME Revit category. Reinforcing meeting + # an embed, or a bar running into a ledge, is how the piece goes + # together; two things of one kind in one place is the defect. Off by + # default -- it does hide real cross-category problems, and that is a + # project's call to make. + 'within_category_only': False, + # Families expected to cross, 'A|B', matched as substrings both ways. + 'ignore_family_pairs': [], + # Families nothing is ever reported against, matched as substrings. + 'exempt_families': [], + # Hardware that arrives as several families but is one part. A + # connection is a plate, its rebar, its sleeve -- modelled to touch + # because they are installed as a unit. Revit records none of that + # ownership, so it is stated here: + # + # - name: TOP OF PANEL TO COLUMN + # families: [SAMPLE_PLATE, SAMPLE_BAR] + # interferes: within_group # may overlap each other, not others + # - name: SOMETHING_SET_OUT_AS_A_UNIT + # families: [SAMPLE_UNIT] + # interferes: false # never reported, with anything + 'component_groups': [], + # Mean thickness the shared material must exceed. Defaults to + # tolerance_in. See ticketqc.intersection_depth for why this is a + # thickness and not a bounding box. + 'min_thickness_in': None, + # A lifting insert is set out with a bar to either side, and those + # bars belong to the insert rather than to the reinforcing schedule -- + # the plant places them and may move them freely, so there is nothing + # to check them against. They are the same family and run the same way + # as the ordinary bars of that size, so the only thing that tells them + # apart is that they hug an insert. + # Found by symmetry, not by proximity: reinforcing comes in mirrored + # layers, so an ordinary bar has a counterpart at the same station in + # another layer and an insert's bar does not. An unpaired bar within + # reach of an insert belongs to it; one that is unpaired and near + # nothing is reported, because the drawing does not explain it. + 'insert_bars': { + 'insert_families': [], # the insert + 'bar_families': [], # what its bars are modelled as + 'within_of_insert_in': 5.0, # how far its bars may sit from it + 'station_tol_in': 1.0, # two bars at "the same" station + 'layer_tol_in': 0.75, # two bars in "the same" layer + }, + 'max_reported': 10, + }, +} + + +def catalogue_path(): + """The published hardware data that ships with the tool. + + The one knowledge layer that IS in this repository, because it is the one + that identifies nobody: manufacturers' catalogue data, the same for + everyone who buys the part. It is still a file rather than code so that + correcting a rating is an edit. + """ + here = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) # ...\WillTools.extension + return os.path.join(os.path.dirname(here), 'knowledge', 'catalogue.yaml') + + +def load(doc): + """Built-in defaults, the catalogue, the plant, the job, the project file. + + Five layers, each overriding the one before it key by key: + + DEFAULTS what the code assumes + knowledge/catalogue.yaml published data (in the repo) + /[/] what THAT PLANT always does + knowledge.yaml + /_ticketqc/ what this job does + knowledge.yaml + the project config file what the engineer set, and where + + The job layer sits inside the `_ticketqc` folder beside the model, not + loose beside it -- `designspec.knowledge_path()` is the one place that + spelling lives, and `designspec.root_dir()` is what decides where + `_ticketqc` itself is (WILLTOOLS_JOBS wins; APPDATA is the fallback for a + model with no path yet). Never build this path by hand. + + so a plant states its naming conventions and bed layout once and a job + only has to record what is unusual about itself. Only the first two are + part of this repository: between them the other three are the whole + description of one office's template, and that is exactly what must not + be published. + + THE PLANT LAYER IS PER PLANT. `job.plant` in the job file names a folder + under the plant root and a second plant is a new folder, not an edit to + the first one's file. A job that names no plant reads the machine-wide + `/knowledge.yaml`, which is what every install had before the key + existed. See `designspec.plant_knowledge_path` for the three cases and + the two-pass note below for why the name has to be read early. + + The last layer is the file Project Config writes. It is opt-in -- a model + has one only once somebody has pressed that button -- and it is deliberately + last, because it is the layer a human edited most recently and by hand. It + wins. + + pyRevit's YAML reader hands back every scalar as a string -- 1.5 arrives as + '1.5' and null as 'null' -- so values are coerced here rather than at each + call site, where a stray string would turn into an arithmetic error deep + inside a check. + """ + from vibe import designspec + merged = _deep_copy(DEFAULTS) + # Every participating layer is named as it is merged. A file that is + # there and will not parse is skipped -- but it leaves a marker, because + # a layer that fails to load looks exactly like a layer that agrees with + # the defaults. See `_merge_file`. + _merge_file(merged, catalogue_path(), 'catalogue') + + # ---- the two-pass, and why it is not an accident ------------------ + # + # `job.plant` names WHICH PLANT this job is produced at, and that name + # chooses the plant file. But the job layer merges AFTER the plant layer, + # so by the time the name is in `merged` the plant file has already been + # read -- from the wrong path. The dependency runs backwards through the + # merge order and no ordering of these three lines fixes it. + # + # So the job file (and the config file above it, which may override the + # same key) is READ ONCE, on its own, before anything is merged, purely + # to answer "which plant". `plant_id()` does that and nothing else. + # + # DO NOT "simplify" this into a single pass. Deleting the pre-pass makes + # the tool read the machine-wide plant file while the job file plainly + # says which plant it is for, and every report still looks normal. + # `_plant_layer_stale` below is the tripwire that catches it if someone + # does: the plant the file was chosen by, compared against the plant the + # merged tree ends up naming. + choice = plant_choice(doc) + plant, plant_path = choice['plant'], None + merged['_plant_basis'] = choice['basis'] + if choice['path']: + # The project config named the plant knowledge file itself. + plant_path = choice['path'] + _merge_file(merged, plant_path, 'plant') + elif plant is None: + # No plant named -- the ordinary case, and the path this function has + # always used. Quiet on purpose: every install that predates the key + # is in exactly this state, and a warning printed on every run of + # every install is a warning nobody reads. + # + # ONE EXCEPTION, and it is not about the plant name: if somebody has + # explicitly set WILLTOOLS_KNOWLEDGE and the file it names is not + # there, that is a definite finding. An operator pointed the tool at + # a file; the tool did not read it; nothing else would ever say so. + plant_path = designspec.plant_knowledge_path() + if (os.environ.get(designspec.PLANT_ENV) + and plant_path and not os.path.isfile(plant_path)): + merged['_plant_env_missing'] = plant_path + _merge_file(merged, plant_path, 'plant') + else: + plant_path = designspec.plant_knowledge_path(plant) + if plant_path and os.path.isfile(plant_path): + _merge_file(merged, plant_path, 'plant') + else: + # Same discipline as `_config_missing` below: NEVER fall back to + # the machine-wide plant file. Somebody named a plant; running on + # a different plant's rules, or on none, while the report reads + # normally is the failure this whole codebase is built against. + merged['_plant_missing'] = (plant, plant_path) + + _merge_file(merged, designspec.knowledge_path(doc), 'job') + + config = designspec.active_config_path(doc) + if config: + if os.path.isfile(config): + _merge_config_file(merged, config) + else: + # Never silently fall back to the layers underneath. The run would + # use a configuration nobody chose and look completely normal. + merged['_config_missing'] = config + + _nest_dotted_parameters(merged) + + # Which plant file was actually read, for reports and for Self Check. + merged['_plant_id'] = plant + merged['_plant_path'] = plant_path + + # THE TRIPWIRE. If the finished tree names a plant other than the one the + # plant file was chosen by, the pre-pass and the merge have drifted apart + # -- a layer nobody thought to pre-read has set `job.plant`. Recorded, not + # repaired: re-reading here would hide the drift instead of showing it. + final = get(merged, 'job.plant') + final = (as_text(final).strip() or None) if final is not None else None + # A plant DETECTED off the model leaves `job.plant` unset, and that is + # not drift: only a layer that names a different plant is. + if final is not None and final != plant: + merged['_plant_layer_stale'] = (plant, final) + return merged + + +PRODUCER_NAME_FRAGMENTS = ('precast manufacturer', 'producing plant', + 'precast plant', 'precaster', 'producer', + 'manufacturer') + + +def producer_name(doc): + """The precast producer named on the model's Project Information, read + generically by parameter NAME (one carrying a PRODUCER_NAME_FRAGMENTS + fragment, '_' read as ' '), or None. The value is live model data -- + matched at run time, never written anywhere tracked. Never raises.""" + try: + pinfo = doc.ProjectInformation + for p in pinfo.Parameters: + try: + name = p.Definition.Name.lower().replace('_', ' ') + except Exception: + continue + if not any(f in name for f in PRODUCER_NAME_FRAGMENTS): + continue + try: + value = p.AsString() + except Exception: + value = None + value = as_text(value).strip() if value else '' + if value: + return value + except Exception: + pass + return None + + +def plant_aliases(plant): + """The names a plant folder answers to: its id plus `plant.aliases` from + its own knowledge file, lower-cased, in order. [] when unreadable.""" + from vibe import designspec + out = [] + try: + path = designspec.plant_knowledge_path(plant) + holder = {} + if path and os.path.isfile(path): + _merge_file(holder, path) + names = [plant] + list(get(holder, 'plant.aliases') or []) + for n in names: + n = as_text(n).strip().lower() + if n and n not in out: + out.append(n) + except Exception: + pass + return out + + +def match_plant(name, aliases_by_id): + """(plant id or None, why). Pure, host-testable. + + `name` is the producer name off the model; `aliases_by_id` maps each + plant id to its lower-cased aliases. A plant matches when the words of + one of its aliases are all words of the name, or the name's words are + all words of the alias -- whole words, so 'mc' does not hide inside + 'mcallister'. Exactly one match resolves; none or several declines, + and the sentence says which.""" + text = as_text(name or '').strip().lower() + if not text: + return None, ('the model names no precast producer on Project ' + 'Information') + words = set(re.findall(r'[a-z0-9]+', text)) + hits = [] + for plant in sorted(aliases_by_id or {}): + for alias in aliases_by_id.get(plant) or []: + alias_words = set(re.findall(r'[a-z0-9]+', as_text(alias).lower())) + if alias_words and (alias_words <= words or words <= alias_words): + hits.append(plant) + break + if len(hits) == 1: + return hits[0], ('the producer named on Project Information matched ' + 'plant folder {0!r}'.format(hits[0])) + if not hits: + return None, ('the producer named on Project Information matches none ' + 'of the plant folders {0}' + .format(sorted(aliases_by_id or {}))) + return None, ('the producer named on Project Information matches several ' + 'plant folders {0}; name one in the job file or the project ' + 'config'.format(hits)) + + +def detect_plant(doc): + """(plant id or None, why): the plant folder the model resolves to, in + three levels -- (1) an explicit `job.plant` always wins over all of this, + (2) the producer named on the model's Project Information matched against + each plant folder's aliases, then (3), when level 2 finds nothing, the + names of the model's titleblock families matched against each plant + folder's `plant.titleblock_patterns` (a last resort: titleblock families + are a naming habit, not a declared fact). The `why` names the level that + decided, or both when level 3 follows a level-2 miss. Never raises -- + a model that names nothing falls through to the machine-wide plant file + and the sentence says so. Owner, 2026-09-03: entering a new job must not + run on another plant's rules in silence.""" + from vibe import plantpath + name = producer_name(doc) + aliases = dict((p, plant_aliases(p)) for p in plantpath.plant_ids()) + plant, why2 = match_plant(name, aliases) + if plant is not None: + return plant, why2 + family_names = titleblock_family_names(doc) + patterns = dict((p, plant_titleblock_patterns(p)) + for p in plantpath.plant_ids()) + plant3, why3 = match_titleblocks(family_names, patterns) + if plant3 is not None: + return plant3, why2 + ' -- ' + why3 + return None, why2 + '; ' + why3 + + +def plant_titleblock_patterns(plant): + """The titleblock-family glob patterns a plant folder recognizes: its + `plant.titleblock_patterns` from its own knowledge file, in order. [] + when the key is absent or the file unreadable. Revit-free.""" + from vibe import designspec + out = [] + try: + path = designspec.plant_knowledge_path(plant) + holder = {} + if path and os.path.isfile(path): + _merge_file(holder, path) + for pat in get(holder, 'plant.titleblock_patterns') or []: + pat = as_text(pat).strip() + if pat: + out.append(pat) + except Exception: + pass + return out + + +def titleblock_family_names(doc): + """The distinct names of the model's titleblock families, read with a + SCOPED collector (CLAUDE.md forbids unfiltered ones) off + OST_TitleBlocks. A malformed element is skipped, never raised on -- the + answer is the names that were read, possibly []. Revit-only.""" + names = [] + seen = set() + try: + import Autodesk.Revit.DB as rdb + coll = rdb.FilteredElementCollector(doc) + coll = coll.OfCategory(rdb.BuiltInCategory.OST_TitleBlocks) + coll = coll.WhereElementIsNotElementType() + for el in coll: + try: + name = el.Symbol.Family.Name + except Exception: + continue + name = as_text(name).strip() + if name and name not in seen: + seen.add(name) + names.append(name) + except Exception: + pass + return names + + +def match_titleblocks(family_names, patterns_by_id): + """(plant id or None, why). Pure, host-testable. + + `family_names` is the model's titleblock family names; `patterns_by_id` + maps each plant id to its glob patterns. Case-insensitive on both sides. + A plant hits when ANY of its patterns matches ANY family name. Exactly + one hitting plant resolves; none or several declines, and the sentence + says which. `[]` family_names is its own sentence.""" + names = [] + for raw in family_names or []: + n = as_text(raw).strip() + if n: + names.append(n) + if not names: + return None, 'the model has no titleblocks to read' + hits = [] + hit_name = None + hit_pattern = None + for plant in sorted(patterns_by_id or {}): + for pattern in patterns_by_id.get(plant) or []: + pattern = as_text(pattern).strip() + if not pattern: + continue + for n in names: + if fnmatch.fnmatchcase(n.lower(), pattern.lower()): + hits.append(plant) + hit_name = n + hit_pattern = pattern + break + else: + continue + break + if len(hits) == 1: + return hits[0], ("titleblock family {0!r} matches {1}'s pattern {2!r}" + .format(hit_name, hits[0], hit_pattern)) + if not hits: + return None, ("no titleblock family matches any plant's " + 'titleblock_patterns ({0} families seen)'.format(len(names))) + return None, ('titleblock families match several plants: {0}' + .format(', '.join(hits))) + + +def plant_choice(doc): + """{'plant', 'path', 'basis', 'missing'}: which plant layer this job + reads, why, and -- when job.plant names a folder with no knowledge + file -- the path that is missing (None otherwise). + + Precedence: a plant knowledge FILE chosen in the project config + (`knowledge_sources.plant_file`, the picker in Project Config) > an + explicit `job.plant` (project config over job file) > the plant the + model's producer name detects > nothing named (the machine-wide file, + the old default). `basis` is the sentence a report prints.""" + from vibe import designspec + holder = {} + _merge_file(holder, designspec.knowledge_path(doc)) + config = designspec.active_config_path(doc) + if config and os.path.isfile(config): + raw = {} + _merge_file(raw, config) + # A config file wraps its knowledge in `knowledge:`; the older, + # undocumented shape is the tree itself. + body = raw.get('knowledge') + _deep_update(holder, body if isinstance(body, dict) else raw) + plant_file = get(holder, 'knowledge_sources.plant_file') + plant_file = as_text(plant_file).strip() if plant_file else '' + if plant_file and os.path.isfile(plant_file): + file_holder = {} + _merge_file(file_holder, plant_file) + pid = get(file_holder, 'plant.id') + pid = (as_text(pid).strip() or None) if pid is not None else None + return {'plant': pid, 'path': plant_file, + 'basis': 'the plant knowledge file chosen in the project ' + 'config', 'missing': None} + plant = get(holder, 'job.plant') + plant = (as_text(plant).strip() or None) if plant is not None else None + if plant: + # `missing`: the named plant's folder has no knowledge file. load() + # refuses that (_plant_missing) -- but a form opening the window + # could not tell "no layer names a plant" from "a plant was chosen + # and its folder has since gone" and could only warn on the next + # save (bridge#2, the company machine, 2026-09-07). Named here so + # the window can say so on open. + from vibe import plantpath + ppath = plantpath.plant_knowledge_path(plant) + missing = None if (ppath and os.path.isfile(ppath)) else (ppath or plant) + return {'plant': plant, 'path': None, + 'basis': 'job.plant named in the job file / project config', + 'missing': missing} + detected, why = detect_plant(doc) + return {'plant': detected, 'path': None, 'basis': why, 'missing': None} + + +def plant_file_in_use(know): + """The plant knowledge file THIS merged tree was actually built from -- + the one an error message should tell the user to edit. + + `load()` records it as `_plant_path` (per-plant folder when the job names + a plant, the machine-wide file otherwise). Before this existed, two + buttons formatted `designspec.plant_knowledge_path()` with no plant into + their "add it to " messages, which is the machine-wide file by + definition -- so on a job that named a plant the message sent the user + to a file that already had the key (issue #14, 2026-09-05: an hour lost + checking the environment because the file the message named looked + fine). Falls back to the machine-wide path only when the tree carries + no record, e.g. a caller that built `know` by hand.""" + from vibe import plantpath # Revit-free; designspec re-exports this + path = None + if isinstance(know, dict): + path = know.get('_plant_path') + if path: + return path + return plantpath.plant_knowledge_path() + + +# ------------------------------------------------- plant layer completeness +# The data gaps below are found here, at setup time, instead of arriving as a +# per-piece NOT RUN from a button the first time somebody presses it. Each +# finding cites the button that refuses without the key and ends with "-- edit +# " pointing at the plant file actually read (issue #14's discipline: +# naming the machine-wide file on a job that reads a per-plant one sent people +# to a file that already had the key). + +# The handling stage names that carry the plant's load-multiplier wording. +_DUNNAGE_MODES = ('lifter_rows', 'fraction') + + +def plant_layer_gaps(know): + """Plant-owned configuration gaps in a merged tree, as finding dicts. + + Each dict is {'key', 'needed_by', 'severity', 'text'}; `text` ends with + '-- edit ' so the reader is sent to the file that really was + read. Empty when the tree names everything the buttons need. Pure dict + logic -- no Revit imports, no doc -- so Self Check can run it off the + merged tree and the tests can run it off a hand-built tree. + """ + if not isinstance(know, dict): + know = {} + where = '-- edit {0}'.format(plant_file_in_use(know)) + groups = get(know, 'handling.lifter_groups', {}) + face = (groups or {}).get('face') or {} + edge = (groups or {}).get('edge_top') or {} + out = [] + + if not (face.get('families') or []): + out.append({ + 'key': 'handling.lifter_groups.face.families', + 'needed_by': 'Panel Lift Check', + 'severity': 'FAIL', + 'text': ('handling.lifter_groups.face.families is empty, so no ' + 'family is known to be a face lifter and Panel Lift ' + 'Check cannot row the stripping picks. {0}'.format(where)), + }) + if not (edge.get('families') or []): + out.append({ + 'key': 'handling.lifter_groups.edge_top.families', + 'needed_by': 'Panel Lift Check', + 'severity': 'WARN', + 'text': ('handling.lifter_groups.edge_top.families is empty, so ' + 'no family is known to be an edge anchor; the erection ' + 'stage hangs off edge anchors and empty means erection ' + 'uses the face lifters only. {0}'.format(where)), + }) + + stages = get(know, 'handling.stages', []) or [] + if not stages: + out.append({ + 'key': 'handling.stages', + 'needed_by': 'Panel Lift Check', + 'severity': 'FAIL', + 'text': ('handling.stages is empty, so there is nothing to check ' + 'a piece through and Panel Lift Check refuses to run. ' + '{0}'.format(where)), + }) + + dunnage = get(know, 'handling.dunnage') or {} + if dunnage.get('mode') not in _DUNNAGE_MODES: + out.append({ + 'key': 'handling.dunnage.mode', + 'needed_by': 'Panel Lift Check', + 'severity': 'WARN', + 'text': ('handling.dunnage.mode is not one of lifter_rows / ' + 'fraction, so the transportation stage falls back to the ' + 'old lift approximation and the report says ' + 'approximate. {0}'.format(where)), + }) + + for i, stage in enumerate(stages): + if not isinstance(stage, dict): + continue + # Not `float(value)` in a try: IronPython turns float(None) into a + # .NET NullReferenceException, which surfaces as SystemError and is + # not caught by `except (TypeError, ValueError)` -- issue #17, + # 2026-09-05, Self Check crashed inside Revit while CPython passed. + mult = stage.get('multiplier') + numeric = isinstance(mult, (int, float)) and not isinstance(mult, bool) + if not numeric and isinstance(mult, string_types): + try: + float(mult) + numeric = True + except ValueError: + numeric = False + if not numeric: + name = stage.get('name') or ('handling.stages[{0}]'.format(i)) + out.append({ + 'key': 'handling.stages.{0}.multiplier'.format(name), + 'needed_by': 'Panel Lift Check', + 'severity': 'WARN', + 'text': ('handling.stages.{0}.multiplier is not a number, so ' + 'the {0} stage has no load factor of its own and ' + 'panellift reads PCI Table 8.3.1 by pci_case ' + 'instead. {1}'.format(name, where)), + }) + + lifters = get(know, 'lifters', {}) + if not isinstance(lifters, dict) or 'capacity_lb' not in lifters: + out.append({ + 'key': 'lifters.capacity_lb', + 'needed_by': 'Panel Lift Check / Reinforcing QC', + 'severity': 'WARN', + 'text': ('lifters.capacity_lb is absent (an empty dict is the ' + '"no overrides" shape), so lifter capacities fall back ' + 'to the published catalogue and a plant-only part is ' + 'reported unrated. {0}'.format(where)), + }) + + missing, _not_applicable = unmapped_roles(know) + if missing: + out.append({ + 'key': 'parameters', + 'needed_by': 'every check', + 'severity': 'FAIL', + 'text': ('{0} parameter role(s) are unmapped ({1}), so the ' + 'checks that read them cannot. {2}' + .format(len(missing), ', '.join(missing), where)), + }) + return out + + +def plant_id(doc): + """The plant this job is produced at, read BEFORE any layer is merged -- + see `plant_choice` for the precedence (config plant file > job.plant > + detected off the model's producer name > None). A pre-pass, because the + key that chooses the plant layer lives in a layer that merges after it.""" + return plant_choice(doc)['plant'] + + +def _merge_config_file(merged, path): + """Merge a project config file, which wraps its knowledge in a key. + + The file also carries `project:`, `output_locations:` and + `design_assumptions:` sections, which describe the configuration rather + than being part of it, so merging the document whole would plant junk + branches in the knowledge tree. + + Two of them are not junk though -- they are the sections a person will + actually edit, because they are the ones at the top of the file with + obvious names. So the keys in them that are settings rather than derived + facts are mapped back onto the keys that are really read. A file where + editing the obvious place does nothing is a trap. + """ + holder = {} + # The marker goes on `merged`, not on the holder: the holder is this + # function's scratch space and is thrown away, and an unreadable config + # file is exactly the fact that must survive the call. + _merge_file(holder, path, 'config', notes=merged) + if not holder: + return merged + + body = holder.get('knowledge') + body = body if isinstance(body, dict) else holder + _note_capacity_override_emptied(merged, 'config', path, body) + _deep_update(merged, body) + + locations = holder.get('output_locations') + if isinstance(locations, dict): + report_dir = locations.get('lifting_check_reports') + if report_dir and not locations.get('lifting_check_reports_is_default'): + merged.setdefault('handling', {})['report_dir'] = report_dir + + # Older files carried these two under `design_assumptions:` before the + # decisions block gathered them up. Still read, so a config written by an + # earlier build keeps working. + assumptions = holder.get('design_assumptions') + if isinstance(assumptions, dict): + for key in ('prestress_loss_pct', 'transfer_length_in'): + if assumptions.get(key) is not None: + merged.setdefault('handling', {})[key] = \ + _coerce(assumptions[key]) + + # Mapped back LAST, so the section at the top of the file beats the same + # setting buried in the tree below it. Somebody who edited both meant the + # one they could find. + _merge_decisions(merged, holder.get('decisions')) + return merged + + +def _note_config_problem(merged, message): + """Record a definite finding about the config file, for config_problems. + + Recorded HERE, where the value is judged, rather than re-read later from + the tree: by the time anyone looks at the tree the bad value is gone -- + `_merge_decisions` drops it on purpose and maps the good ones onto + `handling.*`, so there is nothing left to judge. Reporting from the same + branch that does the dropping is what makes the two impossible to drift + apart, and they DID drift: config_problems used to read + `decisions.insert_position` out of the merged tree, a path this merge + never creates, so on a real config file the check could not fire at all. + """ + problems = merged.setdefault('_config_problems', []) + # A file with no `knowledge:` wrapper is merged whole, so its `decisions` + # branch reaches the tree as well and this can be walked twice. One value, + # one sentence. + if message not in problems: + problems.append(message) + return merged + + +def _merge_decisions(merged, decisions): + """The `decisions:` block at the top of a project config, mapped back. + + Every key here is a friendly name for something read from somewhere else + in the tree. The mapping has to exist in both directions or the section is + decoration: a file where editing the obvious place does nothing is a trap, + and this is the most obvious place in the file. + + A value that will not do is dropped, never guessed at, and recorded with + `_note_config_problem` so `config_problems` can say so. A misspelled + setting is not "could not judge" -- it is a definite finding. + """ + if not isinstance(decisions, dict): + return merged + handling = merged.setdefault('handling', {}) + + if decisions.get('report_dir'): + handling['report_dir'] = decisions['report_dir'] + if decisions.get('open_report_when_done') is not None: + handling['open_report'] = _coerce(decisions['open_report_when_done']) + for key in ('prestress_loss_pct', 'transfer_length_in'): + if decisions.get(key) is not None: + handling[key] = _coerce(decisions[key]) + + # `decisions.dunnage` is written into every config file with its own + # VALID VALUES comment and was the one key here nothing mapped back: + # editing the most obvious place in the file did nothing. Merged key by + # key so editing only `mode` does not wipe `fraction`. An explicit null + # mode is a real setting -- the old, labelled approximation -- so + # membership, not truthiness, decides. + dunnage = decisions.get('dunnage') + if isinstance(dunnage, dict) and dunnage: + target = handling.setdefault('dunnage', {}) + for key in ('mode', 'fraction'): + if key in dunnage: + target[key] = _coerce(dunnage[key]) + + # A value that will not parse, or one at or below zero, is left alone + # here and reported -- never guessed at. + tol = decisions.get('group_tolerance_ft') + if tol is not None: + try: + tol = float(tol) + except (TypeError, ValueError): + tol = None + if tol and tol > 0.0: + handling['group_tolerance_ft'] = tol + else: + _note_config_problem( + merged, + 'decisions.group_tolerance_ft is {0!r}; it must be a ' + 'positive number of feet -- how far along the piece two ' + 'inserts of one row may sit and still hang from it as one ' + 'row. It is being ignored, so the run is using the default.' + .format(decisions.get('group_tolerance_ft'))) + + positions = decisions.get('insert_position') + if isinstance(positions, dict): + for name, target in (('one_piece', 'fast_lifter_box'), + ('batch', 'fast_lifter_box_batch')): + value = positions.get(name) + if value is None: + continue + text = str(value).strip().lower() + # A value that is neither is left alone and reported. Guessing + # which one a typo meant is how a setting nobody checked ends up + # deciding a moment arm. + if text in ('fast', 'precise'): + handling[target] = (text == 'fast') + else: + _note_config_problem( + merged, + 'decisions.insert_position.{0} is {1!r}; it can only be ' + '"fast" (the cached bounding box) or "precise" (a solved ' + 'centroid). It is being ignored, so the run is using the ' + 'default for that mode.'.format(name, value)) + elif positions is not None: + # `insert_position: fast` -- one value where two are wanted. Silently + # dropped before; it is a definite finding, so it says so. + _note_config_problem( + merged, + 'decisions.insert_position is {0!r}; it has to name the two run ' + 'modes separately (one_piece: and batch:, each "fast" or ' + '"precise"). It is being ignored entirely.'.format(positions)) + + modes = decisions.get('rig_mode') + if not isinstance(modes, dict): + if modes is not None: + _note_config_problem( + merged, + 'decisions.rig_mode is {0!r}; it has to be a block with a ' + '`default:` in it (and optionally one line per stage). It is ' + 'being ignored entirely.'.format(modes)) + return merged + if modes.get('default'): + handling['rig_mode'] = _checked_rig_mode( + merged, 'default', modes['default']) + # A stage named here overrides the default for that stage only. Left + # blank it inherits, which is what one rig for the whole job looks like. + for stage in handling.get('stages') or []: + name = stage.get('name') + if name in modes and modes[name] is not None: + stage['rig_mode'] = _checked_rig_mode(merged, name, modes[name]) + return merged + + +def _checked_rig_mode(merged, where, value): + """The value as written, plus a finding if it is not one of the two. + + Deliberately still passed through. A rig mode changes the mechanics, not + a coefficient, and the solver already REFUSES an unknown one outright -- + substituting a default here would turn that hard stop into a quiet run on + a rig nobody chose. This only makes the refusal say which line of which + file it came from, before any work starts. + """ + text = str(value) + if text not in ('rolling', 'static'): + _note_config_problem( + merged, + 'decisions.rig_mode.{0} is {1!r}; it can only be "rolling" ' + '(equalising blocks -- every line in a group carries the same ' + 'force) or "static" (fixed-length slings -- a continuous beam on ' + 'rigid supports). A misspelled rig mode is refused, not silently ' + 'inherited.'.format(where, value)) + return text + + +def plant_problems(knowledge): + """What went wrong choosing the plant layer, as sentences. + + Reads the markers `load()` left behind. They cannot be re-derived from + the tree: whether a FILE was there is a fact about the moment it was + looked for, and by the time anyone reads the tree the only evidence that + the plant layer never merged is that its values are missing -- which + looks exactly like a plant that never set them. + + A job that names NO plant produces nothing here, on purpose. The + machine-wide plant file is read, exactly as before this key existed, and + that is the state every install is in. It is recorded rather than warned + about: `_plant_id` is None and `sources()` prints the file that was read, + so the fact is on the page for anyone who looks. A warning on every run + of every existing install would be noise, and noise is how a real warning + stops being read. + """ + out = [] + if not isinstance(knowledge, dict): + return out + + missing = knowledge.get('_plant_missing') + if missing: + plant, path = missing[0], missing[1] + where = (' {0}\n\n'.format(path) if path else + ' -- {0!r} is not a usable folder name, so it does not ' + 'name a file at all.\n\n'.format(plant)) + out.append( + 'This job says it is produced at plant {0!r}, and that plant has ' + 'no knowledge file:\n\n{1}Nothing was read in place of it. The ' + 'machine-wide plant file was NOT used as a fallback -- running ' + 'one plant\'s job on another plant\'s rules is worse than not ' + 'running. Create the folder, or correct `job.plant` in the job ' + 'knowledge file.'.format(plant, where)) + + env_missing = knowledge.get('_plant_env_missing') + if env_missing: + out.append( + 'WILLTOOLS_KNOWLEDGE is set, and the plant knowledge file it ' + 'names is not there:\n\n {0}\n\nNothing was read in its place, ' + 'so this run is on built-in defaults plus the job file alone. ' + 'Point the variable at the file, or at the folder the per-plant ' + 'folders sit in.'.format(env_missing)) + + stale = knowledge.get('_plant_layer_stale') + if stale: + out.append( + 'The plant knowledge was chosen for plant {0!r} but the finished ' + 'configuration says the plant is {1!r}. Some layer sets ' + '`job.plant` that the plant-resolution pre-pass does not read, so ' + 'the plant file in force is the wrong one. See the two-pass note ' + 'in knowledge.load().'.format(stale[0], stale[1])) + return out + + +# The frames a vocabulary may declare. Not a hard-coded vocabulary: the +# frames themselves are published in catalogue.yaml under +# `face_designations.frames`, and this is only the fallback used when that +# branch is missing, so a file that fails to load cannot make every frame +# look valid. +_FALLBACK_FRAMES = ('view', 'form') + + +def _frame_sides(frames, frame): + """The sides a frame can name, or () when the frame does not publish any. + + `frames` entries used to be plain sentences and may still be, in a plant + file written against the older shape. A sentence publishes no side list, + and the check that needs one stands down rather than calling every side + invalid -- an older file must not turn into a page of findings. + """ + entry = (frames or {}).get(frame) + if not isinstance(entry, dict): + return () + sides = entry.get('sides') + if not isinstance(sides, list): + return () + return tuple([s for s in sides if s is not None]) + + +def face_designation_problems(knowledge): + """Whether the selected face-designation vocabularies can be resolved. + + Derived from the merged tree, so no marker is needed: every fact this + judges -- which ids were selected, which are defined, what frame each + declares -- is still in the tree when anyone looks. + + Three ways it goes wrong, and all three are silent otherwise: + + * an id in `use` that nothing defines. The plant thinks it declared a + vocabulary; every token in it is unknown to the check. + * a vocabulary declaring a frame that is not a published one. The + check cannot know what evidence to resolve it against. + * two selected vocabularies claiming the SAME token with different + frames. That token is then undecidable -- and it is decided anyway, + by whichever vocabulary happened to be looked at first. + + Selecting nothing is not a problem here. It means no vocabulary declared, + the 5.3 check stands down and says so itself, and a plant that never + prints a face designation must not be nagged on every run. + """ + out = [] + if not isinstance(knowledge, dict): + return out + branch = get(knowledge, 'face_designations') or {} + use = branch.get('use') or [] + if not isinstance(use, list) or not use: + # Selecting nothing is not a problem -- but stating what the MODEL's + # values mean while selecting no vocabulary to resolve them into is, + # and it is the same fault as the one below. + for model, printed in sorted(face_model_tokens(knowledge).items()): + out.append( + 'face_designations.model_tokens maps the model value {0!r} ' + 'to the printed designation {1!r}, and face_designations.use ' + 'selects no vocabulary at all, so nothing can resolve it.' + .format(model, printed)) + return out + vocabularies = branch.get('vocabularies') or {} + frames = branch.get('frames') or {} + known_frames = tuple(frames.keys()) if frames else _FALLBACK_FRAMES + + seen = {} + for vocab_id in use: + vocab = vocabularies.get(vocab_id) if isinstance(vocabularies, dict) \ + else None + if not isinstance(vocab, dict): + out.append( + 'face_designations.use names {0!r}, and no layer defines a ' + 'vocabulary by that name. Every face designation it was ' + 'meant to explain is unknown. Defined here: {1}.' + .format(vocab_id, + ', '.join(sorted(vocabularies.keys())) or 'none')) + continue + frame = vocab.get('frame') + if frame not in known_frames: + out.append( + 'Face-designation vocabulary {0!r} declares frame {1!r}, ' + 'which is not one of {2}. A vocabulary\'s frame says what its ' + 'tokens are measured FROM -- the viewing direction or the ' + 'casting bed -- and nothing can resolve a token without it.' + .format(vocab_id, frame, ', '.join(sorted(known_frames)))) + # A token's `side` has to belong to its vocabulary's FRAME. This + # catches the half-edit: somebody who flips a published vocabulary's + # frame from view to form and leaves `side: near` behind gets a + # vocabulary that parses, resolves, and answers nonsense. + allowed = _frame_sides(frames, frame) + if allowed: + for entry in vocab.get('tokens') or []: + if not isinstance(entry, dict): + continue + side = entry.get('side') + if side is not None and side not in allowed: + out.append( + 'Face designation {0!r} in vocabulary {1!r} is ' + 'side {2!r}, which is not a side of frame {3!r} ' + '({4}). A side and the frame it is read in have to ' + 'agree or the token cannot be placed on the piece ' + 'at all.' + .format(entry.get('token'), vocab_id, side, frame, + ', '.join(allowed))) + for entry in vocab.get('tokens') or []: + if not isinstance(entry, dict): + continue + token = entry.get('token') + if token is None: + continue + first = seen.get(token) + if first is None: + seen[token] = (vocab_id, frame) + elif first[1] != frame: + out.append( + 'Face designation {0!r} is claimed by two selected ' + 'vocabularies in different frames of reference: {1!r} ' + '({2}) and {3!r} ({4}). One is measured from the view and ' + 'the other from the form, so the token cannot be resolved ' + 'at all -- drop one of the two from ' + 'face_designations.use.' + .format(token, first[0], first[1], vocab_id, frame)) + + # A FOURTH WAY, added 2026-08-31 with the model-side field: the plant maps + # what its MODEL writes onto what its TICKET prints + # (`face_designations.model_tokens`), and a mapping pointing at a token no + # SELECTED vocabulary carries is dead. Every element carrying that value + # then reads as unresolvable, one element at a time, with nothing saying + # the fault is in the configuration rather than in the model. It is the + # `use`-names-nothing failure wearing a different coat, so it is reported + # in the same place. + for model, printed in sorted(face_model_tokens(knowledge).items()): + if face_designation(knowledge, printed) is not None: + continue + out.append( + 'face_designations.model_tokens maps the model value {0!r} to ' + 'the printed designation {1!r}, and no vocabulary in ' + 'face_designations.use carries {1!r}. Every element holding ' + '{0!r} is unresolvable, and nothing else would say the fault is ' + 'in the configuration rather than in the model.' + .format(model, printed)) + return out + + +def face_vocabularies(knowledge): + """The face-designation vocabularies this plant actually uses, in order. + + [(id, vocabulary)] for every id in `face_designations.use` that a layer + defines, skipping the ones it does not -- those are reported by + `face_designation_problems`, and returning a half-built vocabulary here + would let a caller resolve tokens as if nothing were wrong. + + Empty means NO VOCABULARY DECLARED. It never means "no designations + used": a caller that gets an empty list must stand down and say so, not + fall back to a set it invented. + """ + branch = get(knowledge, 'face_designations') or {} + use = branch.get('use') or [] + vocabularies = branch.get('vocabularies') or {} + if not isinstance(use, list) or not isinstance(vocabularies, dict): + return [] + out = [] + for vocab_id in use: + vocab = vocabularies.get(vocab_id) + if isinstance(vocab, dict): + out.append((vocab_id, vocab)) + return out + + +def face_designation(knowledge, token): + """What one printed designation means, and WHAT IT IS MEASURED FROM. + + Returns a dict -- token, means, side, frame, vocabulary, measured_from -- + or None when no selected vocabulary carries the token. None is a real + answer and a caller must print it as one: an unrecognised designation is + "this tool does not know that abbreviation", never "the designation is + wrong". + + `frame` comes back with every lookup on purpose. `side` alone is not an + answer -- 'near' is only meaningful once you know it is measured from the + view and therefore flips with it, while 'top' is measured from the bed + and does not. A caller that reads `side` and drops `frame` has thrown + away the half of the answer that makes checklist 5.3 decidable. + + Matched case-insensitively on the token as printed, with surrounding + whitespace and any wrapping parentheses already stripped by the caller. + """ + if token is None: + return None + wanted = as_text(token).strip().upper() + if not wanted: + return None + for vocab_id, vocab in face_vocabularies(knowledge): + for entry in vocab.get('tokens') or []: + if not isinstance(entry, dict): + continue + printed = entry.get('token') + if printed is None: + continue + if as_text(printed).strip().upper() != wanted: + continue + return { + 'token': printed, + 'means': entry.get('means'), + 'side': entry.get('side'), + 'frame': vocab.get('frame'), + 'measured_from': vocab.get('measured_from'), + 'vocabulary': vocab_id, + } + return None + + +# --------------------------------- the face the MODEL records, not the ticket +# +# A face designation has two carriers and they are not the same thing: +# +# the TICKET prints it `P76 W/ SR5602 (S.I.F.)` -- a parenthetical +# suffix, in the vocabulary the plant selected. +# the MODEL holds it a shared parameter on the element, measured +# 2026-08-31: a String, on the INSTANCE, and every +# family TYPE carries exactly one value -- a +# far-face embed and a side-in-form embed are +# different types. +# +# Checklist 5.3 asks whether the two agree, so it needs both, and the model +# half is what was missing. IT IS NOT THE DRAWN LINE. The solid-versus-dashed +# rendering on the sheet is the same fact one layer further from the data +# (it is carried by Object Styles, per subcategory, project-wide), and a view +# also carries hand-drafted detail curves with line styles of their own, which +# are not a rendering of any member at all. +# +# WHY A SEPARATE MAP RATHER THAN AN ALIAS ON THE TOKEN. The model's token is +# not the printed one -- `FF` against `F.F.` -- and the printed tokens live in +# published vocabularies whose `tokens` are a LIST. Lists replace, so a plant +# adding one alias to a published vocabulary would replace the whole thing: +# exactly the half-edit `face_designation_problems` exists to refuse. So the +# plant states a MAP from what its model writes to what its ticket prints, the +# map deep-merges, and the printed token then resolves through the vocabulary +# machinery unchanged. Two facts, two keys. + + +FACE_ROLE = 'member.face_designation' + + +def _face_branch(knowledge): + branch = get(knowledge, 'face_designations') + return branch if isinstance(branch, dict) else {} + + +def face_model_tokens(knowledge): + """{model value (upper) -> printed token} for this plant, or {}. + + Empty means NOBODY HAS STATED THE MAPPING. It never means the model + records no face: a caller that gets {} must stand down and say so. + """ + raw = _face_branch(knowledge).get('model_tokens') + if not isinstance(raw, dict): + return {} + out = {} + for key, value in raw.items(): + if key is None or value is None: + continue + model = as_text(key).strip().upper() + printed = as_text(value).strip() + if model and printed: + out[model] = printed + return out + + +def face_read_from(knowledge): + """Where the model's face field is read: ['instance'] by default. + + IT IS NOT ['instance', 'type'], and that is the one place this differs + from `drawings.reinforcing_rule` -- which really is both, because there + the two are mutually exclusive per element. + + Measured over 180 real tickets, 85 unique: the SAME part code carries + DIFFERENT face tokens on different sheets. Twenty-four codes do; one bar + code is `(E.L.)` on one ticket and `(N.F.)` on another. So the face is a + property of the PLACEMENT, not of the part. A type-level read is not a + weaker version of an instance read -- it collapses distinct facts onto one + answer and returns something that looks perfectly clean. + """ + raw = _face_branch(knowledge).get('read_from') + if not isinstance(raw, list) or not raw: + return ['instance'] + return [as_text(v).strip().lower() for v in raw if as_text(v).strip()] + + +def face_modifier(knowledge, token): + """A trailing group that MODIFIES a face rather than naming one. + + Returns the declared entry (with whatever a layer wrote into it -- at + minimum `means`, `means_verified` and `position`) or None. + + WHY MODIFIERS ARE A SEPARATE SET. A call-out can stack two parenthesised + groups -- `P106 (S.I.F.) (E.S.)`, measured 35 times across 34 documents, + always after a face token, never instead of one. A vocabulary models a + token as naming a (frame, side); a group that is orthogonal to the face + axis has nowhere to live in that, and a reader expecting exactly one + group MIS-PARSES the pair. So a modifier is declared, strippable, and + carries no side at all. + + `means_verified: false` is the ordinary dormant-and-loud flag: a plausible + reading of an abbreviation is a reading, not a fact, and nothing here may + act on an unverified one. + """ + if token is None: + return None + wanted = as_text(token).strip().upper() + if not wanted: + return None + table = _face_branch(knowledge).get('modifiers') + if not isinstance(table, dict): + return None + for key, entry in table.items(): + if as_text(key).strip().upper() == wanted: + out = dict(entry) if isinstance(entry, dict) else {} + out['token'] = key + return out + return None + + +def face_non_token(knowledge, token): + """A group DECLARED not to be a face designation, or None. + + The negative has to be written down. `(O.H.)` is a real trailing group at + one plant -- 15 of them -- that qualifies a DIMENSION and never attaches + to a part call-out, and it pattern-matches a face token exactly. Without + a place to record "this is not one", it comes back as either a false + positive or an unexplained unknown on every sheet that carries it. + """ + if token is None: + return None + wanted = as_text(token).strip().upper() + if not wanted: + return None + table = _face_branch(knowledge).get('not_face_tokens') + if not isinstance(table, dict): + return None + for key, entry in table.items(): + if as_text(key).strip().upper() == wanted: + out = {'token': key, 'why': None} + if isinstance(entry, dict): + out.update(entry) + out['token'] = key + elif entry is not None: + out['why'] = as_text(entry) + return out + return None + + +def split_face_callout(knowledge, groups): + """Sort one call-out's parenthesised groups into face / modifier / other. + + `groups` is the groups as printed, in order -- for + `P106 (S.I.F.) (E.S.)` that is `['S.I.F.', 'E.S.']`. + + Returns a dict: + + face the `face_designation()` dict for the one face token, or + None + token that token as printed, or None + modifiers the declared modifier entries, in order + not_faces groups DECLARED not to be faces (a dimension qualifier) + unknown groups nothing recognised. NEVER SILENTLY DROPPED. + why one sentence + + An unknown group is could-not-judge, not noise. A reader that ignores what + it does not recognise cannot tell "this call-out names no face" from "this + call-out names a face in words nobody wrote down", and those want opposite + actions. Two face tokens in one call-out are likewise refused rather than + resolved by taking the first. + """ + out = {'face': None, 'token': None, 'modifiers': [], 'not_faces': [], + 'unknown': [], 'why': None} + faces = [] + for group in (groups or []): + text = as_text(group).strip() if group is not None else '' + if not text: + continue + face = face_designation(knowledge, text) + if face is not None: + faces.append(face) + continue + modifier = face_modifier(knowledge, text) + if modifier is not None: + out['modifiers'].append(modifier) + continue + non = face_non_token(knowledge, text) + if non is not None: + out['not_faces'].append(non) + continue + out['unknown'].append(text) + + if len(faces) > 1: + out['why'] = ('this call-out carries {0} face designations ({1}) and ' + 'a part is on one face, so none was chosen. Taking the ' + 'first would decide it by print order.' + .format(len(faces), + ', '.join([f['token'] for f in faces]))) + return out + if faces: + out['face'] = faces[0] + out['token'] = faces[0]['token'] + tail = [] + if out['modifiers']: + tail.append('modifiers stripped: {0}' + .format(', '.join([m.get('token') or '?' + for m in out['modifiers']]))) + if out['not_faces']: + tail.append('declared not to be face designations: {0}' + .format(', '.join([n.get('token') or '?' + for n in out['not_faces']]))) + if out['unknown']: + tail.append('NOT RECOGNISED, and not ignored: {0}' + .format(', '.join([repr(u) for u in out['unknown']]))) + if out['face'] is not None: + head = ('{0} ({1}), read in the {2} frame' + .format(out['token'], out['face'].get('means'), + out['face'].get('frame'))) + else: + head = 'no face designation in this call-out' + out['why'] = head + ('. ' + '; '.join(tail) + '.' if tail else '.') + return out + + +def face_attachment_verdict(knowledge, token, kind): + """May this face designation attach to this kind of member? + + (True / False / None, sentence). `kind` is a short word a caller + classifies the member into -- 'embed', 'bar' -- and the table is + `face_designations.attaches_to`, a MAP of token -> list of kinds, so a job + adds one token's rule without restating the plant's. + + This is DATA rather than a rule inside a check because it was measured and + it is exact: at one plant `S.I.F.` attaches to an embed 177 times out of + 177 and never to a bar, and `E.L.` attaches only to bars. A `S.I.F.` on a + reinforcing element is, on that evidence, wrong by construction -- which + is a real finding waiting for somebody to write the check, and a fact that + would rot inside one. + + None where nothing is declared for the token, or where the caller could + not classify the member. Never False for want of data: "no rule written + down" and "that is not allowed" are opposite answers. + """ + table = _face_branch(knowledge).get('attaches_to') + if not isinstance(table, dict) or not table: + return None, ('face_designations.attaches_to is empty, so nothing ' + 'says which kinds of member a face designation may ' + 'attach to.') + wanted = as_text(token).strip().upper() if token is not None else '' + allowed = None + for key, value in table.items(): + if as_text(key).strip().upper() == wanted: + allowed = value + break + if not isinstance(allowed, list) or not allowed: + return None, ('face_designations.attaches_to says nothing about ' + '{0!r}, so whether it may attach here is not written ' + 'down.'.format(token)) + allowed = [as_text(v).strip().lower() for v in allowed + if as_text(v).strip()] + if kind is None or not as_text(kind).strip(): + return None, ('{0!r} attaches to {1}, and this member was not ' + 'classified, so the two could not be compared.' + .format(token, ', '.join(allowed))) + got = as_text(kind).strip().lower() + if got in allowed: + return True, ('{0!r} attaches to {1}, and this member is classified ' + '{2}.'.format(token, ', '.join(allowed), got)) + return False, ('{0!r} attaches only to {1}, and this member is classified ' + '{2}. Measured over a whole ticket corpus with no ' + 'exception, so this is wrong by construction rather than ' + 'unusual.'.format(token, ', '.join(allowed), got)) + + +def face_recorded_on(knowledge): + """The member kinds whose elements record a face at all, and why not. + + Returns (kinds, why). An EMPTY list means nobody has written it down -- + never "no kind records one". The distinction is the whole reason this + exists: a blank field on a kind that DOES record faces is unknown, and a + blank on a kind that never does is not applicable, and those two want + different sentences in a report. + """ + branch = _face_branch(knowledge) + kinds = branch.get('recorded_on') + if isinstance(kinds, list) and kinds: + return [as_text(k).strip() for k in kinds if as_text(k).strip()], None + return [], (branch.get('recorded_on_unverified_because') + or 'face_designations.recorded_on is empty: nobody has ' + 'written down which kinds of member record a face, so a ' + 'blank field cannot be told from a field that does not ' + 'apply.') + + +def face_from_model_token(knowledge, value, component=None, family=None): + """What face the MODEL's own field names, and where the answer stopped. + + Returns a dict, always, never None -- the same discipline as + `rebar_stock`, and for the same reason: a caller that gets nothing back + has to invent the sentence explaining why, and it will invent a worse one. + + value what was read off the element, as read + token the printed designation it maps to, or None + face the `face_designation()` dict -- token, means, side, frame, + measured_from, vocabulary -- or None + why one sentence, whatever the outcome + + `component` is the member's kind as the plant records it (the + `member.manufacture_component` value) and `family` its family name. Both + optional, and both only ever used to explain an ABSENCE better -- neither + can produce a face. + + A BLANK FIELD IS NEVER A FACE. Measured across 282 panels: the field is + blank 34,434 times, and within the kinds that use it at all a face is + written EXPLICITLY (163 near-face values) alongside 1,304 blanks -- if + blank meant near face, near face would never need writing. On the paper + side the same holds: 219 of 461 unmarked call-outs use a code that is + tagged elsewhere on the same sheet, so an unmarked call-out is annotation + that was not repeated. There is no key here for what a blank means, and + there must not be one. + + LIFTING HARDWARE IS ITS OWN STAND-DOWN, and it is not a rare corner. The + ticket prints `(N.F.)` more than any other face token, and the most + commonly named part in those call-outs is the face lifter -- whose + instances are blank in the model, every one of them. A part that is on a + face by definition, with a model that does not say which, is a piece of + evidence a check must decline on rather than treat as a disagreement. + + WHAT A CHECK CALLS. For 5.3, per member: read the role + `member.face_designation` off the INSTANCE (see `face_read_from` -- never + the type), hand the raw value here with the member's kind and family, and + compare `result['token']` against the ticket's designation with + `same_face()`, which answers True / False / **None**. `None` is not a + disagreement. A `face` of None owes the row a NOT RUN carrying `why`, and + a token whose `face_attachment_verdict` is False is its own finding + independent of any comparison. + """ + out = {'value': value, 'token': None, 'face': None, 'why': None} + raw = '' if value is None else as_text(value).strip() + mapping = face_model_tokens(knowledge) + if not raw: + if family is not None and is_lifter(knowledge, family): + out['why'] = ('This is lifting hardware and it records no face. ' + 'It is on a face by definition and the model does ' + 'not say which -- measured, blank on every ' + 'instance -- so nothing may be concluded from the ' + 'blank. Stand down and say so.') + return out + kinds, kinds_why = face_recorded_on(knowledge) + if kinds and component is not None: + up = as_text(component).strip().upper() + if any(k.upper() in up for k in kinds): + out['why'] = ('This element records no face, and its kind ' + '({0!r}) IS one of the kinds that record one, ' + 'so the face is UNKNOWN here -- not a default ' + 'and not near.'.format(component)) + else: + out['why'] = ('This element records no face, and its kind ' + '({0!r}) is not one of the kinds that record ' + 'one, so the field does not apply to it.' + .format(component)) + return out + out['why'] = ('This element records no face: the parameter behind ' + 'parameters.{0} is absent or empty on it. That is a ' + 'fact about the element, not about the mapping, and it ' + 'is not a face -- {1}' + .format(FACE_ROLE, + kinds_why or 'nothing distinguishes a blank ' + 'from a field that does not ' + 'apply.')) + return out + if not mapping: + out['why'] = ('The model records {0!r} on this element and no layer ' + 'states face_designations.model_tokens, so nothing ' + 'knows which printed designation that value is. ' + 'Nothing was assumed.'.format(raw)) + return out + printed = mapping.get(raw.upper()) + if printed is None: + out['why'] = ('The model records {0!r} on this element and ' + 'face_designations.model_tokens does not carry it ' + '(it carries {1}). It is either a value nobody has ' + 'measured or a typo, and no face was guessed: a face ' + 'vocabulary has no "other", so a guess would resolve ' + 'silently to a face nobody meant.' + .format(raw, ', '.join(sorted(mapping.keys())))) + return out + out['token'] = printed + face = face_designation(knowledge, printed) + if face is None: + out['why'] = ('The model records {0!r}, which face_designations.' + 'model_tokens maps to the printed designation {1!r} -- ' + 'and no vocabulary in face_designations.use carries ' + '{1!r}. The mapping and the selection disagree; see ' + 'config_problems.'.format(raw, printed)) + return out + out['face'] = face + out['why'] = ('The model records {0!r}, which this plant prints as {1} ' + '({2}), read in the {3} frame -- {4}.' + .format(raw, printed, face.get('means'), face.get('frame'), + face.get('measured_from'))) + return out + + +def top_in_form_rule(knowledge): + """Where the PER-PIECE "which building face is top in form" value lives. + + Returns a dict -- label, source, values, verified, why -- and it is + DORMANT until somebody reads the field, which is the point of landing it. + + WHY THIS EXISTS AND WHY IT IS NOT A PLANT CONSTANT. A form-anchored + vocabulary says a part is on the face that ends up on top in the form. + That is absolute for the piece, and it is NOT a fixed building face: the + design output carries the value per panel and it varies panel to panel -- + two of three sampled read one way and the third the other. + + So a check that resolves a printed face designation to a BUILDING face + (interior / exterior) without reading this per-piece value is exactly + backwards on every piece of the opposite handedness, and every individual + verdict still reads perfectly normal. `building_face_of()` is the guard; + this is where it gets its label from. + + It is neither plant nor job knowledge -- it is a value to be READ off each + piece, like a design number -- so what a knowledge layer holds is only the + label to look for. Note the label's exact spelling, space before the colon + included, because that is how it is printed. + """ + rule = _face_branch(knowledge).get('top_in_form') + rule = dict(rule) if isinstance(rule, dict) else {} + out = {'label': rule.get('label'), + 'source': rule.get('source'), + 'values': rule.get('values') or [], + 'verified': bool(rule.get('verified')), + 'why': None} + if not out['label']: + out['why'] = (rule.get('unverified_because') + or 'face_designations.top_in_form.label is unset: ' + 'nobody has written down where a piece states which ' + 'building face ends up on top in the form.') + return out + if not out['verified']: + out['why'] = ('face_designations.top_in_form names {0!r}{1}, and it ' + 'is not verified: {2}' + .format(out['label'], + ' in ' + as_text(out['source']) + if out['source'] else '', + rule.get('unverified_because') + or 'nobody has said why.')) + return out + out['why'] = ('A piece states which building face is top in form under ' + '{0!r}{1}; the values seen are {2}.' + .format(out['label'], + ' in ' + as_text(out['source']) + if out['source'] else '', + ', '.join([as_text(v) for v in out['values']]) + or 'unrecorded')) + return out + + +def building_face_of(knowledge, token, top_in_form=None): + """Which BUILDING face a printed designation lands on, or a refusal. + + Returns (building_face, why). `building_face` is whatever the piece's own + field says -- typically 'Interior' / 'Exterior' -- or None. + + `top_in_form` is that per-piece value, read off the piece. WITHOUT IT THIS + REFUSES, always, and that refusal is the whole function: a form-anchored + token names a casting orientation, the mapping from casting orientation to + building face is per piece and it varies, and a check that assumes one + handedness is not slightly wrong -- it is inverted on half the job, one + normal-looking verdict at a time. + + It refuses on a view-framed token too. Near/far measured from the drawing + says nothing about the form until something resolves the view against it. + """ + face = face_designation(knowledge, token) + if face is None: + return None, ('{0!r} is not a designation any selected vocabulary ' + 'carries, so it names no face at all.'.format(token)) + if face.get('frame') != 'form': + return None, ('{0} is read in the {1} frame, not the form, so it ' + 'says nothing about which building face it lands on ' + 'until something resolves that frame against the form.' + .format(face['token'], face.get('frame'))) + side = face.get('side') + if side == 'both': + return None, ('{0} names every face the vocabulary can name, so it ' + 'does not pick one building face.' + .format(face['token'])) + stated = as_text(top_in_form).strip() if top_in_form is not None else '' + if not stated: + rule = top_in_form_rule(knowledge) + return None, ('{0} is (form, {1}), and which BUILDING face that is ' + 'varies piece by piece -- so it was not resolved. Read ' + 'the piece\'s own top-in-form value first: {2}' + .format(face['token'], side, rule['why'])) + if side == 'top': + return stated, ('{0} is top in form, and this piece states {1!r} is ' + 'top in form.'.format(face['token'], stated)) + return None, ('{0} is (form, {1}) and this piece states {2!r} is top in ' + 'form. The OPPOSITE of a stated building face is not ' + 'derivable from that statement alone -- a piece has more ' + 'than two faces and nothing here says which one is meant. ' + 'Read it, do not invert it.' + .format(face['token'], side, stated)) + + +# ------------------------------------ which end of a piece is UP once erected +# +# Owner, 2026-08-30, asked whether to wire or delete the two plant keys that +# had been asking a producer to declare this: +# +# "哪头朝上取决于 assembly origin。也就是 TOT。写入公共知识。 +# assembly origin 里面有一个朝上的头。" +# +# Which end is up depends on the ASSEMBLY ORIGIN, also called TOT; write it +# into the PUBLIC knowledge; the assembly origin has an up end in it. +# +# PUBLIC, NOT PLANT. That placement is the ruling, not a filing decision. The +# origin is placed as part of making the assembly, and placing it IS the +# statement of which end goes up -- so there is nothing here for a producer to +# declare, and `orientation.top_is_higher_global_z` / `orientation. +# top_end_families`, the two plant keys that asked one to, were the wrong +# shape rather than merely unread. Both are deleted; the rule ships in +# `knowledge/catalogue.yaml`, which identifies nobody. +# +# WHAT A HIGHER LAYER MAY AND MAY NOT DO. `orientation.up_end` is a MAP, so it +# deep-merges: a producer whose assemblies genuinely carry no usable origin +# corrects one field and inherits the rest. What a layer may NOT do is name a +# different MECHANISM -- that is code, not data, and this function reads +# exactly the fields the catalogue documents. +# +# THE MODEL-READING HALF SHIPPED DORMANT and was answered the same day: +# `AssemblyInstance.GetTransform().BasisY` is the erected up direction, +# measured against geometry unioned from `GetMemberIds()` on 282 assemblies. +# It went live as data -- `origin_up` and `origin_up_verified` in the +# catalogue -- and no code changed to turn it on, which is the whole point of +# having written it as a shape rather than as an `if`. +# +# WHAT DID NEED CODE is the three ways that reading is wrong on real pieces, +# and `up_end_from_origin()` below is exactly those three, in order. One +# assembly in 282 has BasisY pointing sideways; the frame is confirmed for +# HORIZONTAL panels only; and a near-square piece cannot be classified as +# either. All three DECLINE -- they never clamp and never pick the nearest +# axis -- because a wrong answer here is silent: the moments still balance, +# they are just balanced about the wrong end. + +UP_END_SOURCE = 'assembly_origin' + +# Fallbacks for a catalogue that predates the guards. `1.25` and `0.99` are +# the shipped values and the reasoning for both is in catalogue.yaml. +UP_END_NEAR_SQUARE_RATIO = 1.25 +UP_END_UP_TOLERANCE = 0.99 + + +def up_end_rule(knowledge): + """How to read which end of a piece is up once erected -- and whether it runs. + + Returns (source, why) with source one of: + + 'assembly_origin' the ruled mechanism, and it is READY: a layer has + said what on the origin denotes the up end, and + somebody has verified that against a real model. + None it cannot run, and `why` is the sentence to print. + + None NEVER means "this piece has no up end", and a caller must not read it + that way. It means the rule as ruled cannot be executed yet -- which is a + fact about this checkout, not about the piece. + + Kept here rather than in `liftexport` on purpose. `liftexport. + erected_orientation()` was the only place in the codebase that knew which + end was up, and it knew it as an unstated implementation detail; the + ruling is knowledge, so it is written down where knowledge lives and where + it can be tested with no model and no licence. + """ + rule = get(knowledge, 'orientation.up_end') or {} + if not isinstance(rule, dict) or not rule: + return None, ( + 'Nothing says how to tell which end of a piece is up once it is ' + 'erected: orientation.up_end is not declared in any layer, not ' + 'even the catalogue that ships with the tool. Nothing was assumed ' + 'either way.') + + source = as_text(rule.get('source') or '').strip() + if not source: + return None, ( + 'orientation.up_end names no source, so there is nothing to read ' + 'the up end off. The published rule is {0!r}.' + .format(UP_END_SOURCE)) + if source != UP_END_SOURCE: + return None, ( + 'orientation.up_end.source is {0!r}, and the only mechanism this ' + 'tool implements is {1!r}. A source nobody implemented is not a ' + 'different answer, it is an unimplemented one -- so nothing is ' + 'read off it and nothing is guessed in its place.' + .format(source, UP_END_SOURCE)) + + origin_up = rule.get('origin_up') + verified = bool(rule.get('origin_up_verified')) + if origin_up and verified: + return UP_END_SOURCE, ( + "Which end is up comes from the assembly's origin ({0}): {1}." + .format(rule.get('also_called') or UP_END_SOURCE, origin_up)) + + stated = rule.get('origin_up_as_stated') + because = rule.get('origin_up_unverified_because') + if origin_up and not verified: + return None, ( + "The rule says which end is up comes from the assembly's origin, " + 'and orientation.up_end.origin_up says {0!r} -- but ' + 'origin_up_verified is false, so nobody has checked that against ' + 'a real assembly. {1}' + .format(origin_up, + because or 'Verify it against a model and set the flag.')) + + return None, ( + "Which end is up comes from the assembly's origin, but nothing says " + 'WHAT on the origin denotes the up end, so the rule cannot be ' + 'executed{0}. {1}' + .format(' (written as {0!r}, not yet verified against a model)' + .format(stated) if stated else '', + because or 'Read it off a live assembly and set ' + 'orientation.up_end.origin_up.')) + + +def up_end_from_origin(knowledge, basis_up, up_axis, long_ft, short_ft, + label=None): + """Can THIS piece's origin answer which end is up? (True / None, why) + + `basis_up` the assembly origin's up basis vector in WORLD coordinates, + as an (x, y, z) triple -- `GetTransform().BasisY`. + `up_axis` which of the piece's own axes that vector lies along: + 'length', 'width', 'thickness', or None for none of them. + `long_ft` / `short_ft` + the piece's two in-plane dimensions, longer first. + `label` the mark or id, so a refusal names the piece rather than + describing a shape. + + NEVER RETURNS FALSE. The answer is "yes, read it off the origin" or "no + one can tell from the origin, and here is which of the three known + reasons applies". False would have to mean "the origin says the piece is + upside down", and nothing here can mean that. + + The three refusals, in order, each measured rather than imagined: + + 1. `basis_up` is not world up. One assembly in 282 has it pointing + sideways with its FACE NORMAL up instead, and on that piece every + other reading off the frame is wrong too. An assertion, not a + preference -- clamping to the nearest axis is how a single odd piece + becomes a silently mis-erected one. + 2. The piece is near square, so long-versus-up cannot classify it. Two + pieces sit at ratio 1.00 and 0.92 while every clean case is 1.7 and + up. Declining a genuinely undecidable band is not timidity; picking + a side there is a coin toss wearing a verdict's clothes. + 3. The piece is not horizontal. The frame is confirmed for horizontal + panels; the owner has since confirmed (2026-09-01) that the same + green-arrow reading also holds on a vertical wall panel, but this + function has not been extended to run there -- widening it would + change real verdicts on real pieces, and that is his call. Standing + down until he makes it costs one fallback; assuming it costs an + erection. + """ + rule = get(knowledge, 'orientation.up_end') or {} + named = 'assembly {0}'.format(label) if label else 'this assembly' + + tolerance = _number(rule.get('up_tolerance')) + if tolerance is None: + tolerance = UP_END_UP_TOLERANCE + ratio_max = _number(rule.get('near_square_ratio')) + if ratio_max is None or ratio_max < 1.0: + ratio_max = UP_END_NEAR_SQUARE_RATIO + + # ---- 1. the origin's up must actually be up ----------------------- + try: + x, y, z = [float(v) for v in basis_up] + except (TypeError, ValueError): + return None, ('The origin of {0} gave no up vector, so nothing can be ' + 'read off it.'.format(named)) + size = (x * x + y * y + z * z) ** 0.5 + if size <= 0: + return None, ('The origin of {0} gave a zero-length up vector, so ' + 'nothing can be read off it.'.format(named)) + if (z / size) < tolerance: + return None, ( + "The origin of {0} does not stand the piece up: its up basis is " + '({1}, {2}, {3}) rather than world up. One assembly in 282 is ' + 'like this -- its face normal points up instead -- and on such a ' + 'piece every reading off the frame is wrong, so nothing is read ' + 'off this one.' + .format(named, round(x, 4), round(y, 4), round(z, 4))) + + # ---- 2. near square: no classification is possible ---------------- + long_v, short_v = _number(long_ft), _number(short_ft) + if long_v is None or short_v is None or short_v <= 0 or long_v <= 0: + return None, ('{0} has no readable in-plane dimensions, so it cannot ' + 'be told apart from a vertical piece.' + .format(named[0].upper() + named[1:])) + if long_v < short_v: + long_v, short_v = short_v, long_v + ratio = long_v / short_v + if ratio < ratio_max: + return None, ( + '{0} is near square ({1} x {2} ft, ratio {3}), and below {4} the ' + 'long direction cannot be told from the up direction. Two pieces ' + 'on this job sit at 1.00 and 0.92 while every clean case is 1.7 ' + 'and up, so this band is genuinely undecidable rather than ' + 'merely awkward.' + .format(named[0].upper() + named[1:], round(long_v, 2), + round(short_v, 2), round(ratio, 2), ratio_max)) + + # ---- 3. the frame is confirmed for horizontal panels only --------- + # + # WORDING FIXED 2026-09-01, VERDICT NOT. This message used to say + # "Vertical panels work differently and nobody has said how yet" -- true + # when it was written, false now: the owner has confirmed (2026-09-01, + # `knowledge/catalogue.yaml` orientation.up_end.also_seen_as. + # vertical_wall_panels) that the SAME green-arrow reading -- BasisY is + # world up -- also holds on a vertical wall panel. What is still open is + # purple and red for that frame, and whether THIS THREE-REFUSAL FUNCTION + # should run there at all -- a decision the owner has not made, so the + # decline stands (see the docstring's "NEVER RETURNS FALSE" note and + # `knowledge/catalogue.yaml`'s own comment on `applies_to`: widening it + # changes real verdicts on real pieces and is his call, not a wording + # fix's). + applies = as_text(rule.get('applies_to') or 'horizontal_panels').strip() + if applies == 'horizontal_panels' and up_axis != 'width': + return None, ( + "The origin rule is confirmed for HORIZONTAL panels only, and the " + 'origin of {0} stands its {1} up, which is not one. The owner has ' + 'confirmed the same green-arrow reading also holds on a vertical ' + 'wall panel (2026-09-01), but this rule has not been extended to ' + 'run there yet, so the origin is not read on this piece.' + .format(named, up_axis or 'no recognisable direction')) + + return True, ( + "Read off the origin of {0} ({1}): {2} is the erected up direction, " + 'and it stands the piece\'s {3} up.' + .format(named, rule.get('also_called') or UP_END_SOURCE, + rule.get('origin_up'), up_axis)) + + +# ------------------------------------------- which MEMBERS are reinforcing +# +# Checklist 5.6 and 5.7 rest entirely on knowing which modelled members are +# bars, and until now that was an ENUMERATION -- `drawings.reinforcing_ +# families`, a list of names. An enumeration goes stale in silence: the plant +# adds a bar family, nobody re-lists it, and the checks report nothing on +# those bars, which reads exactly like nothing being wrong. +# +# So the same fact can now be stated as a RULE. Owner, 2026-08-30: the ones +# with rebar written in them -- "里面写着 rebar 的". +# +# WHAT "IN THEM" MEANS, corrected the same day and worth the space, because +# the first reading of it was wrong and wrong in a way that would have gone +# quiet rather than loud. It was built as a CATEGORY plus a FAMILY-NAME +# token. The census behind that read `FamilySymbol` TYPES only -- and this +# plant writes its categorisation on the INSTANCE, so the census missed the +# entire reinforcing population and the category could never be verified. +# Scoped to real assemblies the bars are not hiding at all: 112 members on +# one piece, 84 on another. +# +# The rule is a PARAMETER and a token in its VALUE, read from the INSTANCE +# first and the TYPE second, and it is not an invention -- the plant has +# already encoded exactly this in the model. Thirteen of its view filters key +# on that one parameter, and every string rule among them is "contains". +# +# The parameter is named here as a ROLE, so the office's own parameter name +# stays in the plant layer where it belongs and never enters this repository. + + +def _upper_strings(values): + if not isinstance(values, list): + return [] + return [as_text(v).strip().upper() for v in values + if v is not None and as_text(v).strip()] + + +def _reinforcing_family_patterns(knowledge): + """`drawings.reinforcing_family_patterns`, compiled. + + Returns a list of `(raw, compiled)` pairs, in file order. `compiled` is + `None` when the entry does not compile as a regex -- it then simply never + matches, the same silent-inert fate a plain-list token gets when it never + appears in a family name. Matched with `re.search`, case-insensitively, + against the family name AS GIVEN (the plain list upper-cases both sides + first; a regex author writes its own case handling by using this flag, + so the entry is left alone here). + """ + raw = get(knowledge, 'drawings.reinforcing_family_patterns', []) + if not isinstance(raw, list): + return [] + out = [] + for entry in raw: + text = as_text(entry or '').strip() + if not text: + continue + try: + out.append((text, re.compile(text, re.IGNORECASE))) + except re.error: + out.append((text, None)) + return out + + +def reinforcing_mode(knowledge): + """Which of the three ways of knowing is actually in force, and why. + + Returns (mode, why) with mode one of: + + 'list' the plant enumerated its families and that enumeration is + authoritative. It WINS where it is set: being overtaken by a + pattern the plant did not write would be worse than the + pattern is worth. + 'rule' no enumeration, and the parameter + token rule can run -- + which means the parameter role is named, at least one token + is named, and the parameter has been VERIFIED against a + model. All three; a rule that half-runs is worse than none. + None NEITHER. Nothing can be judged, and `why` is the sentence to + print. It is never "no families are bars". + + Exposed separately from the per-family verdict because a report wants to + say, once, how it decided -- and because a plant sitting on an + enumeration while a rule is written and inert underneath it should be + able to see that, rather than wondering why the rule changed nothing. + """ + # Default `[]`, spelled out, to agree with ticketchecks' two call + # sites. Two readers disagreeing about what unset means is a + # finding in tools/knowledge_drift.py, and rightly so. + listed = _upper_strings( + get(knowledge, 'drawings.reinforcing_families', [])) + patterns = _reinforcing_family_patterns(knowledge) + if listed or patterns: + return 'list', ('{0} reinforcing families and {1} family-name ' + 'pattern(s) are named in the knowledge files, so the ' + 'list is used and the parameter rule, if any, is not ' + 'consulted.'.format(len(listed), len(patterns))) + + rule = get(knowledge, 'drawings.reinforcing_rule') or {} + role = as_text(rule.get('parameter') or '').strip() + tokens = _upper_strings(rule.get('value_contains')) + verified = bool(rule.get('parameter_verified')) + because = rule.get('parameter_unverified_because') + + if role and tokens and verified: + return 'rule', ( + 'A member whose {0!r} parameter contains {1} is reinforcing, read ' + 'from the {2}.' + .format(role, ' or '.join([repr(t) for t in tokens]), + ' then the '.join(_read_from(rule)))) + + if role and tokens and not verified: + return None, ( + 'The reinforcing rule names the parameter role {0!r} and the ' + 'token(s) {1}, but parameter_verified is false -- nobody has ' + 'confirmed that parameter against a model. {2}' + .format(role, ' or '.join([repr(t) for t in tokens]), + because or 'Read it off a live document and set the flag.')) + + if tokens and not role: + return None, ( + 'The reinforcing rule names the token(s) {0} but no PARAMETER to ' + 'read them out of, so it cannot run. A token with nothing behind ' + 'it has nothing to match against. {1}' + .format(' or '.join([repr(t) for t in tokens]), + because or 'Name the parameter role and verify it.')) + + return None, ( + 'Nothing says which modelled members are reinforcing: ' + 'drawings.reinforcing_families and drawings.reinforcing_family_' + 'patterns are both empty and drawings.reinforcing_rule names no ' + 'parameter and token. Nothing was assumed either way.') + + +def _read_from(rule): + """Where to look for the parameter, in order. Instance first, then type. + + THEY ARE MUTUALLY EXCLUSIVE PER ELEMENT -- measured 2026-08-30: where the + instance carries a value the type is empty, and vice versa. Reading only + one of them therefore finds nothing on half the model and returns a result + that looks perfectly clean, which is exactly how the first census of this + parameter missed every bar in the job. + """ + order = rule.get('read_from') + order = ([as_text(v).strip().lower() for v in order] + if isinstance(order, list) else []) + order = [v for v in order if v in ('instance', 'type')] + return order or ['instance', 'type'] + + +def _scope_for(rule, item): + """One checklist row's additions and exclusions, as upper-cased tokens.""" + scopes = rule.get('scopes') + if not isinstance(scopes, dict) or item is None: + return [], [] + entry = scopes.get(as_text(item).strip()) or {} + if not isinstance(entry, dict): + return [], [] + return (_upper_strings(entry.get('also')), + _upper_strings(entry.get('except'))) + + +def reinforcing_verdict(knowledge, family, instance_value=None, + type_value=None, item=None): + """Is this member reinforcing? (True / False / None, why) + + NONE IS A REAL ANSWER and the caller must keep it: it means nothing here + can judge, which is not the same as False. Reporting "not a bar" for a + member nobody has described is how 5.6 and 5.7 went quiet in the first + place -- and it is what the old category-plus-family-name shape did to + every bar in the job, because it read TYPES and the answer is on the + INSTANCE. + + `instance_value` / `type_value` are the member's and its type's value for + the parameter the rule names. Pass both; they are read in the order + `read_from` gives, because they are mutually exclusive per element. + + `item` is the checklist row asking ('5.6', '5.7'). It selects a per-row + scope, and with no scope declared every row sees the same population -- + today's behaviour exactly. It exists because the token is NOT a clean + boundary in either direction: some bar-formed hardware carries it (one is + a strand loop, with no bar mark and no stock length), and some genuine + reinforcement does not. Which row wants which is an open question with the + owner, so the lists ship empty rather than guessed. + + Resolution order: + + 1. `drawings.reinforcing_family_overrides` -- an exact, case-insensitive + FAMILY name mapped to true or false. Definitive, and first, so a job + can add or exclude one family without restating a list or a rule. + 2. the enumeration, if it is set -- family-name substring match, the + existing semantics unchanged -- then `drawings. + reinforcing_family_patterns`, the same evidence class written as + regexes instead of literal substrings, for a family name that wraps + an open-ended code an enumeration could not keep up with. Either + one being non-empty wins over the rule. + 3. the rule -- the parameter's value, then the row's scope, then the + token. + 4. otherwise None. + """ + fam = as_text(family or '').strip().upper() + + overrides = get(knowledge, 'drawings.reinforcing_family_overrides') or {} + if isinstance(overrides, dict): + for name, value in overrides.items(): + if as_text(name).strip().upper() == fam and fam: + return bool(value), ( + 'Family {0!r} is named in ' + 'drawings.reinforcing_family_overrides as {1}.' + .format(family, 'a bar' if value else 'not a bar')) + + mode, why = reinforcing_mode(knowledge) + if mode is None: + return None, why + + if mode == 'list': + for name in _upper_strings( + get(knowledge, 'drawings.reinforcing_families', [])): + if name and name in fam: + return True, ('Family {0!r} contains {1!r}, which is named in ' + 'drawings.reinforcing_families.' + .format(family, name)) + for raw, compiled in _reinforcing_family_patterns(knowledge): + if compiled is not None and compiled.search(as_text(family or '')): + return True, ('Family {0!r} matches {1!r}, a pattern named in ' + 'drawings.reinforcing_family_patterns.' + .format(family, raw)) + return False, ('Family {0!r} matches none of the names in ' + 'drawings.reinforcing_families nor any pattern in ' + 'drawings.reinforcing_family_patterns.'.format(family)) + + rule = get(knowledge, 'drawings.reinforcing_rule') or {} + role = rule.get('parameter') + values = {'instance': instance_value, 'type': type_value} + read, source = None, None + for where in _read_from(rule): + candidate = as_text(values.get(where) or '').strip() + if candidate: + read, source = candidate, where + break + + if read is None: + return None, ( + 'Neither the instance nor the type of {0!r} carries a value for ' + '{1!r}, so nothing here can judge it -- which is not the same as ' + 'it not being a bar.'.format(family, role)) + + upper = read.upper() + also, excluded = _scope_for(rule, item) + for token in excluded: + if token in upper: + return False, ( + '{0!r} says {1!r}, which checklist {2} excludes from ' + 'reinforcing.'.format(family, read, item)) + for token in also: + if token in upper: + return True, ( + '{0!r} says {1!r}, which checklist {2} counts as reinforcing ' + 'even though it carries no token.'.format(family, read, item)) + + for token in _upper_strings(rule.get('value_contains')): + if token in upper: + return True, ('{0!r} is reinforcing: its {1} {2!r} is {3!r}, ' + 'which contains {4!r}.' + .format(family, source, role, read, token)) + return False, ('{0!r} is not reinforcing: its {1} {2!r} is {3!r}, which ' + 'carries none of {4}.' + .format(family, source, role, read, + ' or '.join([repr(t) for t in _upper_strings( + rule.get('value_contains'))]) or 'any token')) + + +# ------------------------------------------------------------ rebar stock +# +# Checklist 5.7 asks whether any bar is longer than the stock it is cut from. +# Until `stock_by_size` existed it compared every size against ONE number, and +# a 30 ft #4 at a plant that stocks #4 in 20 ft lengths passed -- silently, on +# a real defect class. Everything below exists so that never happens by +# omission again, and so a size nobody has data for makes the check STAND +# DOWN rather than pass. + +# A size, optionally followed by a PARENTHESISED qualifier. +# +# THE PARENTHESES ARE THE WHOLE POINT AND ARE NOT NEGOTIABLE. This pattern +# used to require the entire string to be the size, and a real value in a real +# model -- `'#9 (80 KSI)'` -- came back None, so every bar of the one size the +# owner specifically told us about went unjudged by 5.7. Silently: an +# unreadable size is a stand-down, and a stand-down on five values out of six +# looks like a working check. +# +# The tolerance is deliberately narrow. `(...)` is a qualifier; anything else +# trailing is not a size at all. Widening this to "a size followed by +# whatever" would make `'#4 BENT BAR (17)'` -- a free-text DESCRIPTION -- read +# as a bare size, and a plant that mapped the size role at that field would +# then be silently parsing prose with the strong reader. The description has +# its own reader, with its own basis; see `bar_size_of`. +_SIZE_RE = re.compile( + r'^\s*(?:#|no\.?\s*|number\s*)?0*(\d{1,2})\s*(\([^)]*\))?\s*$', re.I) + +# `80 KSI`, `(80 ksi)`, `Grade 80` inside a qualifier. A GRADE, and the only +# place in the tool a grade is ever read off a bar rather than defaulted. +_QUALIFIER_FY_RE = re.compile( + r'(?:^|\W)(?:grade\s*)?(\d{2,3})\s*(?:ksi)?(?:\W|$)', re.I) + + +def rebar_size_parts(value): + """(size, qualifier) -- the size, and the parenthesised text after it. + + `'#4'` -> `('#4', None)`. `'#9 (80 KSI)'` -> `('#9', '80 KSI')`. + Anything that is not a size -> `(None, None)`. + + THE QUALIFIER IS KEPT RATHER THAN DISCARDED because at least one real one + is a GRADE. `rebar_fy()` has a provenance chain -- 'stated', 'plant + stock', 'astm default', None -- whose strongest rung, 'stated', had + nowhere to be filled from: nothing in the model was read as a grade, so + every bar answered from a plant or ASTM default even where the model said + otherwise. A bar whose own size field reads `#9 (80 KSI)` has stated its + grade, and `bar_size_of()` hands the number on as `qualifier_fy_ksi` so a + caller can pass it to `rebar_fy(..., stated_fy_ksi=...)`. + + Nothing here decides that a qualifier IS a grade. It returns the text; the + number is offered separately and only when the text reads as one. + """ + if value is None: + return None, None + m = _SIZE_RE.match(as_text(value)) + if not m: + return None, None + qualifier = m.group(2) + if qualifier: + qualifier = qualifier.strip('() ').strip() or None + return '#' + m.group(1), qualifier + + +def rebar_size(value): + """Anything that names a bar size, as the string the tables are keyed by. + + `'#4'`, `'4'`, `4`, `'No. 4'`, `'#04'` all come back `'#4'`, and so does + `'#9 (80 KSI)'` -- a size with a parenthesised qualifier is still that + size, and refusing it meant every bar of that size went unjudged by 5.7 + (measured 2026-08-31, and it was the one size the owner had named). + Anything else comes back None, which keeps a junk token out of a table + lookup rather than turning it into a size nobody meant. + + The qualifier is DISCARDED here and kept by `rebar_size_parts()`. Callers + that only need a table key stay unchanged; a caller that wants the grade + asks for it. + + Keyed by the PRINTED form, `'#4'`, and not by the number 4 on purpose: + the two YAML readers this tool runs on disagree about a bare `4:` -- one + gives the integer, the other the string -- and one field with two types + is a bug this file has already paid for once (see `_coerce`). + """ + return rebar_size_parts(value)[0] + + +def rebar_size_qualifier_fy(qualifier): + """A yield strength stated inside a size qualifier, or None. + + `'80 KSI'` -> 80.0. `'GRADE 80'` -> 80.0. Anything with no number in it, + or a number outside the range any rebar grade takes, -> None. It never + guesses: a qualifier that is not a grade is not a grade. + """ + if qualifier is None: + return None + m = _QUALIFIER_FY_RE.search(as_text(qualifier)) + if not m: + return None + value = _number(m.group(1)) + if value is None or not 40 <= value <= 120: + # Outside every published rebar grade (A615: 40/60/75/80/100; + # A706: 60/80). A number outside that band is something else in the + # parentheses and must not be read as a strength. + return None + return value + + +# The role, exported for callers and tests. THE READ SITES BELOW SPELL THE +# PATH OUT ANYWAY, and that is not redundancy: `tools/knowledge_drift.py` +# pairs declared keys against read keys by scanning the source for the string, +# so a constant HIDES the read and the key reports as "declared but never +# read". `drawings.bed_plan` is the standing example of exactly that. +BAR_SIZE_ROLE = 'member.bar_size' + +# The size at the FRONT of a part description, and it insists on the marker +# -- `#4`, `No. 4` -- where `rebar_size` accepts a bare `4`. +# +# THAT DIFFERENCE IS THE SAFETY, not an oversight. `rebar_size` is handed a +# field whose whole job is to hold a size, so a bare number in it is a size. A +# description holds a size AND other things, and a leading bare integer there +# is marked as nothing at all. Reading `4 X 8'-8"` as a #4 bar would be a +# guess wearing a parser's clothes. +_DESC_SIZE_RE = re.compile(r'^\s*(?:#|no\.?\s*|number\s*)0*(\d{1,2})\b', re.I) + + +def _bar_size_rule(knowledge): + rule = get(knowledge, 'drawings.bar_size_rule') + return rule if isinstance(rule, dict) else {} + + +def bent_bar_verdict(knowledge, description): + """Is this a BENT bar? (True / False / None, sentence). + + None is a real answer and callers must keep it: it means nothing here can + judge, which is not the same as False. Reporting "not a bent bar" for an + element nobody has described is how a check goes quiet. + + IT DOES TWO JOBS AND BOTH MATTER. + + * IT GATES THE PARSE. `bar_size_of()` reads a size off the front of a + part description only where this says True, because the shape + `#N BENT BAR (...)` IS the evidence that the leading token is a bar + size. A description that no longer has the shape is no longer + evidence, so the bar goes UNJUDGED rather than mis-sized. + * IT EXPLAINS AN ABSENCE. A bar with no size gets a reason saying "this + is a bent bar" instead of one that sends somebody to re-check a + mapping that is already correct. + + The token and the field are both PLANT knowledge + (`drawings.bar_size_rule.bent_bar_tokens`, read out of the description + role). Matched case-insensitively as a SUBSTRING, because that is what + this plant's own thirteen view filters do. + """ + tokens = _upper_strings(_bar_size_rule(knowledge).get('bent_bar_tokens')) + if not tokens: + return None, ('drawings.bar_size_rule.bent_bar_tokens is empty, so ' + 'nothing here can tell a bent bar from a straight one.') + text = '' if description is None else as_text(description).strip() + if not text: + return None, ('this element records no description, so whether it is ' + 'a bent bar could not be read.') + up = text.upper() + for token in tokens: + if token in up: + return True, ('its description carries {0!r}, which is how a bent ' + 'bar is recognised at this plant.'.format(token)) + return False, ('its description carries none of {0}, so it does not read ' + 'as a bent bar.' + .format(', '.join([repr(t) for t in tokens]))) + + +# Owner, verbatim (2026-08-31): "assembly 的 identity description 是我的随 +# 笔。family 的不是。" -- the assembly's identity description is his running +# notes; the family's is not. +def bar_size_of(knowledge, parameter_value=None, description=None): + """A bar's size, and WHICH FIELD ANSWERED. + + Returns a dict, always, never None: + + size the normalised printed size (`'#4'`), or None + basis 'parameter' the size field behind member.bar_size + 'description' the part description behind + member.identity_description, read off the + FAMILY + None nothing could read it. Stand down, print why. + read_from the raw string the answer came out of, or None + qualifier a parenthesised suffix on a size, e.g. `'80 KSI'`, or None + qualifier_fy_ksi that qualifier as a yield strength, when it reads as + one. Pass it to `rebar_fy(..., stated_fy_ksi=)`, whose + strongest provenance rung had nothing to fill it from until + this existed. + why one sentence, whatever the outcome + + TWO READERS, TWO POPULATIONS -- NOT A FALLBACK CHAIN. Measured over 24,539 + rebar members in 13 families: the size parameter is carried by the one + STRAIGHT family and by none of the twelve bent ones, and the family + description is carried by all twelve bent families (100% on the type) and + by no straight member. The two sets are disjoint. The size parameter is + tried first only because something has to be, and since no bar carries + both, that order has never decided anything -- do not read it as a + ranking, and do not "fix" a bar by looking for the other field. + + THE DESCRIPTION IS STRUCTURED DATA, NOT NOTES, and this docstring said the + opposite for part of one day. Owner, 2026-08-31: "the assembly's identity + description is my running notes; the family's is not." The ASSEMBLY's identity + description is his running notes -- the field where `MASTER` sits as a + stable token inside free text. The FAMILY's is the description of the + part. Same parameter name, two kinds of element, opposite reliability, and + `drawings.bar_size_rule.description_read_from` is what says which one to + read. The evidence agrees with him: 7,405 of 7,405 bent members parse, in + one uniform shape, with no exceptions. Prose does not do that. + + THE PARSE IS STRICT ON PURPOSE, and the reason is not distrust. The shape + `#N BENT BAR (...)` IS the evidence that the leading token is a bar size; + a description that no longer has that shape is no longer evidence of + anything, so it must leave the bar UNJUDGED rather than be parsed through. + A leading BARE number is refused for the same reason -- nothing marks it + as a size. Both refusals are could-not-judge with a reason. + + WHY BENT BARS ARE READ AT ALL. Owner, 2026-08-30: "rebars come in 40ft + stock. unless overwritten by other knowledge, all rebars pieces must be + less than 40ft" -- ALL pieces -- and separately that a bar's length is + read off a parameter, bent bars included. Bent bars are in scope for 5.7 + by ruling; only their size was missing. + + THE ALTERNATIVE THAT WAS CONSIDERED AND REJECTED: a bar diameter parameter + plus a diameter-to-size map. Measured, it is not universal -- four bent + families, 534 members, carry no diameter on any member, and all four parse + cleanly from the description. The map would have had a hole this route + does not, on top of needing nominal diameters, tolerance bands, and a + ruling for a value landing between two bands. Do not build it. + """ + out = {'size': None, 'basis': None, 'read_from': None, + 'qualifier': None, 'qualifier_fy_ksi': None, 'why': None} + rule = _bar_size_rule(knowledge) + + raw = '' if parameter_value is None else as_text(parameter_value).strip() + if raw: + size, qualifier = rebar_size_parts(raw) + if size is not None: + out.update({'size': size, 'basis': 'parameter', 'read_from': raw, + 'qualifier': qualifier, + 'qualifier_fy_ksi': rebar_size_qualifier_fy(qualifier)}) + out['why'] = ('{0}, read from the size field behind ' + 'parameters.{1} ({2}){3}.' + .format(size, BAR_SIZE_ROLE, + ', '.join(param_names( + knowledge, 'member.bar_size')) + or 'unmapped', + ', qualified {0!r}'.format(qualifier) + if qualifier else '')) + return out + out['why'] = ('the size field behind parameters.{0} holds {1!r}, ' + 'which does not read as a bar size. Nothing was guessed ' + 'from it.'.format(BAR_SIZE_ROLE, raw)) + return out + + mapped = param_names(knowledge, 'member.bar_size') + text = '' if description is None else as_text(description).strip() + bent, bent_why = bent_bar_verdict(knowledge, description) + + if not rule.get('description_fallback'): + if not mapped: + out['why'] = ('no parameters.{0} is mapped in any knowledge layer ' + 'and drawings.bar_size_rule.description_fallback is ' + 'off, so nothing in the tool can read a bar size -- ' + 'on any bar.'.format(BAR_SIZE_ROLE)) + return out + out['why'] = ('this bar carries no value in the size field behind ' + 'parameters.{0} ({1}), and the part-description reader ' + 'is off (drawings.bar_size_rule.description_fallback). ' + 'The mapping is not what is missing.{2}' + .format(BAR_SIZE_ROLE, ', '.join(mapped), + ' This is a bent bar: {0}'.format(bent_why) + if bent else '')) + return out + + if not text: + out['why'] = ('this bar carries no size field, and the part ' + 'description behind {0} (read from {1}) is empty too, ' + 'so there is nothing left to read a size out of.' + .format(rule.get('description_role') + or 'the description role', + ', '.join(rule.get('description_read_from') + or ['type']))) + return out + if bent is not True: + out['why'] = ('this bar carries no size field, and its part ' + 'description {0!r} does not have the shape a size is ' + 'read out of -- {1} The shape is the evidence that the ' + 'leading token is a size, so a description without it ' + 'leaves the bar unjudged rather than mis-sized.' + .format(text, bent_why)) + return out + match = _DESC_SIZE_RE.match(text) + if not match: + out['why'] = ('this bar carries no size field, and its part ' + 'description {0!r} does not START with a marked bar ' + 'size (`#4`, `No. 4`). A leading bare number is not ' + 'accepted: nothing marks it as a size.'.format(text)) + return out + size = rebar_size(match.group(1)) + out.update({'size': size, 'basis': 'description', 'read_from': text}) + out['why'] = ('{0}, read off the front of the part description {1!r}. ' + 'That is the field the bent-bar families carry their size ' + 'in; they carry no size field at all, so this is the ' + 'reader for that population rather than a fallback.' + .format(size, text)) + return out + + +def _no_size_why(knowledge, size): + """Why a bar reached `rebar_stock` with no usable size. + + States that used to share one sentence -- "None does not read as a bar + size" -- and each wants a different action: + + * nothing mapped and no fallback on. Nothing can read a size, on any + bar. Fix: map `parameters.member.bar_size`. + * the role IS mapped and this bar came back empty. The field is + ABSENT ON THIS BAR'S FAMILY -- measured 2026-08-31, a plant carries it + on its straight family and on none of its twelve bent ones, which + read their size off the part description instead. Fix: + `drawings.bar_size_rule.description_fallback`, not the mapping. + * something was read and it is not a size. Fix: look at the value. + + The middle one is why this exists. Saying "no size parameter is mapped" + to somebody who has just mapped one sends them to re-check a file that is + already right, and hides that the gap is per family. Naming the mapped + parameter is what makes the difference checkable by the reader. + + NOTE WHAT IS NOT HERE. It does not say bent bars are out of scope. They + are IN scope by the owner's ruling of 2026-08-30 -- "all rebars pieces + must be less than 40ft" -- so a bent bar with no readable size is an + unanswered question about the size, never a decision about the bar. + """ + if size is not None and as_text(size).strip(): + return ('{0!r} does not read as a bar size, so no stock length was ' + 'looked up.'.format(size)) + tail = (' A bar with no resolvable size is not judged against the generic ' + 'stock length either: "40 ft" and "40 ft for this size" are ' + 'different claims, and the generic one is wrong in the direction ' + 'that passes a defect.') + return ('This bar has no size, so no stock length was looked up -- ' + + bar_size_of(knowledge)['why'] + tail) + + +def rebar_stock(knowledge, size): + """How long a stick of this bar size is, and whether the plant keeps it. + + Returns a dict, always, never None: + + size the normalised size, or None if it was not readable + length_in integer inches, exact. None when nothing knows. + length_ft the same length in feet, for printing. None likewise. + basis 'by_size' the per-size table -- the real answer + 'generic' the one-number industry fallback + None NOTHING KNOWS. Stand down and say why. + stocked True / False / None. A SEPARATE FACT from the length: + a size can be listed with a length and still be marked + not stocked, and that is neither a pass nor a failure -- + it is a reason to ask a person. + astm the designation, from the size row if it names one and + otherwise from the plant's `stock_material`. It does NOT + give a yield strength; see rebar_fy(). + astm_basis 'by_size' or 'plant stock', so a size row that overrides + the plant's stock material is distinguishable from one + that inherits it. + why one sentence, for the report, whatever the outcome. + + LENGTHS ARE INCHES because two common stock lengths are not whole feet + -- 23'-4" is 23.3333... ft -- and a bar cut to exactly 23'-4" would fail a + comparison against a decimal-foot copy of its own stock length. Feet are + returned too, computed here, so no caller ever does the conversion twice + and differently. + """ + key = rebar_size(size) + out = {'size': key, 'length_in': None, 'length_ft': None, 'basis': None, + 'stocked': None, 'astm': None, 'astm_basis': None, 'why': None} + if key is None: + out['why'] = _no_size_why(knowledge, size) + return out + + # The designation, if anything says one. A SIZE ROW rarely does and + # should not have to: what the stock is made of is stated once for the + # plant in `stock_material`, because a material repeated on every row is + # a material that goes stale on some of them. `astm_basis` says which + # answered, so a row that DOES override -- a job's odd bar -- is + # distinguishable from the plant default. + material = get(knowledge, 'catalogue.rebar.stock_material') or {} + if isinstance(material, dict) and material.get('astm'): + out['astm'] = material.get('astm') + out['astm_basis'] = 'plant stock' + else: + out['astm_basis'] = None + + entry = (get(knowledge, 'catalogue.rebar.stock_by_size') or {}).get(key) + if isinstance(entry, dict): + out['stocked'] = entry.get('stocked') + if entry.get('astm'): + out['astm'] = entry.get('astm') + out['astm_basis'] = 'by_size' + length = _number(entry.get('stock_length_in')) + if length is not None: + out['length_in'] = length + out['length_ft'] = length / 12.0 + out['basis'] = 'by_size' + out['why'] = ('{0} stock is {1:g} in ({2:.2f} ft), from the ' + 'per-size stock table in the plant layer.' + .format(key, length, length / 12.0)) + if entry.get('stocked') is False: + out['why'] += (' It is marked NOT STOCKED: that length is ' + 'what would arrive if it were ordered.') + return out + # Listed, but with no length. `stocked` still means something and is + # returned; the LENGTH falls through to the generic number, because + # an entry that says nothing about length is not a statement that the + # length is unknown. + + generic = _number(get(knowledge, 'catalogue.rebar.stock_length_ft')) + if generic is not None: + out['length_ft'] = generic + out['length_in'] = generic * 12.0 + out['basis'] = 'generic' + out['why'] = ('No stock length is listed for {0}, so the generic ' + '{1:g} ft is used. That number is not size specific ' + 'and the small sizes are commonly much shorter -- list ' + '{0} in the plant layer to check it properly.' + .format(key, generic)) + return out + + out['why'] = ('No stock length is known for {0}: it is not in the ' + 'per-size table and no generic length is set either. ' + 'Nothing was assumed.'.format(key)) + return out + + +def rebar_fy(knowledge, astm=None, stated_fy_ksi=None): + """The yield strength to use, and WHERE IT CAME FROM. + + Returns (fy_ksi, basis, why). `basis` is 'stated' when a grade was given, + 'astm default' when it came from the designation's published default, and + None when nothing defensible is known -- in which case fy is None and the + caller must stand down and print `why`. + + THE DESIGNATION DOES NOT GIVE A GRADE, and this function exists to stop + anything pretending it does. Current A615 covers Grades 60, 80 and 100 + (historically 40 and 75 too), so "A615" is not a strength; its published + `default_fy_ksi` is deliberately null and this returns None for it. + Current A706 covers 60 and 80, with 60 as the default -- a DEFAULT, and + it is reported as one, because a default that reads like a measurement is + how a job with 80 ksi bar gets checked at 60. + """ + stated = _number(stated_fy_ksi) + if stated is not None: + return stated, 'stated', ('{0:g} ksi, as stated.'.format(stated)) + + # The PLANT'S OWN STATED STOCK MATERIAL, and it comes before the ASTM + # table on purpose: "this plant stocks A706 Grade 60" is an assertion + # about the plant, while "A706 usually means Grade 60" is what the + # designation tends to mean. Same number here, different strength of + # claim, and the basis says which. Only consulted when the caller named + # no designation, or named the one the plant stocks -- a caller asking + # about some other designation is asking a different question. + material = get(knowledge, 'catalogue.rebar.stock_material') or {} + if isinstance(material, dict) and material: + material_fy = _number(material.get('fy_ksi')) + material_astm = material.get('astm') + if material_fy is not None and (not astm or astm == material_astm): + return material_fy, 'plant stock', ( + '{0:g} ksi -- the plant states its stock rebar is {1}, ' + '{0:g} ksi. A stated fact about this plant, not an assumption ' + 'about the designation.' + .format(material_fy, material_astm or 'unspecified')) + + entry = (get(knowledge, 'catalogue.rebar.astm_grades') or {}).get(astm) \ + if astm else None + if isinstance(entry, dict): + default = _number(entry.get('default_fy_ksi')) + grades = entry.get('grades_ksi') or [] + if default is not None: + return default, 'astm default', ( + '{0:g} ksi, the published DEFAULT for {1} and not a stated ' + 'grade. {1} also covers {2}.' + .format(default, astm, + ', '.join(['%g ksi' % g for g in grades + if _number(g) is not None]) + or 'other grades')) + return None, None, ( + 'No yield strength is known. {0} does not imply one -- it covers ' + '{1} -- and no grade was stated, so nothing was assumed.' + .format(astm, ', '.join(['%g ksi' % g for g in grades + if _number(g) is not None]) + or 'several grades')) + + if astm: + return None, None, ( + 'No yield strength is known: {0!r} is not in the published ASTM ' + 'grade table and no grade was stated.'.format(astm)) + return None, None, ('No yield strength is known: no grade was stated and ' + 'no ASTM designation was given.') + + +def rebar_astm(knowledge, name): + """What the plant's stock list says about one ASTM heading. + + Returns the entry, or None when the heading is not mentioned at all. The + THREE-WAY distinction is the whole point and a caller must keep it: + + entry with sizes_listed True sizes are listed; look them up + entry with sizes_listed False NAMED, WITH NOTHING UNDER IT. Nothing + is known. This is NOT "the plant has + none" and must never be printed as + one. + None never mentioned. + + The middle case is real: one plant's section 5301 names A706 and the page + ends there. Collapsing it into either neighbour invents a fact. + """ + table = get(knowledge, 'catalogue.rebar.astm_designations') or {} + entry = table.get(name) + return entry if isinstance(entry, dict) else None + + +def rebar_grades_present(knowledge): + """Bar grades a human has declared are on this job, as a sorted list. + + Existence facts about individual call-outs -- "there is a #9 at 80 ksi on + this job" -- NOT a statement that every bar of that size carries that + grade. Declared in the job layer as a map so a second declaration is a + second key; see the note in DEFAULTS. + """ + table = get(knowledge, 'catalogue.rebar.grades_present') or {} + if not isinstance(table, dict): + return [] + out = [] + for key in sorted(table): + entry = table[key] + if isinstance(entry, dict): + row = dict(entry) + row['id'] = key + row['size'] = rebar_size(entry.get('size')) or entry.get('size') + out.append(row) + return out + + +def _number(value): + """A number, or None. Never a substitute value.""" + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def same_face(knowledge, token_a, token_b): + """Do these two printed designations name THE SAME FACE of the piece? + + True / False / None, with None meaning one of them could not be resolved + -- never False, because "I do not know that abbreviation" and "those are + different faces" are opposite answers. + + THE IDENTITY OF A FACE IS THE PAIR (frame, side). Two tokens from two + different vocabularies name one face when both are read in the same frame + and land on the same side, and this is not a coincidence of the data + model -- it is what lets a plant whose near/far is anchored to the FORM + say that its `N.F.` and the trade's `T.I.F.` are one face, without either + vocabulary having to mention the other. + + Two tokens in DIFFERENT frames are never the same face here, even when + they sound alike. They are measured from different things, so the + question has no answer until something resolves one frame against the + other, and that is a fact about the piece and the view rather than about + the vocabulary. + """ + a = face_designation(knowledge, token_a) + b = face_designation(knowledge, token_b) + if a is None or b is None: + return None + if a.get('frame') is None or a.get('frame') != b.get('frame'): + return False + if a.get('side') is None or b.get('side') is None: + return None + return a['side'] == b['side'] + + +def config_problems(doc, knowledge=None): + """Things wrong with this model's configuration, as sentences. + + Empty when there is nothing to say. Buttons call this before doing any + work: a configuration that is not what the engineer set is worth stopping + for, and it is invisible otherwise. + """ + out = [] + # FIRST, because it is the loudest thing that can be wrong with a + # configuration: the file is there, the reader refused it, and the run + # is on the layers underneath it looking entirely normal. + for label, path, message in unreadable_layers(knowledge): + out.append( + 'The {0} knowledge layer is on this machine and could not be ' + 'read, so NONE of it was used:\n\n {1}\n\nThe YAML reader ' + 'refused it:\n\n {2}\n\nThe layer was skipped whole -- a ' + 'half-parsed layer is never merged -- so every value that file ' + 'sets is back to whatever the layer underneath it says, and a ' + 'layer that fails to load looks exactly like a layer that agrees ' + 'with the defaults. Fix the file and run again (a key stated ' + 'twice inside one mapping is the usual cause, and ' + '`python tools/knowledgelint.py` names it with a line number); ' + 'the tool will not guess at what it said.' + .format(label, path, message)) + if isinstance(knowledge, dict) and knowledge.get('_config_missing'): + out.append( + 'This model is set to run on a project config file that is not ' + 'there:\n\n {0}\n\nUntil it comes back, or Project Config is ' + 'run again, everything is using the layered knowledge files ' + 'instead -- which is NOT the configuration that was set.' + .format(knowledge['_config_missing'])) + if isinstance(knowledge, dict): + emptied = knowledge.get('_capacity_override_emptied') + if emptied: + where = '; '.join('{0} layer ({1})'.format(layer, path) + for layer, path in emptied) + out.append( + 'lifters.capacity_lb is declared but EMPTY in {0}. The ' + 'ordinary shape of that table is "no overrides" -- an ' + 'empty dict, `{{}}` -- and a layer that instead states the ' + 'key with nothing under it has almost certainly had a ' + 'VALUE REMOVED from it. This is the exact shape of the ' + '2026-08-31 incident where a plant-layer restructure ' + 'silently deleted the owner\'s hand-entered P76 = 16,000 lb ' + 'override and every export after it quietly fell back to ' + 'the catalogue\'s 7,690 with nothing anywhere saying so. ' + 'If this really is meant to say "no overrides here", write ' + '`capacity_lb: {{}}` and this stops firing; if a value used ' + 'to be here, it needs to come back.'.format(where)) + out.extend(plant_problems(knowledge)) + out.extend(face_designation_problems(knowledge)) + # Everything the `decisions:` block got wrong, judged by _merge_decisions + # at the moment it dropped the value. NOT re-derived from the tree here: + # a dropped value leaves no trace in the tree to re-derive it from, and + # the version of this function that tried read `decisions.insert_position` + # -- a path the merge never creates -- so it never fired on a real file. + if isinstance(knowledge, dict): + for message in knowledge.get('_config_problems') or []: + out.append(message) + + dunnage = get(knowledge, 'handling.dunnage') or {} + mode = dunnage.get('mode') + if mode is not None: + if mode not in ('lifter_rows', 'fraction'): + out.append( + 'handling.dunnage.mode is {0!r}; it can only be ' + '"lifter_rows", "fraction" or blank (the old, labelled lift ' + 'approximation).'.format(mode)) + if mode == 'fraction': + fraction = dunnage.get('fraction') + try: + fraction = float(fraction) + except (TypeError, ValueError): + fraction = None + if fraction is None or not 0.0 < fraction < 0.5: + out.append( + 'handling.dunnage.mode is "fraction" but ' + 'handling.dunnage.fraction must be a number between 0 and ' + '0.5, got {0!r}.'.format(dunnage.get('fraction'))) + return out + + +def sources(doc): + """[(label, path, exists)] for the layers load() reads, newest last. + + Resolves the plant layer through the same pre-pass `load()` uses, so this + names the file that is really read. Asking for the plant path without a + plant would print the machine-wide file while the run used a per-plant + one -- a listing of layers that lies about the layers. + """ + from vibe import designspec + plant = plant_id(doc) + rows = [('catalogue', catalogue_path()), + ('plant', designspec.plant_knowledge_path(plant)), + ('job', designspec.knowledge_path(doc))] + config = designspec.active_config_path(doc) + if config: + rows.append(('project config', config)) + # `path` is None where a plant id would not resolve to one (a malformed + # name). Listed as a layer that is not there, which is what it is. + return [(label, path, bool(path) and os.path.isfile(path)) + for label, path in rows] + + +# The layers that are FILES, in merge order: the token the marker key is +# built from, and the label a sentence calls the layer by. The labels match +# `sources()`, so a person reading Self Check sees one name for one layer. +_FILE_LAYERS = (('catalogue', 'catalogue'), + ('plant', 'plant'), + ('job', 'job'), + ('config', 'project config')) +_FILE_LAYER_LABELS = dict(_FILE_LAYERS) + + +def _unreadable_key(layer): + """The marker key one layer leaves when its file is there and will not read. + + Per layer, the same shape as `_plant_missing` / `_config_missing`, because + they answer the same kind of question: not "what does the tree say" but + "what happened to a file at the moment it was looked for". + """ + return '_{0}_unreadable'.format(layer) + + +def unreadable_layers(knowledge): + """[(label, path, message)] for every layer whose file would not parse. + + Merge order, so the first row is the deepest layer that failed. Empty + when every file that exists was read, which is the ordinary case. + """ + out = [] + if not isinstance(knowledge, dict): + return out + for layer, label in _FILE_LAYERS: + marker = knowledge.get(_unreadable_key(layer)) + if marker: + out.append((label, marker[0], marker[1])) + return out + + +def _note_layer_unreadable(target, layer, path, error): + """Record that a layer's file exists and the reader refused it.""" + if not layer or not isinstance(target, dict): + return target + message = as_text(error).strip() or type(error).__name__ + target[_unreadable_key(layer)] = (path, message) + return target + + +# Commit message, verbatim, private repo `pyrevit-knowledge` `767281e`: +# "总表三层化、厂层收割落库" -- master table split into three layers, plant +# layer harvested and landed into the store. +def _note_capacity_override_emptied(target, layer, path, data): + """Record when a layer states `lifters.capacity_lb` with nothing in it. + + THE INCIDENT THIS EXISTS FOR, 2026-08-31/09-01: a restructure of the + plant layer (private repo `pyrevit-knowledge`, commit `767281e`, + "master table split into three layers, plant layer harvested and + landed into the store") walked into `lifters.capacity_lb` holding the + owner's own hand-entered override -- P76 = 16,000 lb, the correct ALP + with-tension-bar rating -- found it disagreeing with catalogue.yaml's + published 7,690, and emitted the key EMPTY with a comment reading as + though it had always been blank. `lifter_capacity()` checks this table + BEFORE the catalogue, so every export after that quietly fell back to + 7,690 and five real panels carried a false FAIL/PLUMB. The sibling key + in the same block, `lifters.min_edge_distance_in: 9`, survived the + identical restructure untouched -- proof this was an accident and not a + policy, and proof the failure mode is silent: nothing anywhere said the + value had gone. + + The default SHAPE of `lifters.capacity_lb` is `{}`, an empty dict: "no + overrides declared here". A layer that instead states the key with + nothing under it (bare `capacity_lb:`, or `capacity_lb: null`) merges to + the exact same VALUE as `{}` -- `lifter_capacity` cannot tell them apart + after the merge, because `_longest_substring_match(None or {}, ...)` + behaves identically either way -- but it is a DIFFERENT EVENT: a `null` + is either a value that used to be there, or someone typed the key with + nothing meant by it, and neither is "nobody ever set this here". That + distinction disappears the moment `_deep_update` runs unless it is + caught HERE, at the layer that did it -- which is why this lives beside + `_note_layer_unreadable` rather than being re-derived from the merged + tree later, the same reasoning `_note_config_problem` documents for + `decisions:`. + + There is no legitimate reason to write `capacity_lb: null` rather than + `capacity_lb: {}` or leaving the key out entirely -- so if this ever + fires on a genuinely intended blank, the fix is to write the entry-less + dict instead of removing this check. + """ + if not layer or not isinstance(target, dict) or not isinstance(data, dict): + return target + lifters = data.get('lifters') + if isinstance(lifters, dict) and 'capacity_lb' in lifters \ + and lifters['capacity_lb'] is None: + target.setdefault('_capacity_override_emptied', []).append( + (layer, path)) + return target + + +def _merge_file(merged, path, layer=None, notes=None): + """Merge one layer file, and say so out loud when it will not parse. + + A file that is not there is not this function's business -- absence is + ordinary (no job file yet, no catalogue on a stripped install) and the + layers that must NOT be absent have their own markers upstream + (`_plant_missing`, `_config_missing`). + + A file that IS there and raises is a different animal, and it used to be + swallowed here by a bare `except: return merged`. Measured 2026-09-01: one + duplicate key nested inside one mapping of a plant file -- pyRevit's + reader is strict and raises `Duplicate key` on it -- discarded the ENTIRE + 255KB plant layer. Every parameter role mapped to None, the sweep + enumerated 0 assemblies, and every report rendered normally; + `sources()` and `config_problems()` both said healthy, because + A LAYER THAT FAILS TO LOAD LOOKS EXACTLY LIKE A LAYER THAT AGREES WITH + THE DEFAULTS. Nothing downstream can tell the two apart, so the fact has + to be recorded here, at the moment of the failure, or it is gone. + + The layer is still SKIPPED -- a half-parsed layer must never be merged, + which is the same rule `yamlkit.YamlKitError` exists for -- but the skip + now leaves a marker that `config_problems()` turns into a refusal. + + `layer` is the token from `_FILE_LAYERS`; passing none (the plant-name + pre-pass, which reads files only to answer "which plant" and throws its + holder away) records nothing. `notes` is where the marker goes when that + is not the tree being merged into -- `_merge_config_file` merges into a + holder of its own and the marker belongs on the real tree. + + The host-side gate for the same failure is `tools/knowledgelint.py`; the + test double that must stay this strict is `tests/pyrevit_yaml_fake.py`. + """ + if not path or not os.path.isfile(path): + return merged + try: + from pyrevit.coreutils import yaml as pyyaml + data = pyyaml.load_as_dict(path) + except Exception as ex: + _note_layer_unreadable(merged if notes is None else notes, + layer, path, ex) + return merged + if isinstance(data, dict): + coerced = _coerce(data) + # 'config' is excluded here because a project config file may wrap + # its knowledge in a `knowledge:` key -- `_merge_config_file` checks + # the UNWRAPPED body itself, after it has decided which shape this + # file is, rather than double-checking the raw one here. + if layer is not None and layer != 'config': + _note_capacity_override_emptied( + merged if notes is None else notes, layer, path, coerced) + _deep_update(merged, coerced) + return merged + + +# A digit-only string that leads with a zero: `0010001`, `03`, `00`. The +# author wrote the zero on purpose, so the value is a LABEL and stays a +# string. `0` alone is the number zero. +_LEADING_ZERO_LABEL = re.compile(r'^0\d+$') + +_NULLS = ('null', 'none', '~', '') +_TRUE = ('true', 'yes', 'on') +_FALSE = ('false', 'no', 'off') + + +def _coerce(node): + """Give YAML scalars their intended types. + + pyRevit's YAML reader hands back every scalar as a string -- `1.5` + arrives as `'1.5'` and `null` as `'null'` -- so the typing has to be + put back somewhere, and doing it once here beats a stray string + turning into an arithmetic error deep inside a check. + + THE ONE PLACE IT MUST NOT: a LEADING ZERO on an otherwise digit-only + string. `0010001` is a part mark (a blockout former on this job's + details 13/X4.2, 14/X4.2, 28/X4.5), not the number ten thousand and + one, and coercing it silently deleted three characters and changed + its type. Quoting in the YAML does not help -- coercion happens after + parsing, so `'0010001'` and `0010001` reach here identically. The + damage stayed invisible because it is SELF-CONSISTENT: comparisons + inside the knowledge tree still agreed, and only a comparison against + a string read from the model or printed on a drawing would disagree. + It also cost a whole `number` field in the detail catalogue, because + `'03'` arrived as `3` while its companion `'03A'` stayed a string -- + one field, two types. + + A leading zero is the author saying "this is a label". No genuinely + numeric setting is ever written `007`, so nothing numeric is lost: + `0`, `1.5`, `-3`, `0.5` and `1e3` all still arrive as numbers. + """ + if isinstance(node, dict): + return dict([(k, _coerce(v)) for k, v in node.items()]) + if isinstance(node, list): + return [_coerce(v) for v in node] + if not isinstance(node, string_types): + return node + + text = node.strip() + low = text.lower() + if low in _NULLS: + return None + if low in _TRUE: + return True + if low in _FALSE: + return False + if _LEADING_ZERO_LABEL.match(text): + return text # a part mark, not a number -- see above + try: + if re.match(r'^[+-]?\d+$', text): + return int(text) + if re.match(r'^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$', text): + return float(text) + except Exception: + pass + return text + + +def _nest_dotted_parameters(merged): + """Fold flat `parameters` keys ('panel.design_number: X') into the + nested tree ('parameters.panel.design_number') that `get` and + `param_names` walk. + + A job layer sampled off a live model wrote its role map in the flat + dotted form -- the form the role NAMES are spelled in everywhere else + -- and nothing ever read it: `get` walks nested keys only, so every + role in that file stayed unmapped and the plant's None placeholders + won (found 2026-09-03 when a slab's design number came back None + while the piece carried it). Both spellings are now one tree. The + flat entry is the LATER layer's word, so it overwrites whatever the + nested slot holds; the flat key is then dropped so nothing reads it + twice. Never raises. + """ + try: + params = merged.get('parameters') + if not isinstance(params, dict): + return + for key in list(params.keys()): + if not isinstance(key, string_types) or '.' not in key: + continue + value = params.pop(key) + node = params + parts = key.split('.') + for part in parts[:-1]: + child = node.get(part) + if not isinstance(child, dict): + child = {} + node[part] = child + node = child + node[parts[-1]] = value + except Exception: + pass + + +def get(knowledge, dotted, default=None): + node = knowledge + for part in dotted.split('.'): + if not isinstance(node, dict) or part not in node: + return default + node = node[part] + return default if node is None else node + + +def _deep_copy(node): + if isinstance(node, dict): + return dict([(k, _deep_copy(v)) for k, v in node.items()]) + if isinstance(node, list): + return [_deep_copy(v) for v in node] + return node + + +def _deep_update(base, override): + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + _deep_update(base[key], value) + else: + base[key] = value + return base + + +# ------------------------------------------------------- parameter naming + +PARAMETER_ROLES = ( + 'panel.design_number', 'panel.length', 'panel.width', + 'panel.unit_weight', 'panel.weight', + 'panel_type.thickness', + 'strand_type.diameter', 'strand_type.description', + 'wwf_type.description', + 'sheet.design_number', 'sheet.length', 'sheet.width', 'sheet.depth', + 'sheet.plant', + 'sheet.fc', 'sheet.fci', 'sheet.unit_weight', + # AN OPTIONAL ACCELERATOR FOR reinforce's same-elevation tie-breaker, + # never its source of truth -- the source of truth is geometric (a + # thickness-axis coordinate band, computed for every piece regardless; + # see `reinforcecore.elevation_band`). This role names an OPAQUE + # GROUPING KEY a plant may already carry on its walls for an unrelated + # reason: MC's is `Tagged_Sheet`, the drafting sheet a wall's elevation + # is drawn on -- owner, 2026-09-02, "对MC而言,所有的墙应该都有一个参数叫 + # Tagged_Sheet。如果俩panel这个参数一样,那么他们就是同一个elevation, + # 不用跑哪个几何引擎。" Two panels sharing a non-empty value here are the + # same elevation with no geometry looked at; unmapped or blank, the + # geometric floor decides instead, and that is the NORMAL state, not a + # gap -- other plants carry no such parameter at all, and even on MC it + # exists only on complex jobs. The parameter NAME is the plant's to + # declare; do not assume it exists on any job that has not mapped it. + 'panel.elevation_group', + 'member.mark', + # A BAR'S LENGTH, AND THE ONLY SOURCE FOR IT. Will, 2026-08-30: a bar's + # length -- BENT BARS INCLUDED -- is READ off the parameter this role + # maps to and never measured. His words: "钢筋的长度,即使是弯筋的 + # 长度,也都不要自己测量" -- never measure it yourself. A bent bar's + # stock consumption is its DEVELOPED length, which a bounding box + # understates and which the parameter carries. He named the parameter; + # the NAME belongs in the plant layer, not in this repository. A bar + # without it is could-not-judge and is named in the finding. + 'member.length', + # The free-text identity field a user writes the lateral marker into. + # Declared here so it is settable and visible; OPTIONAL so that a plant + # which never uses Lateral Extract is not nagged about it by every other + # button. Lateral Extract refuses on its own, with its own sentence, + # when this is unmapped -- see latextract.scan(). + # + # SECOND READER, added 2026-08-30: Reinforcing Suggestion reads the + # plant's reviewed-template marker (`reinforce.master_token`) out of the + # same field -- the only provenance that feature has. It stands down the + # same way, with its own sentence in its own report, rather than falling + # back to a parameter name written into the code. + 'member.identity_description', + # WHAT THE PLANT SAYS A MEMBER IS. A shared parameter carrying a short + # phrase per element, and `drawings.reinforcing_rule` reads it to decide + # which members are reinforcing. Measured 2026-08-30: it exists on BOTH + # the instance and the type and they are MUTUALLY EXCLUSIVE per element, + # which is why the rule reads instance first and falls back to type -- + # reading either alone finds nothing on half the model and the result + # looks clean. The plant's own thirteen view filters key on this same + # parameter, all of them with "contains" rather than "equals". + # + # OPTIONAL: a plant that does not carry it is not nagged on every run; + # the reinforcing rule stands down with its own sentence instead, and the + # enumeration is still there for a plant that prefers to name families. + 'member.manufacture_component', + # A BAR'S SIZE, as the printed form the stock tables are keyed by -- + # '#3', '#4', '#5'. `ticketchecks._bar_size` reads it (instance, then the + # TYPE) and normalises through `rebar_size`, so `catalogue.rebar. + # stock_by_size` can be used per size instead of one generic number. + # + # Declared here 2026-08-31, when a measurement finally answered which + # parameter carries it (open question A3). The NAME still belongs in the + # plant layer -- this is only the role. + # + # THE COVERAGE IS PARTIAL, and that is why this role is only half the + # answer. Measured over two whole assemblies (112 and 100 members, 82 of + # each): STRAIGHT bars carry the parameter; BENT ones -- and a bar-formed + # lifting loop -- do not carry it at all, on any of their types. + # + # BENT BARS ARE IN SCOPE ANYWAY. Owner, 2026-08-30: "rebars come in 40ft + # stock. unless overwritten by other knowledge, all rebars pieces must be + # less than 40ft" -- all pieces -- and separately that a bar's length is + # read off a parameter, bent bars included. So an unreadable size is a gap + # to answer, never a licence to set those bars aside; the second route is + # `drawings.bar_size_rule.description_fallback` and `bar_size_of()` says + # which of the two answered. + # + # OPTIONAL: a plant that carries no size parameter is not nagged on every + # run; 5.7 stands down per bar with its own sentence instead. + 'member.bar_size', + # WHICH FACE OF THE PIECE A PART GOES ON, as the model records it. + # + # A face designation is printed on the ticket as a parenthetical suffix + # (`P76 W/ SR5602 (S.I.F.)`) and it is ALSO a property of the element, and + # this role is the second of those. Checklist 5.3 asks whether the two + # agree; reading the model's own field is one layer closer to the data + # than reading the solid-or-dashed line the sheet draws, which is a + # rendering of the same fact. + # + # The raw value is not the printed token -- measured 2026-08-31, this + # plant's parameter holds `FF` where the ticket prints `F.F.` -- so the + # plant also states `face_designations.model_tokens`, and + # `face_from_model_token()` is the reader. See that function. + # + # OPTIONAL: a plant that records no face on its elements is not nagged + # every run; the check stands down and says which of the two halves it + # was missing. + 'member.face_designation', + + # WHICH PHYSICAL PIECE THIS IS, as against which design it was built to. + # + # A mark names a design: every panel cast to it carries the same one. A + # control number names ONE piece, and it is what a plant's paperwork, its + # yard and its erection sequence all key on -- so a form that shows an + # engineer a mark alone has told them what the piece is but not which + # piece it is. + # + # OPTIONAL, and separate from `member.mark` on purpose. Some plants keep + # both; some keep only one and map their single identifier to + # `member.mark` (this one maps it to the mapped mark parameter, + # which is the mark AND the control number at once). Where the two would + # print the same string, callers show it once. A plant that records no + # control number gets no parenthetical rather than a fabricated one. + 'member.control_number', +) + +# Roles a check can do without. Everything else unmapped is worth reporting. +# member.mark is optional because the built-in 'Mark' parameter is the +# general fallback; the plant mapping only exists to name its own. +OPTIONAL_ROLES = ('sheet.plant', 'member.mark', 'panel.elevation_group', + 'member.identity_description', + 'member.manufacture_component', + 'member.bar_size', + 'member.face_designation', + 'member.control_number') + + +def param_names(knowledge, role): + """Candidate parameter names for a role, in priority order. + + Accepts either a single name or a list, because pyRevit's YAML reader is + happiest with plain scalars and most shops only have one name per role. + """ + value = get(knowledge, 'parameters.' + role) + if value is None: + return [] + if isinstance(value, string_types): + value = [value] + return [str(v).strip() for v in value if str(v).strip()] + + +def unmapped_roles(knowledge, include_optional=False): + """Roles with no parameter name behind them. + + Mapped in the plant file, since they describe the office template rather + than the job; a project file may still override one. + + Roles listed under `parameters.not_applicable` are declared NOT TO + EXIST at this plant (its tickets do not print that value; the value + comes from each design's spec instead). They are separated from + genuinely missing mappings -- 'we know it is absent' and 'we never + thought of it' must look different (Will 08-28: unit_weight is + per-design, never a ticket role here). + """ + not_applicable = set(get(knowledge, 'parameters.not_applicable', []) + or []) + missing = [] + for role in PARAMETER_ROLES: + if not include_optional and role in OPTIONAL_ROLES: + continue + if role in not_applicable: + continue # declared absent -> not a miss + if not param_names(knowledge, role): + missing.append(role) + return missing, not_applicable + + +# ------------------------------------------------------------ family tests + +def matches(name, includes, excludes): + up = (name or '').upper() + if not any(tok.upper() in up for tok in (includes or [])): + return False + return not any(tok.upper() in up for tok in (excludes or [])) + + +def is_strand(knowledge, family_name): + return matches(family_name, + get(knowledge, 'families.strand_include', ['STRAND']), + get(knowledge, 'families.strand_exclude', [])) + + +def is_lifter(knowledge, family_name): + return matches(family_name, + get(knowledge, 'families.lifter_include', []), + get(knowledge, 'families.lifter_exclude', [])) + + +def _longest_substring_match(table, family_name): + """The value whose key is the longest substring of the family name.""" + up = (family_name or '').upper() + best, best_len = None, -1 + for key, value in (table or {}).items(): + ku = str(key).upper() + if ku in up and len(ku) > best_len: + best, best_len = value, len(ku) + return best + + +def _longest_substring_key(table, family_name): + """(key, value) of the entry whose key is the longest substring of the + family name; (None, None) when nothing matches.""" + up = (family_name or '').upper() + best_key, best, best_len = None, None, -1 + for key, value in (table or {}).items(): + ku = str(key).upper() + if ku in up and len(ku) > best_len: + best_key, best, best_len = key, value, len(ku) + return best_key, best + + +def is_lpa_family(family_name): + """Owner ruling (2026-09-05, verbatim): 'LPA8Txxx 在the plant的厂知识里面 + 没有shear rating,但是你去ALP的知识(公共知识里面),不就能找到shear + rating了吗?' and 'LPA8T当例子,检查fallback机制为啥没能实现,并且修。' + -- restating the owner's 2026-09-02 ruling that an LPA-prefixed family + is a face lifter regardless of whether the plant's whitelist happens to + spell out that exact part number. This is the ONE fallback rule, shared + by `_is_face_lifter` here and `ticketchecks._is_face_stripping_lifter`, + so the two checks can never quietly diverge on which families count.""" + return (family_name or '').upper().startswith('LPA') + + +def _is_face_lifter(knowledge, family_name): + """Is this family one the plant groups as a FACE (back-cast) lifter -- + `handling.lifter_groups.face.families`, matched as substrings the way + the export groups them. + + The plant's whitelist is enumerated by hand and does not keep up with + every LPA part number it casts (LPA8T10 was missing; LPA2T338, LPA2T512, + LPA4T414, LPA4T718, LPA8T634 are the next ones due). When the whitelist + misses, fall back to `is_lpa_family` -- the same LPA-prefix rule + `ticketchecks._is_face_stripping_lifter` already used, per the owner's + ruling above. A family that is not LPA-prefixed and not in the + whitelist is still not a face lifter; this fallback only widens what + counts as LPA, it never narrows the whitelist's own hits.""" + up = (family_name or '').upper() + for fam in get(knowledge, 'handling.lifter_groups.face.families', []) or []: + if as_text(fam).strip().upper() and as_text(fam).strip().upper() in up: + return True + return is_lpa_family(family_name) + + +def catalogue_item(knowledge, family_name): + """The catalogue designation a modelled family stands for, or None. + + A plant models its own part number; the catalogue is written against the + manufacturer's. This is the hop between them. + """ + alias = _longest_substring_match( + get(knowledge, 'lifters.catalogue_alias', {}), family_name) + return str(alias) if alias else None + + +def concrete_derate(entry, fc_psi): + """How much of a catalogue rating survives at the concrete you have. + + A catalogue rating is derived at one strength -- the QuikLift tables are + at 3500 psi. Below that the concrete governs and the capacity follows + sqrt(f'c). ABOVE it nothing is gained, because the anchor's own mechanical + capacity takes over as the governing failure: + + factor = min(1, sqrt(f'c actual / basis)) + + Releasing at 2500 psi on a 3500 psi table therefore costs + sqrt(2500/3500) = 0.845, and releasing at 6000 buys nothing. + + Returns 1.0 when either the strength or the basis is unknown -- an + unstated basis must not silently reduce a rating. + """ + if not isinstance(entry, dict) or not fc_psi: + return 1.0 + basis = entry.get('basis_fc_psi') + if not basis or basis <= 0 or fc_psi <= 0: + return 1.0 + if fc_psi >= basis: + return 1.0 + return (float(fc_psi) / float(basis)) ** 0.5 + + +def part_prefixes(knowledge): + """{prefix (upper) -> entry} for every declared part-number prefix. + + Empty means NOBODY HAS DECLARED THE GRAMMAR. It never means a part number + has no prefix, and a caller that gets {} must stand down rather than + reading the letters itself. + """ + table = get(knowledge, 'part_numbers.prefixes') + if not isinstance(table, dict): + return {} + out = {} + for key, entry in table.items(): + if key is None: + continue + prefix = as_text(key).strip().upper() + if not prefix: + continue + out[prefix] = dict(entry) if isinstance(entry, dict) else {} + out[prefix]['prefix'] = as_text(key).strip() + return out + + +def part_prefix(knowledge, part_number): + """Which declared prefix a part number carries. (prefix, entry, why). + + LONGEST PREFIX WINS, and it is the whole point rather than a tie-break: + `SP78` is an SP, not a P that happens to start with an S. Resolved the + short way round, a job's special plate is filed in the plant's + standard-plate namespace -- which is the same merge a substring matcher + makes on a bare part number, and it has already cost once here on a + lifting check. + + (None, None, why) when nothing declares the grammar, when the value is + empty, or when no declared prefix matches. The last is a real answer and + not a failure: a part number in a shape nobody has described is a fact + about the corpus, and inventing a prefix for it would file it on somebody's + desk at random. + """ + table = part_prefixes(knowledge) + text = as_text(part_number).strip() if part_number is not None else '' + if not text: + return None, None, ('no part number was given, so no prefix was ' + 'read.') + if not table: + return None, None, ( + 'part_numbers.prefixes is empty: no layer states what a part ' + 'number\'s prefix means, so {0!r} says nothing about which layer ' + 'defines it.'.format(text)) + up = text.upper() + best = None + for prefix in table: + if up.startswith(prefix) and (best is None or len(prefix) > len(best)): + best = prefix + if best is None: + return None, None, ( + '{0!r} starts with none of the declared part-number prefixes ' + '({1}), so nothing here says which layer defines it. Nothing was ' + 'guessed.'.format(text, ', '.join(sorted(table)))) + entry = table[best] + return best, entry, ( + '{0!r} is a {1!r} part -- {2} -- which is {3} knowledge{4}.' + .format(text, entry.get('prefix') or best, + entry.get('means') or 'meaning unrecorded', + entry.get('layer') or 'of an unrecorded layer', + '' if entry.get('verified') + else ', and that reading is UNVERIFIED: {0}'.format( + entry.get('unverified_because') or 'no reason recorded'))) + + +# ============================================================================ +# WHICH KIND OF MEMBER THIS IS, AND WHICH CHECKLIST THAT PLANT ASKS OF IT +# ============================================================================ +# +# Owner, 2026-08-31: "按照 plant-member 这种级别,给 ticketqc 的 item 重新编号, +# 让 ticketqc 自动识别 plant-member, 并且做正确的 check。" +# +# Three resolutions, in order, and they are separate functions because they +# fail separately and because two of them already existed: +# +# member kind from the MARK PREFIX (`nomenclature.member_kinds`), read +# through `scope.prefix_of` so the scope and the checklist +# can never disagree about what a mark's prefix is. +# panel type from MEASURED SIGNALS (`classify_panel`), and it refines +# WITHIN a member kind -- an insulated wall panel is a wall +# panel first. +# profile selected on the PAIR. A profile that names a panel type is +# more specific than one that names only a member kind, and +# the more specific one wins. +# +# EVERY STEP MAY ANSWER "I COULD NOT TELL", AND EACH TIME THAT RESOLVES +# TOWARDS ASKING MORE. No member kind -> no profile -> the full table under +# its own numbers. That is not a degraded mode; it is the mode this tool ran +# in before any of this existed, and it is the compatibility contract. + + +def member_kinds(knowledge): + """{MARK PREFIX (upper): member kind id} -- this plant's naming. + + Empty means NOBODY HAS DECLARED THE MAPPING. It never means every piece + is the same kind, and a caller that gets {} must resolve every piece to + an unknown member kind rather than to a default one. + """ + table = get(knowledge, 'nomenclature.member_kinds') + if not isinstance(table, dict): + return {} + out = {} + for prefix in table: + key = as_text(prefix).strip().upper() + kind = table[prefix] + kind = as_text(kind).strip() if kind is not None else '' + if key and kind: + out[key] = kind + return out + + +def member_kind_of(knowledge, mark): + """What kind of member this mark names. A dict, and None is a real answer. + + member_kind the id, or None -- NOT TOLD APART, never a default + prefix the leading letters `scope.prefix_of` read, or None + why one sentence + + THE PREFIX COMES FROM `scope.prefix_of` AND THE LOOKUP IS EXACT. That is + scope.py's own ruling, argued there at length: the prefix is the WHOLE + leading letter run, so a declared `AAA` does not reach a mark `AAAB001`, + whose prefix is `AAAB`. Matching a declared prefix AS a prefix was + considered there and rejected, and re-deciding it here would give the + scope and the checklist two different opinions about the same mark -- + silently, and only on the pieces where it matters. + """ + from vibe import scope as scopelib + table = member_kinds(knowledge) + prefix = scopelib.prefix_of(mark) + out = {'member_kind': None, 'prefix': prefix, 'why': ''} + if not table: + out['why'] = ( + 'nomenclature.member_kinds is empty: no layer says what kind of ' + 'member a mark prefix names at this plant, so no piece can be ' + 'placed as one.') + return out + if prefix is None: + out['why'] = ( + '{0!r} has no readable letter prefix, so what kind of member it ' + 'is could not be read. Nothing was assumed.' + .format(as_text(mark) if mark is not None else None)) + return out + kind = table.get(prefix) + if not kind: + out['why'] = ( + 'the mark prefix {0!r} is not one of the declared member-kind ' + 'prefixes ({1}), so what kind of member this is, is not written ' + 'down. Nothing was assumed -- and note the prefix is the WHOLE ' + 'leading letter run, so a prefix that merely STARTS with a ' + 'declared one is deliberately not a match (scope.py).' + .format(prefix, ', '.join(sorted(table)))) + return out + out['member_kind'] = kind + out['why'] = ('the mark prefix {0!r} says this is a {1!r}.' + .format(prefix, kind)) + return out + + +# ------------------------------------------- the checklist this plant asks + +UNLISTED_ASK = 'ask' +UNLISTED_NOT_APPLICABLE = 'not_applicable' +UNLISTED_POLICIES = (UNLISTED_ASK, UNLISTED_NOT_APPLICABLE) + + +def checklist_profiles(knowledge): + """{profile id: entry} for every declared checklist profile.""" + table = get(knowledge, 'checklists.profiles') + if not isinstance(table, dict): + return {} + out = {} + for key in table: + entry = table[key] + name = as_text(key).strip() + if name: + out[name] = dict(entry) if isinstance(entry, dict) else {} + return out + + +def _profile_row(row, where, problems): + """One board line as (display number, master id), or None. + + TWO SPELLINGS, AND THE FIRST ONE IS THE SAFE ONE: + + - '2.1 = 3.3' <- write this + - {number: '2.1', check: '3.3'} <- accepted, and it has a trap + + THE TRAP, MEASURED 2026-08-31 AND IT COST A ROW. `_coerce` gives YAML + scalars their intended types, and it runs on VALUES. A number in a value + position is numified -- so `5.10` becomes the FLOAT 5.1, which is the + same value as `5.1`, and two different master ids collide into one. The + board silently lost a question and gained a duplicate. + + It is the leading-zero lesson (`0010001` is a part mark, not a number) + with the zero at the other end, and it is worse: a leading zero at least + changes the text, while `5.10` -> `5.1` lands on an id that really + exists. Quoting does not save it either, for the reason `_coerce`'s own + docstring gives -- coercion happens after parsing. + + So the `=` form is the recommended one: `'2.1 = 5.10'` never parses as a + number, whether or not anybody quoted it. And the map form REFUSES a + non-string number rather than accepting a value that may already have + been mangled -- there is no way to tell 5.1 from a flattened 5.10 after + the fact, and guessing would put the wrong question on the board. + """ + if isinstance(row, (int, float)) and not isinstance(row, bool): + problems.append( + '{0} is the number {1!r}. A board line must be text -- a bare ' + 'number is coerced (5.10 becomes 5.1, which is a DIFFERENT ' + 'master id), so write it as `\' = \'`.' + .format(where, row)) + return None + if isinstance(row, string_types): + if '=' not in row: + problems.append( + '{0} is {1!r}. A board line is `\' = \'`.' + .format(where, row)) + return None + number, check = row.split('=', 1) + return (as_text(number).strip(), as_text(check).strip()) + if not isinstance(row, dict): + problems.append('{0} is {1!r}, which is neither a `\' = ' + '\'` line nor a {{number, check}} map. It ' + 'was dropped from the board.'.format(where, row)) + return None + out = [] + for field in ('number', 'check'): + value = row.get(field) + if isinstance(value, (int, float)) and not isinstance(value, bool): + problems.append( + '{0}.{1} arrived as the number {2!r} rather than as text, and ' + 'a number here cannot be trusted: `5.10` is coerced to 5.1, ' + 'which is a different master id, and nothing can tell the two ' + 'apart afterwards. It was REFUSED. Write the line as ' + '`\' = \'`.'.format(where, field, value)) + return None + out.append(as_text(value).strip() if value is not None else '') + return (out[0], out[1]) + + +def _profile_items(entry, problems): + """[(display number, master id)] for one profile, in order. + + A malformed row is REPORTED and dropped from the board rather than + guessed at -- and dropping it from the BOARD is safe in a way dropping + it from the RUN would not be, because `unlisted` decides what happens to + a check no board names, and it defaults to asking. + """ + rows = entry.get('items') + if rows is None: + return None + if not isinstance(rows, (list, tuple)): + problems.append('items must be an ordered LIST of {number, check} ' + 'entries; {0!r} is not one, so this profile names no ' + 'board at all.'.format(rows)) + return None + out, seen_ids, seen_numbers = [], {}, {} + for index, row in enumerate(rows): + where = 'items[{0}]'.format(index) + parsed = _profile_row(row, where, problems) + if parsed is None: + continue + number, check = parsed + if not check: + problems.append('{0} names no `check`, so nothing says which ' + 'question it puts on the board. It was dropped.' + .format(where)) + continue + if not number: + # The display number falling back to the stable id is safe and + # visible; inventing a position in somebody's numbering is not. + number = check + problems.append('{0} names check {1!r} with no `number`, so it ' + 'is shown under its stable id instead of a ' + 'profile number.'.format(where, check)) + if check in seen_ids: + problems.append('{0} puts check {1!r} on the board twice (already ' + 'at {2}). The second was dropped -- a question ' + 'asked twice under two numbers is two answers a ' + 'reader has to reconcile.' + .format(where, check, seen_ids[check])) + continue + if number in seen_numbers: + problems.append('{0} reuses display number {1!r}, which already ' + 'belongs to check {2!r}. Both are kept and shown, ' + 'because dropping one would hide a question, but ' + 'the numbering is ambiguous and should be fixed.' + .format(where, number, seen_numbers[number])) + seen_ids[check] = number + seen_numbers[number] = check + out.append((number, check)) + return out + + +def profile_for(knowledge, member_kind=None, panel_type=None): + """The checklist profile for this piece. A dict, always every key. + + profile the id, or None -- and None means THE FULL TABLE under + its own numbers, which is the compatibility contract + items [(display number, stable check id)] or None + unlisted 'ask' (default) or 'not_applicable' + title/because/since/declared_by as declared + basis 'member_kind+panel_type' | 'member_kind' | 'none' + why one sentence + problem a sentence when a declaration was found and REFUSED + + MOST SPECIFIC WINS. A profile naming both a member kind and a panel type + beats one naming only the member kind -- an insulated wall panel is a + wall panel first, and a plant that has said something particular about + the insulated ones has said the more particular thing. + """ + out = {'profile': None, 'items': None, 'unlisted': UNLISTED_ASK, + 'unlisted_because': None, 'unlisted_since': None, + 'title': None, 'because': None, 'since': None, + 'declared_by': None, 'basis': 'none', 'why': '', 'problem': None, + 'member_kind': member_kind, 'panel_type': panel_type} + declared = checklist_profiles(knowledge) + if not declared: + out['why'] = ( + 'checklists.profiles is empty: no layer declares a checklist for ' + 'any kind of member, so the full checklist runs under its own ' + 'numbering -- which is what this tool did before profiles ' + 'existed.') + return out + if not member_kind: + out['why'] = ( + 'this piece was not placed as a kind of member, so no profile ' + 'could be selected and the FULL checklist runs under its own ' + 'numbering. A piece whose kind cannot be told is asked more, ' + 'never less.') + return out + + wanted = as_text(member_kind).strip().lower() + kind_only, both = [], [] + for name in sorted(declared): + entry = declared[name] + kind = entry.get('member_kind') + kind = as_text(kind).strip().lower() if kind is not None else '' + if kind != wanted: + continue + want_type = entry.get('panel_type') + want_type = (as_text(want_type).strip() + if want_type is not None else '') + if not want_type: + kind_only.append(name) + elif (panel_type and + as_text(panel_type).strip().lower() == want_type.lower()): + both.append(name) + + problems = [] + chosen, basis = None, 'none' + if len(both) == 1: + chosen, basis = both[0], 'member_kind+panel_type' + elif len(both) > 1: + problems.append( + 'more than one profile ({0}) claims member kind {1!r} AND panel ' + 'type {2!r}, and a piece cannot have two checklists. NONE was ' + 'used and the full checklist ran.' + .format(', '.join(both), member_kind, panel_type)) + elif len(kind_only) == 1: + chosen, basis = kind_only[0], 'member_kind' + elif len(kind_only) > 1: + problems.append( + 'more than one profile ({0}) claims member kind {1!r}, and a ' + 'piece cannot have two checklists. NONE was used and the full ' + 'checklist ran.'.format(', '.join(kind_only), member_kind)) + + if chosen is None: + out['problem'] = ' '.join(problems) or None + out['why'] = out['problem'] or ( + 'no declared profile claims member kind {0!r} (declared: {1}), so ' + 'the full checklist runs under its own numbering.' + .format(member_kind, ', '.join(sorted(declared)) or 'none')) + return out + + entry = declared[chosen] + items = _profile_items(entry, problems) + policy = entry.get('unlisted') + policy = as_text(policy).strip().lower() if policy is not None else '' + if not policy: + policy = UNLISTED_ASK + elif policy not in UNLISTED_POLICIES: + problems.append( + 'unlisted is {0!r}, which is not one of {1}. It was read as {2!r} ' + '-- the safe reading, because the other one stops asking ' + 'questions nobody has decided about.' + .format(policy, ', '.join(UNLISTED_POLICIES), UNLISTED_ASK)) + policy = UNLISTED_ASK + if policy == UNLISTED_NOT_APPLICABLE: + reason = _said(entry, 'unlisted_because') + since = _said(entry, 'unlisted_since') + if not reason or not since or not _DATE_RE.match(since): + problems.append( + 'unlisted is {0!r}, which is a standing promise about checks ' + 'nobody has written yet, and it carries no reason and/or no ' + 'YYYY-MM-DD date. It was REFUSED and read as {1!r}: a check ' + 'this board does not name is ASKED.' + .format(UNLISTED_NOT_APPLICABLE, UNLISTED_ASK)) + policy = UNLISTED_ASK + + out['profile'] = chosen + out['items'] = items + out['unlisted'] = policy + out['unlisted_because'] = _said(entry, 'unlisted_because') + out['unlisted_since'] = _said(entry, 'unlisted_since') + out['title'] = _said(entry, 'title') + out['because'] = _said(entry, 'because') + out['since'] = _said(entry, 'since') + out['declared_by'] = _declarer(entry) + out['basis'] = basis + out['problem'] = ' '.join(problems) or None + out['why'] = ( + 'this piece is a {0!r}{1}, so the {2!r} checklist was used ({3} ' + 'item(s) on its board; a check it does not name is {4}).' + .format(member_kind, + ' and a {0!r}'.format(panel_type) + if basis == 'member_kind+panel_type' else '', + chosen, len(items) if items else 0, + 'asked anyway' if policy == UNLISTED_ASK + else 'not applicable')) + return out + + +# ============================================================================ +# HOW A HUMAN NAMES A CHECK, NOW THAT PLANTS RENUMBER THEM +# ============================================================================ +# +# Owner, 2026-08-31: "就是得重新编号。以后引用需要前面加 plant-member。" +# +# THE GRAMMAR, and there are exactly two forms: +# +# 3.3 a BARE number is a MASTER ID -- an entry in +# `checklist.master` in the public catalogue. +# Every ruling written before 2026-08-31 is in +# this form and every one of them still means +# exactly what it meant. +# +# sample_plant-wall_panel 3.3 QUALIFIED: - , +# where the number is a position on THAT plant's +# board for THAT kind of member. It resolves to a +# master id through `checklists.profiles`. +# +# HOW IT IS SPLIT, and the two rules are chosen against real ids rather than +# for tidiness: +# +# * the QUALIFIER and the NUMBER are separated by whitespace, because a +# display number contains dots and a member kind contains underscores, +# and a space is the one character neither of them has. +# * the qualifier splits on its LAST hyphen, so a plant whose own id +# carries one (`acme-precast-wall_panel`) still resolves: member kinds +# are single tokens by convention and plants are not. +# +# WHY A QUALIFIED REFERENCE IS RESOLVED AT READ TIME AND STORED AS A MASTER +# ID. This is the whole reason the grammar exists rather than the numbers +# simply changing. A mute written as `sample_plant-wall_panel 3.3` becomes master +# id `3.3` the moment it is read, so when the plant renumbers its board +# tomorrow the mute follows the QUESTION and not the POSITION. A ruling can +# no longer be orphaned by a renumbering, which is the property the whole +# design was built to have -- his three mutes survived one renumbering today +# only because nobody renumbered. +# +# ---- AND A RULE FOR WHOEVER WRITES THE NEXT EXAMPLE ------------------- +# +# THE QUALIFIED FORM CONTAINS A PLANT NAME BY CONSTRUCTION, which puts this +# grammar in direct tension with the rule that no client, job or plant name +# appears in a tracked file (PROJECT.md 3.4). The resolution, and it is not +# a workaround: +# +# * a REAL qualified reference is a real plant's business, and it lives +# where that is allowed -- the gitignored plant and job layers, and QNA. +# * every EXAMPLE in tracked code, comments or docs uses a placeholder +# plant (`sample_plant-wall_panel 3.3`). +# +# This was learned the direct way on the day the grammar landed: the first +# examples were written with the real plant's name and took the scrub gate +# from 2 hits to 7 across three tracked files. The tension is permanent -- +# every future example writer meets it -- so it is written here, beside the +# grammar, rather than left to the gate to catch each time. +# +# AND A QUALIFIER THAT NAMES ANOTHER PLANT IS REFUSED, not ignored and not +# applied. A mute someone copied from another plant's job file is a +# statement about a board this run is not using, and silently applying it +# would quieten a question nobody here decided about. + + +def _split_reference(text): + """('', '', '') or None.""" + parts = as_text(text).strip().split() + if len(parts) == 1: + return (None, None, parts[0]) + if len(parts) != 2: + return None + qualifier, number = parts + if '-' in qualifier: + plant, member = qualifier.rsplit('-', 1) + return (plant.strip() or None, member.strip() or None, number) + return (None, qualifier.strip() or None, number) + + +def resolve_check_reference(knowledge, reference, plant=None, ids=None): + """A written reference to a check -> its MASTER ID. (id, why, problem). + + `ids` is the master universe (`ticketpage.CHECKLIST_IDS`), injected so + this module never imports the page. `plant` is the plant this run is + actually using, so a qualifier naming a different one can be refused. + + (None, why, problem) is a real answer: a reference nobody can resolve is + reported, never dropped. A mute that quietly resolved to nothing is a + row the owner believes is quiet and is not -- or worse, a row he + believes is being asked and is not. + """ + ids = tuple(ids or ()) + text = as_text(reference).strip() if reference is not None else '' + if not text: + return None, 'no reference was given.', 'an empty check reference.' + parts = _split_reference(text) + if parts is None: + return None, '', ( + '{0!r} is not a check reference. Write a master id (`3.3`) or a ' + 'qualified one (`- `).'.format(text)) + ref_plant, ref_member, number = parts + + if ref_member is None: + if not ids or number in ids: + return number, ( + '{0!r} is a master id.'.format(number)), None + return None, '', ( + '{0!r} is not a master checklist id, and it carries no ' + '- qualifier, so nothing can place it. The ' + 'master ids are {1}.'.format(number, ', '.join(ids))) + + if ref_plant and plant and \ + as_text(ref_plant).strip().lower() != as_text(plant).strip().lower(): + return None, '', ( + '{0!r} is qualified for plant {1!r} and this run is using plant ' + '{2!r}. It was REFUSED rather than applied: a reference to ' + 'another plant\'s board says nothing about this one.' + .format(text, ref_plant, plant)) + + profile = profile_for(knowledge, member_kind=ref_member) + items = profile.get('items') + if not profile.get('profile') or items is None: + return None, '', ( + '{0!r} names the {1!r} checklist and {2} so the number could not ' + 'be placed. Write the master id instead, or declare the profile.' + .format(text, ref_member, + (profile.get('why') or 'no such profile is declared,'))) + for (display, check) in items: + if as_text(display).strip() == number: + return check, ( + '{0!r} is number {1} on the {2!r} board, which is master id ' + '{3}.'.format(text, number, profile.get('profile'), check)), None + return None, '', ( + '{0!r} names number {1} on the {2!r} board, and that board has no ' + 'such number (it has {3}). Nothing was matched to it.' + .format(text, number, profile.get('profile'), + ', '.join([d for (d, _c) in items]) or 'none')) + + +def qualified_reference(plant, member_kind, number): + """How this tool WRITES a reference, so every surface spells it one way. + + `sample_plant-wall_panel 3.3`. Falls back to the bare number where there is no + plant or no member kind -- which is exactly the case where a bare number + IS the whole truth, because no board renumbered it. + """ + number = as_text(number).strip() + plant = as_text(plant).strip() if plant else '' + member = as_text(member_kind).strip() if member_kind else '' + if not member: + return number + if not plant: + return '{0} {1}'.format(member, number) + return '{0}-{1} {2}'.format(plant, member, number) + + +# ---------------------------------- a bar that ships inside another part +# +# THE FACT EXISTED AND ONE CHECK COULD NOT SEE IT (2026-08-31). The owner: +# "SR4996 是 SP29/30 的附属 rebar,我记得我增加过这条知识了,为什么还在 WP117 +# 报 warn?" He had recorded it, three times over -- in +# `interference.exempt_families` with his dated comment, in +# `interference.component_groups`, and in `drawings. +# annotation_covered_by_parent`, which the plant file's own +# `rebar_must_appear_on` block states the rule for, nearly verbatim: +# +# "a bar that is PART OF another item on the BOM is covered by that item's +# callout and is never drawn on its own... Looking for SR4996 as an +# independent bar and failing the ticket when it is not found would be the +# checker misreading the BOM, not the ticket being wrong." +# +# 5.2 read the key. The takeoff read the key. **5.6 did not**, so its +# "modelled, never printed" leg reported a bar that is by design never drawn +# on its own. That is the drift detector's oldest category -- LANDED +# KNOWLEDGE WITH NO READER -- live on a check, and it is why this accessor +# exists rather than a third inline `get()`: three readers spelling one fact +# three ways is how the fourth one comes to disagree. + + +def attached_parents(knowledge): + """{BAR MARK (upper): [parent part, ...]} -- bars that ship inside a part. + + `drawings.annotation_covered_by_parent`, normalised. A MAP, so the layers + deep-merge and a job adds its own SP-series bars without deleting the + plant's hardware ones -- which is the opposite of how + `interference.exempt_families` behaves beside it, and the reason that list + carries a restated block and this map does not. + + A single parent may be written as a scalar; it comes back as a one-item + list, so no caller has to test the shape. + """ + source = get(knowledge, 'drawings.annotation_covered_by_parent', {}) + if not isinstance(source, dict): + return {} + out = {} + for child in source: + parents = source[child] + if isinstance(parents, (list, tuple)): + named = [as_text(p).strip() for p in parents + if as_text(p).strip()] + elif parents is None: + named = [] + else: + named = [as_text(parents).strip()] if as_text( + parents).strip() else [] + key = as_text(child).strip().upper() + if key: + out[key] = named + return out + + +# ------------------------- is a printed token a bar mark or a piece of steel +# +# 5.6 compares the marks printed on a ticket against the bars in the model, +# and until 2026-08-31 it took EVERY mark-shaped token on the sheet for a +# candidate bar mark. Measured on WP115: `P78`, `P114`, `SP29`, `SP30` and +# `SP31` were all reported as "printed bar marks matching no bar in the +# model". They are hardware. They match no bar because they are not bars. +# +# The grammar to tell them apart landed the day before -- `part_numbers. +# prefixes`, longest-prefix-wins -- but it records what a prefix MEANS as +# PROSE ("standard plate", "reinforcing mark"), and prose is not a predicate. +# Parsing `means` to decide would be the same mistake as classifying a panel +# off `model_signals`. So the population is its own declared field, `kind`. +# +# THE TWO ANSWERS ARE NOT SYMMETRIC, and the whole design is in the +# difference: +# +# kind: bar_mark ADDS a token to the comparison. Wrong, it produces a +# visible finding about a token that is not a bar. +# kind: hardware REMOVES a token from the comparison. Wrong, a real +# printed bar mark stops being compared and the row goes +# quiet -- a silent pass on the exact defect 5.6 exists +# to find. +# +# So `kind: hardware` is honoured ONLY on a prefix entry that is +# `verified: true`. An unverified hardware claim is REFUSED and the token +# stays unclassified, which is a finding. A reading is not a ruling, and a +# reading may not switch a comparison off. +# +# AND WHEN NOBODY HAS DECLARED THE GRAMMAR AT ALL, this stands down to +# 'ungoverned' and the caller treats every token as a candidate -- which is +# what 5.6 did before any of this existed. Standing down here has to mean +# "do not narrow", because the other reading would empty the comparison for +# every plant that has not written a grammar yet. + +PART_KINDS = ('bar_mark', 'hardware') + + +def printed_token_population(knowledge, token): + """Which population a printed token belongs to. (population, why). + + 'bar_mark' compare it against the modelled bar marks + 'hardware' it is a part, not a bar -- not a candidate + 'unknown' the grammar cannot place it. A finding, never a drop. + 'ungoverned' no layer declares the grammar at all, so nothing was + narrowed and the token stays a candidate. + """ + table = part_prefixes(knowledge) + text = as_text(token).strip() if token is not None else '' + if not table: + return 'ungoverned', ( + 'part_numbers.prefixes is empty: no layer states what a part ' + 'number looks like at this plant, so nothing was set aside and ' + 'every printed token is compared as a bar mark.') + if not text: + return 'unknown', 'no token was given.' + prefix, entry, why = part_prefix(knowledge, text) + if prefix is None: + return 'unknown', why + kind = entry.get('kind') + kind = as_text(kind).strip().lower() if kind is not None else '' + if not kind: + return 'unknown', ( + '{0!r} is a {1!r} part number, and part_numbers.prefixes[{1}] ' + 'declares no `kind`, so nothing says whether a {1} is a bar mark ' + 'or a piece of hardware. It was NOT set aside.' + .format(text, prefix)) + if kind not in PART_KINDS: + return 'unknown', ( + '{0!r} is a {1!r} part number whose declared kind is {2!r}, which ' + 'is not one of {3}. It was NOT set aside.' + .format(text, prefix, kind, ', '.join(PART_KINDS))) + if kind == 'hardware' and not entry.get('verified'): + return 'unknown', ( + '{0!r} is a {1!r} part number declared `kind: hardware`, but that ' + 'prefix is `verified: false` -- {2} Setting a token aside REMOVES ' + 'it from the comparison, so an unverified reading may not do it: ' + 'the entry was REFUSED and the token was NOT set aside.' + .format(text, prefix, + entry.get('unverified_because') or + 'no reason is recorded for why the reading is ' + 'unverified.')) + return kind, why + + +def part_defining_layer(knowledge, part_number): + """Which knowledge layer should define this part. (layer, why). + + 'plant', 'job', or None. **None is not "no layer"** -- it is "nothing says + which", and a caller must report it as an unclassifiable part number + rather than picking a layer to blame. + + WHAT A CHECK WOULD CALL. For a part printed on a ticket that nothing can + resolve: ask this, and route the finding by the answer. A `plant` part + that cannot be looked up is a gap in the plant's standard-material + knowledge -- one message, one owner. A `job` part that cannot be looked up + is a gap in the job file -- a different message and a different owner. + Today a check reports both as "unknown part", which is right half the + time. Nothing here decides a checklist row; it decides whose question it + is. + """ + prefix, entry, why = part_prefix(knowledge, part_number) + if entry is None: + return None, why + layer = entry.get('layer') + if layer is None or not as_text(layer).strip(): + return None, ('{0!r} is a {1!r} part and no layer is recorded for ' + 'that prefix, so whose knowledge should define it is ' + 'not written down.'.format(part_number, prefix)) + return as_text(layer).strip(), why + + +def panel_types(knowledge): + """The panel types a layer declares, as {id: description}. + + Empty means NOBODY HAS DECLARED ANY -- never "this plant builds one kind + of panel". A caller that gets {} must treat every piece as unclassified + and say so. + """ + table = get(knowledge, 'panel_types.types') + return dict(table) if isinstance(table, dict) else {} + + +def panel_type_of(knowledge, stated=None): + """(panel type id, sentence). None is a real answer and stays one. + + `stated` is a type a caller has already established by some other means -- + read off the piece, or handed down from a spec. THERE IS NO DETECTION + HERE, deliberately: how a panel type is recognised is plant knowledge and + a model read, and neither belongs in this function. What belongs here is + the refusal to invent one. + + `panel_types.default` is consulted and ships **null**. A default is the + tempting fix and it is the one that caused this: reading an unclassified + piece as the ordinary panel type is exactly how an insulated panel came to + be checked for a face lifter it does not carry, on every lifting check at + once, all of them green. + + AND A DEFAULT IS REFUSED WHILE `panel_types.detect_verified` IS FALSE. + The two keys only make sense together: a default is "what to assume for + the pieces classification could not place", and while NOTHING can classify + anything it is not a default, it is a blanket assertion that every piece + in the job is one type. That is the failure verbatim, so the default is + reported as unusable rather than applied. Setting `detect_verified: true` + is the deliberate act that turns it on. + """ + declared = panel_types(knowledge) + if stated is not None and as_text(stated).strip(): + got = as_text(stated).strip() + for key in declared: + if as_text(key).strip().lower() == got.lower(): + return key, ('this piece is stated to be {0!r}.'.format(key)) + return None, ('this piece is stated to be {0!r}, which is not one of ' + 'the declared panel types ({1}). Nothing was matched to ' + 'it.'.format(got, ', '.join(sorted(declared)) or 'none')) + if not declared: + return None, ('panel_types.types is empty: no layer declares any ' + 'panel type, so no piece can be classified as one.') + default = get(knowledge, 'panel_types.default') + if default is not None and as_text(default).strip(): + if get(knowledge, 'panel_types.detect_verified'): + return as_text(default).strip(), ( + 'nothing classified this piece, so panel_types.default ' + '({0!r}) was used.'.format(default)) + return None, ( + 'panel_types.default is {0!r} but panel_types.detect_verified is ' + 'false -- nothing can classify a piece at all, so that value ' + 'would not be a fallback for the unclassified ones, it would make ' + 'EVERY piece {0!r}. It was not used. {1}' + .format(default, + get(knowledge, 'panel_types.detect_unverified_because') + or 'No reason is recorded for why detection is ' + 'unverified.')) + return None, (get(knowledge, 'panel_types.detect_unverified_because') + or 'nothing classified this piece and panel_types.default ' + 'is null, so it is UNCLASSIFIED -- which is not the same ' + 'as being the ordinary type.') + + +def families_for(knowledge, base_path, panel_type=None): + """The families in a role, narrowed to a panel type where one is known. + + Returns (families, basis, why). + + basis 'panel_type' the piece was classified and this type's list + was used. The real answer. + basis 'union' no classification, so the union of the base + list and EVERY declared type's list is used. + basis 'base' nothing declares a per-type breakdown; the base + list is the whole answer. + + `base_path` is the role's family list, e.g. + `handling.lifter_groups.face.families`; the breakdown is read from + `_by_panel_type`, a MAP, so a third panel type is one added key + and a job may add one without restating the plant's. + + WHY THE UNION AND NOT NOTHING. This resolves "which families play this + role", and on a lifting check the two errors are not symmetric: a family + left OUT means the check finds no lifters and passes in silence, on a + safety check; a family wrongly IN means a visible finding about a real + insert. So an unclassified piece gets everything, and `basis` says so, so + a report can name it rather than a reader having to infer it. + + AND WHY A CALLER MUST STILL LOOK AT `basis`. The union is safe for + RECOGNISING a part and is NOT safe for COUNTING one -- a parity taken over + the union of two panel types' lifters is a wrong count with nothing to + show for it. A counting caller must refuse on anything but 'panel_type'. + """ + base = get(knowledge, base_path) + base = [as_text(f).strip() for f in base + if as_text(f).strip()] if isinstance(base, list) else [] + table = get(knowledge, base_path + '_by_panel_type') + if not isinstance(table, dict) or not table: + return base, 'base', ( + 'no {0}_by_panel_type is declared, so {1} is the whole answer and ' + 'the panel type does not change it.' + .format(base_path, ', '.join(base) or '(nothing)')) + + def _listed(key): + value = table.get(key) + return [as_text(f).strip() for f in value + if as_text(f).strip()] if isinstance(value, list) else [] + + if panel_type is not None and as_text(panel_type).strip(): + wanted = as_text(panel_type).strip() + for key in table: + if as_text(key).strip().lower() == wanted.lower(): + got = _listed(key) + if got: + return got, 'panel_type', ( + 'this piece is {0!r}, so {1} is what plays this role ' + 'on it.'.format(key, ', '.join(got))) + return base, 'base', ( + 'this piece is {0!r} and {1}_by_panel_type lists nothing ' + 'for it, so the base list {2} stands.' + .format(key, base_path, ', '.join(base) or '(nothing)')) + return base, 'base', ( + 'this piece is {0!r}, which {1}_by_panel_type does not cover (it ' + 'covers {2}), so the base list {3} stands and nothing was ' + 'narrowed.'.format(wanted, base_path, ', '.join(sorted(table)), + ', '.join(base) or '(nothing)')) + + union = list(base) + for key in sorted(table): + for family in _listed(key): + if family not in union: + union.append(family) + return union, 'union', ( + 'this piece was not classified into a panel type, so every family ' + 'that plays this role on ANY declared type is treated as playing it ' + 'here: {0}. That is the safe direction for recognising a part and is ' + 'NOT safe for counting one -- a caller that counts must refuse this ' + 'basis.'.format(', '.join(union) or '(nothing)')) + + +# ============================================================================ +# CLASSIFYING A PIECE, AND WHAT A PLANT MAY SWITCH OFF BECAUSE OF IT +# ============================================================================ +# +# Two halves of one commission (owner, 2026-08-31): which Ticket QC checks +# apply is itself plant knowledge, conditioned on what the piece is. The +# first half recognises the piece; the second reads what the plant declared +# about that kind of piece. They are separate functions because they fail +# separately -- a classifier that cannot tell must not be able to switch +# anything off, and a declaration nobody can parse must not be able to hide +# behind a classification that worked. +# +# BOTH ARE REVIT-FREE AND TAKE PLAIN DATA. `classify_panel` takes a facts +# dict any caller can build -- Ticket QC builds it in `ticketqc.read_ticket`, +# and `liftexport` (which will need "sum both wythes on an insulated body") +# can build the same dict from what it already measures. `panel_facts()` is +# the constructor, so the key names cannot drift between two callers. + +FACT_KEYS = ('body_family', 'thickness_in', 'member_families') + +# The signals this code knows how to evaluate. A signal name a layer invents +# is NOT ignored: it is reported as unknown, because a plant that believes it +# declared a fourth signal and got silence has been handed a classification +# built on fewer facts than it thinks. +SIGNAL_KEYS = ('body_family_contains', 'thickness_in', + 'member_family_contains') + +DEFAULT_MIN_SIGNALS = 2 + + +def panel_facts(body_family=None, thickness_in=None, member_families=None): + """The measured facts a classification is allowed to use, as a dict. + + Every value may be None, and None means NOT MEASURED -- never "no". A + caller that has not read a fact leaves it out; the signal that needs it + then reports itself unevaluable, and an unevaluable signal never counts + towards recognising a type. + + `thickness_in` is INCHES. The ticket carries Revit's internal feet, so + the caller converts -- the unit is in the key name because a 16 that + meant feet would classify every panel in the job as nothing at all, in + silence. + """ + return { + 'body_family': None if body_family is None else as_text(body_family), + 'thickness_in': _number(thickness_in), + 'member_families': [as_text(f) for f in (member_families or []) + if as_text(f).strip()], + } + + +def _signal_body_family(want, facts): + got = facts.get('body_family') + if got is None or not as_text(got).strip(): + return None, 'the body family name was not read on this piece' + tokens = [as_text(w).strip() for w in _as_list(want) + if as_text(w).strip()] + if not tokens: + return None, 'no token is declared for this signal' + up = as_text(got).upper() + hit = [tk for tk in tokens if tk.upper() in up] + if hit: + return True, ('the body family name contains {0}' + .format(', '.join(hit))) + return False, ('the body family name contains none of {0}' + .format(', '.join(tokens))) + + +def _signal_thickness(want, facts): + got = facts.get('thickness_in') + if got is None: + return None, 'the piece thickness was not read' + if not isinstance(want, dict): + return None, ('thickness_in must be a map with min and/or max, in ' + 'inches; {0!r} is not one'.format(want)) + low, high = _number(want.get('min')), _number(want.get('max')) + if low is None and high is None: + return None, 'thickness_in declares neither a min nor a max' + if low is not None and got < low: + return False, ('{0} in is under the {1} in this type needs' + .format(_trim(got), _trim(low))) + if high is not None and got > high: + return False, ('{0} in is over the {1} in this type allows' + .format(_trim(got), _trim(high))) + return True, ('{0} in is inside the band this type needs' + .format(_trim(got))) + + +def _signal_member_family(want, facts): + families = facts.get('member_families') + if families is None: + return None, 'the members of this piece were not read' + tokens = [as_text(w).strip() for w in _as_list(want) + if as_text(w).strip()] + if not tokens: + return None, 'no token is declared for this signal' + ups = [as_text(f).upper() for f in families] + hit = [tk for tk in tokens if any(tk.upper() in u for u in ups)] + if hit: + return True, 'a member family contains {0}'.format(', '.join(hit)) + if not families: + return None, ('no member of this piece had a readable family name, ' + 'so whether it carries one of {0} is unknown' + .format(', '.join(tokens))) + return False, ('no member family contains any of {0}' + .format(', '.join(tokens))) + + +_SIGNAL_TESTS = { + 'body_family_contains': _signal_body_family, + 'thickness_in': _signal_thickness, + 'member_family_contains': _signal_member_family, +} + + +def _as_list(value): + if isinstance(value, (list, tuple)): + return list(value) + return [] if value is None else [value] + + +def _trim(value): + text = '{0:.4f}'.format(float(value)).rstrip('0').rstrip('.') + return text or '0' + + +def classify_panel(knowledge, facts): + """What kind of piece this is, as a dict. `None` is a real answer. + + Returns, always with every key present: + + panel_type the id, or None -- and None means NOT TOLD APART, which + is not the same as being the ordinary type + basis 'signals' | 'undeclared' | 'no_signals' | 'no_match' | + 'ambiguous' + why one sentence, naming what every signal said + verified whether `panel_types.detect_verified` is set -- see + below, and note that a classification is REPORTED + whether or not it is verified and is USED to switch a + check off only when it is + signals {type id: {signal: {'agrees': True/False/None, + 'why': }}} + candidates every type whose signals were satisfied + + WHY IT REPORTS WHILE UNVERIFIED. `detect_verified` says a reader exists + AND was checked against the model. The reading itself is cheap and its + answer is evidence -- printing it beside the piece is how the flag ever + gets turned on. What the flag gates is CONSEQUENCE: while it is false no + declaration conditioned on a panel type may switch anything off. See + `check_not_applicable`. + + THE ASYMMETRY THIS RESTS ON. Running a check that turns out not to apply + is noise. Skipping a check that did apply is a silent pass. So every + doubt resolves towards classifying nothing and running everything. + """ + facts = facts or {} + declared = panel_types(knowledge) + verified = bool(get(knowledge, 'panel_types.detect_verified')) + table_min = _number(get(knowledge, 'panel_types.min_signals')) + if table_min is None: + table_min = DEFAULT_MIN_SIGNALS + + out = {'panel_type': None, 'basis': 'undeclared', 'why': '', + 'verified': verified, 'signals': {}, 'candidates': [], + 'facts': dict(facts)} + + if not declared: + out['why'] = ('panel_types.types is empty: no layer declares any ' + 'panel type, so no piece can be classified as one.') + return out + + stories, candidates, any_signals = [], [], False + for type_id in sorted(declared): + rule = declared.get(type_id) + rule = rule if isinstance(rule, dict) else {} + signals = rule.get('signals') + signals = signals if isinstance(signals, dict) else {} + need = _number(rule.get('min_signals')) + need = int(table_min if need is None else need) + verdicts = {} + agreed, against, unread, unknown = [], [], [], [] + for name in sorted(signals): + test = _SIGNAL_TESTS.get(name) + if test is None: + unknown.append(name) + verdicts[name] = {'agrees': None, 'why': ( + 'this code does not know a signal called {0!r} -- it ' + 'knows {1} -- so nothing evaluated it' + .format(name, ', '.join(SIGNAL_KEYS)))} + continue + agrees, why = test(signals.get(name), facts) + verdicts[name] = {'agrees': agrees, 'why': why} + (agreed if agrees is True else + against if agrees is False else unread).append(name) + out['signals'][type_id] = verdicts + if signals: + any_signals = True + if not signals: + stories.append('{0} declares no signals, so nothing can recognise ' + 'a piece as one (and NOT being another type is not ' + 'evidence of being this one)'.format(type_id)) + continue + if against: + stories.append('{0}: {1} disagree(s) ({2})'.format( + type_id, ', '.join(against), + '; '.join([verdicts[n]['why'] for n in against]))) + continue + if len(agreed) < need: + stories.append( + '{0}: only {1} of the {2} signals it needs could be ' + 'evaluated{3}'.format( + type_id, len(agreed), need, + ' (' + ', '.join( + [n + ': ' + verdicts[n]['why'] + for n in unread + unknown]) + ')' + if (unread or unknown) else '')) + continue + candidates.append(type_id) + stories.append('{0}: {1} signal(s) agree ({2})'.format( + type_id, len(agreed), + '; '.join([verdicts[n]['why'] for n in agreed]))) + + out['candidates'] = candidates + if len(candidates) == 1: + out['panel_type'] = candidates[0] + out['basis'] = 'signals' + out['why'] = ('this piece is {0}. {1}.' + .format(candidates[0], '. '.join(stories))) + return out + if len(candidates) > 1: + out['basis'] = 'ambiguous' + out['why'] = ( + 'THE DECLARED SIGNALS CONTRADICT EACH OTHER on this piece: {0} ' + 'are all satisfied at once, and a piece cannot be two types. ' + 'Nothing was classified and the FULL checklist runs -- read this ' + 'as a defect in the signals, not a verdict on the piece. {1}.' + .format(', '.join(candidates), '. '.join(stories))) + return out + out['basis'] = 'no_match' if any_signals else 'no_signals' + out['why'] = ( + 'this piece was not told apart from any declared panel type, so it ' + 'is UNCLASSIFIED -- which is NOT the same as being the ordinary ' + 'type, and every check runs on it. {0}.'.format('. '.join(stories))) + return out + + +# ------------------------------------- what a plant declares does not apply + +NOT_APPLICABLE_ANY = 'any' + +_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$') + + +def not_applicable_declarations(knowledge): + """`checks.not_applicable` normalised: {row: {condition: entry}}. + + Nothing is dropped and nothing is repaired. An entry this code cannot + use still comes back, so `check_not_applicable` can REPORT it rather + than un-declaring it in silence -- the same ruling as `muted_checks` + keeping a mute whose reason is empty. + """ + raw = get(knowledge, 'checks.not_applicable', {}) + if not isinstance(raw, dict): + return {} + out = {} + for item in raw: + branch = raw.get(item) + if not isinstance(branch, dict): + out[as_text(item).strip()] = {} + continue + rows = {} + for condition in branch: + entry = branch.get(condition) + rows[as_text(condition).strip()] = ( + dict(entry) if isinstance(entry, dict) else entry) + out[as_text(item).strip()] = rows + return out + + +def _declaration_problem(item, condition, entry): + """Why this entry may not be acted on, or None. Refusals only.""" + where = 'checks.not_applicable[{0}][{1}]'.format(item, condition) + if not isinstance(entry, dict): + return ('{0} is {1!r}, not a map. It declares nothing and was ' + 'IGNORED -- the row ran.'.format(where, entry)) + if 'applies' not in entry: + return ('{0} does not say `applies`. A declaration that does not say ' + 'whether the row applies switches nothing off, so the row ' + 'ran.'.format(where)) + applies = entry.get('applies') + if applies not in (True, False): + return ('{0}.applies is {1!r}, and only true or false mean anything. ' + 'It was IGNORED -- the row ran.'.format(where, applies)) + stem = 'applicable' if applies else 'not_applicable' + reason = entry.get(stem + '_because') + reason = '' if reason is None else as_text(reason).strip() + since = entry.get(stem + '_since') + since = '' if since is None else as_text(since).strip() + if not reason: + return ('{0} sets applies: {1} with no `{2}_because`. A reason is ' + 'REQUIRED -- a safety check switched off (or back on) for a ' + 'reason nobody wrote down cannot be told apart from a bug. ' + 'It was IGNORED{3}.' + .format(where, str(applies).lower(), stem, + ' -- the row ran' if not applies else '')) + if not since or not _DATE_RE.match(since): + return ('{0} sets applies: {1} with `{2}_since` = {3!r}. A date in ' + 'YYYY-MM-DD is REQUIRED, so a reader can tell a ruling made ' + 'against today\'s drawings from one made against a job that ' + 'closed. It was IGNORED{4}.' + .format(where, str(applies).lower(), stem, since or None, + ' -- the row ran' if not applies else '')) + return None + + +def only_applies_to_declarations(knowledge): + """`checks.only_applies_to` normalised: {row: {panel type: entry}}.""" + raw = get(knowledge, 'checks.only_applies_to', {}) + if not isinstance(raw, dict): + return {} + out = {} + for item in raw: + branch = raw.get(item) + if not isinstance(branch, dict): + out[as_text(item).strip()] = {} + continue + rows = {} + for condition in branch: + entry = branch.get(condition) + rows[as_text(condition).strip()] = ( + dict(entry) if isinstance(entry, dict) else entry) + out[as_text(item).strip()] = rows + return out + + +def _scope_problem(item, condition, entry): + """Why a `only_applies_to` entry may not be acted on, or None.""" + where = 'checks.only_applies_to[{0}][{1}]'.format(item, condition) + if condition == NOT_APPLICABLE_ANY: + return ('{0} scopes the row to `any`, which is not a scope -- a row ' + 'that applies to every kind of piece needs no entry here. It ' + 'was IGNORED.'.format(where)) + if not isinstance(entry, dict): + return ('{0} is {1!r}, not a map. It declares nothing and was ' + 'IGNORED.'.format(where, entry)) + if 'scoped' not in entry: + return ('{0} does not say `scoped`. A declaration that does not say ' + 'whether the row is scoped to this type scopes nothing, so ' + 'the row was left applying to every piece.'.format(where)) + scoped = entry.get('scoped') + if scoped not in (True, False): + return ('{0}.scoped is {1!r}, and only true or false mean anything. ' + 'It was IGNORED.'.format(where, scoped)) + stem = 'scoped' if scoped else 'unscoped' + reason = entry.get(stem + '_because') + reason = '' if reason is None else as_text(reason).strip() + since = entry.get(stem + '_since') + since = '' if since is None else as_text(since).strip() + if not reason: + return ('{0} sets scoped: {1} with no `{2}_because`. A reason is ' + 'REQUIRED: a row narrowed to one kind of piece is a row that ' + 'stops being asked on every other kind, and a narrowing ' + 'nobody justified cannot be told apart from a bug. It was ' + 'IGNORED.'.format(where, str(scoped).lower(), stem)) + if not since or not _DATE_RE.match(since): + return ('{0} sets scoped: {1} with `{2}_since` = {3!r}. A date in ' + 'YYYY-MM-DD is REQUIRED. It was IGNORED.' + .format(where, str(scoped).lower(), stem, since or None)) + return None + + +def _scope_of(knowledge, item): + """([type ids the row is scoped to], {type: entry}, [problems]).""" + branch = only_applies_to_declarations(knowledge).get(item) or {} + types, entries, problems = [], {}, [] + for condition in sorted(branch): + entry = branch[condition] + problem = _scope_problem(item, condition, entry) + if problem: + problems.append(problem) + continue + if entry.get('scoped') is True: + types.append(condition) + entries[condition] = entry + return types, entries, problems + + +def check_not_applicable(knowledge, item, panel_type=None, verified=False): + """Is this checklist row asked on this piece? A dict, never a bare bool. + + not_applicable True only when a usable declaration says so + unaskable True when the row is scoped to a panel type and this + piece could not be placed -- NOT RUN, never OK and + never NOT APPLICABLE + only_types the panel types the row is scoped to, when any + condition 'any' or the panel type id that matched + reason the declared sentence, verbatim + since the declared date + declared_by who declared it, when the entry says + declared whether any entry at all was found for this row + problem a sentence when a declaration was found and REFUSED + + THE TWO AXES, and the difference is the whole commission. A `any` entry + is the PLANT axis -- "we do not do that here" -- and needs no + classification, so it works on the day it is written. An entry keyed by a + panel type is the PIECE axis and needs one: + + * the piece is that type -> the row is not asked + * the piece is another type -> the entry is not about this piece + * the piece was NOT CLASSIFIED -> the entry is REFUSED and said so, + and the row RUNS. "I could not + tell" may only resolve towards + asking the question. + * nothing can classify at all + (`detect_verified` false) -> REFUSED, with the plant's own + reason for why detection is + unverified, and the row RUNS. + + A REFUSAL IS LOUD, NOT SILENT. `problem` is a sentence for the report: + a declaration that did not take effect and said nothing would leave the + owner believing a row is off while it is on, which is the mirror image + of the failure this whole table exists to prevent. + """ + item = as_text(item).strip() + table = not_applicable_declarations(knowledge) + branch = table.get(item) + only_types, only_entries, problems = _scope_of(knowledge, item) + out = {'item': item, 'not_applicable': False, 'condition': None, + 'reason': None, 'since': None, 'declared_by': None, + 'declared': bool(branch) or bool(only_types), 'problem': None, + 'unaskable': False, 'only_types': list(only_types)} + + # THE SCOPE COMES FIRST. A row that is not about this kind of piece at + # all cannot be switched further off, and it cannot be left on either. + if only_types: + listed = ', '.join(only_types) + told = _said(only_entries[only_types[0]], 'scoped_because') + if not verified: + out['unaskable'] = True + out['reason'] = ( + 'this row is declared to be about {0} pieces only, and ' + 'panel_types.detect_verified is false -- nothing has been ' + 'confirmed to classify a piece, so whether this row is even ' + 'about this piece could not be established. It was NOT ASKED, ' + 'and NOT ASKED is not a pass. {1}' + .format(listed, + get(knowledge, + 'panel_types.detect_unverified_because') or + 'No reason is recorded for why detection is ' + 'unverified.')) + out['problem'] = out['reason'] + elif panel_type is None: + out['unaskable'] = True + out['reason'] = ( + 'this row is declared to be about {0} pieces only and this ' + 'piece could not be classified, so nobody could tell whether ' + 'the question is about it. Running it would answer an {0} ' + 'question on a piece that may not be one; calling it not ' + 'applicable would claim it is not {0}, which is precisely ' + 'what could not be established. It was NOT ASKED.' + .format(listed)) + elif as_text(panel_type).strip().lower() not in [ + t.lower() for t in only_types]: + out['not_applicable'] = True + out['condition'] = as_text(panel_type).strip() + out['reason'] = ( + 'this row is about {0} pieces and this piece is {1}. {2}' + .format(listed, as_text(panel_type).strip(), + told or 'No reason is recorded for the scope.')) + out['since'] = _said(only_entries[only_types[0]], 'scoped_since') + out['declared_by'] = _declarer(only_entries[only_types[0]]) + if problems: + out['problem'] = ' '.join( + [p for p in [out.get('problem')] + problems if p]) + if out['unaskable'] or out['not_applicable'] or not branch: + return out + + # `any` first: it needs no classification, so it cannot be blocked by + # one. A type-keyed entry is only consulted when `any` did not settle it. + order = ([NOT_APPLICABLE_ANY] + + sorted([k for k in branch if k != NOT_APPLICABLE_ANY])) + refusals = [] + for condition in order: + if condition not in branch: + continue + entry = branch[condition] + problem = _declaration_problem(item, condition, entry) + if problem: + refusals.append(problem) + continue + is_any = (condition == NOT_APPLICABLE_ANY) + if not is_any: + if not verified: + refusals.append( + 'checks.not_applicable[{0}][{1}] is conditioned on the ' + 'PIECE, and panel_types.detect_verified is false -- ' + 'nothing has been confirmed to classify a piece, so this ' + 'declaration was REFUSED and the row RAN. {2}' + .format(item, condition, + get(knowledge, + 'panel_types.detect_unverified_because') or + 'No reason is recorded for why detection is ' + 'unverified.')) + continue + if panel_type is None: + refusals.append( + 'checks.not_applicable[{0}][{1}] applies only to {1} ' + 'pieces and this piece could not be classified, so it ' + 'was REFUSED and the row RAN. A piece whose type cannot ' + 'be told gets the full checklist.'.format(item, condition)) + continue + if as_text(panel_type).strip().lower() != condition.lower(): + continue + if entry.get('applies') is True: + # An explicit narrowing: somebody turned this row back on for + # this condition, with its own reason. It stops the search -- + # `any` is consulted first, so a job that re-enables a row for + # one panel type does not have to restate the plant's `any`. + out['condition'] = condition + out['reason'] = _said(entry, 'applicable_because') + out['since'] = _said(entry, 'applicable_since') + out['declared_by'] = _declarer(entry) + break + out['not_applicable'] = True + out['condition'] = condition + out['reason'] = _said(entry, 'not_applicable_because') + out['since'] = _said(entry, 'not_applicable_since') + out['declared_by'] = _declarer(entry) + break + if refusals: + out['problem'] = ' '.join( + [p for p in [out.get('problem')] + refusals if p]) + return out + + +def _said(entry, key): + """One declared sentence, or None. `as_text(None)` is the string 'None', + which is how a missing reason becomes a reason that reads like one.""" + value = entry.get(key) + if value is None: + return None + return as_text(value).strip() or None + + +def _declarer(entry): + return _said(entry, 'declared_by') + + +def declaring_layer(layers, path): + """Which knowledge layer last wrote `path`, from `configdump. + layer_values(doc)`. None when no layer holds it. + + `path` IS A SEQUENCE OF KEYS, not a dotted string, and that is not a + style choice: every key in this table is a CHECKLIST ROW NUMBER, and + `'checks.not_applicable.3.3.any'.split('.')` is five segments where + four were meant. A dotted path cannot address this tree at all. + + A COMPUTED ANSWER, not a typed one. `declared_by` in an entry says who + RULED; this says which FILE the tool actually read it from, and the two + are different questions -- a plant rule copied into a job file still + says the plant's engineer ruled it, and the reader needs to know it is + the job file that has to be edited to change it. + """ + found = None + for layer in (layers or []): + label, data = layer[0], (layer[3] if len(layer) > 3 else None) + if not isinstance(data, dict): + continue + node = data + ok = True + for part in (path or []): + if not isinstance(node, dict) or part not in node: + ok = False + break + node = node[part] + if ok: + found = label + return found + + +def connector_marks(knowledge): + """Every declared connector part number, flattened and uppercased. + + The groups are preserved in the file because the owner named them as + groups; nothing here needs the grouping, so nothing here invents a + meaning for it. + """ + table = get(knowledge, 'connectors.marks') + if not isinstance(table, dict): + return [] + out = [] + for group in sorted(table): + for mark in (table[group] or []): + text = as_text(mark).strip().upper() + if text and text not in out: + out.append(text) + return out + + +def connector_product(knowledge, mark): + """(product key, entry, why) for one connector part number. + + EXACT match on the alias table, never a substring: `R8` must not swallow + `R80`, and the standing lesson in this file is that a bare part number + matched loosely files one part under another's name. + + (None, None, why) is a real answer -- a part number nobody has paired + with a published product -- and every caller must report it rather than + reaching for another product's numbers. + """ + text = as_text(mark).strip().upper() if mark is not None else '' + if not text: + return None, None, 'no connector mark was given.' + alias = get(knowledge, 'connectors.catalogue_alias', {}) or {} + key = None + if isinstance(alias, dict): + for candidate in alias: + if as_text(candidate).strip().upper() == text: + key = alias[candidate] + break + key = as_text(key).strip() if key else '' + if not key: + return None, None, ( + 'the plant declares no published product for connector {0!r}, so ' + 'nothing about its rating or its edge distance can be looked up. ' + 'Nothing was borrowed from another product.'.format(text)) + products = get(knowledge, 'connectors.products', {}) or {} + entry = None + if isinstance(products, dict): + for candidate in products: + if as_text(candidate).strip() == key: + entry = products[candidate] + break + if not isinstance(entry, dict): + return key, None, ( + 'connector {0!r} is declared to be {1!r}, and the public ' + 'catalogue has no entry under that name, so nothing about it can ' + 'be looked up.'.format(text, key)) + return key, dict(entry), ( + 'connector {0!r} is {1!r}.'.format(text, key)) + + +def connector_edge_minimum(knowledge, mark): + """(inches, source, why) for one connector's minimum edge distance. + + PRODUCT FIRST, and a plant-wide fallback is SECOND-CLASS and named: + `source` is 'product' or 'plant', and a finding built on the second says + so, because "6 inches" and "6 inches for this product" are different + claims. That distinction is 6.2's lesson, one part along. + + (None, None, why) when neither exists. NEVER another product's number. + """ + key, entry, why = connector_product(knowledge, mark) + if entry: + value = _number(entry.get('min_edge_distance_in')) + if value is not None: + return value, 'product', ( + '{0} in is the published minimum for {1}.'.format(value, key)) + unknown = entry.get('min_edge_distance_unknown_because') + why = ('{0} publishes no minimum edge distance -- {1}' + .format(key, as_text(unknown).strip() if unknown + else 'and no reason is recorded.')) + fallback = _number(get(knowledge, 'connectors.min_edge_distance_in')) + if fallback is not None: + return fallback, 'plant', ( + '{0} No published per-product minimum was found, so the ' + 'plant-wide {1} in was used -- and that is a WEAKER claim: it is ' + 'not this product\'s number.'.format(why, fallback)) + return None, None, ( + '{0} and the plant declares no fallback minimum either, so how close ' + 'to an edge this connector may sit is not written down anywhere. ' + 'Nothing was borrowed from another product.'.format(why)) + + +def connector_spacing_requirement(knowledge, requirements): + """(requirement, why) -- the design-summary line that states this + plant's connector spacing. + + ROW 5.9's DESIGN SIDE, and until 2026-08-31 there was none: the check + read `intent['connector_spacing_in']`, nothing ever wrote it, and the + row stood down on every press saying the summary parser could not read + the line. The line was `HK ST100'S @1'-4" O.C.` and it was sitting in + 8.1's uninterpreted list the whole time. + + `requirements` is `ticketpage.classify_summary_lines`' first return -- + typed design-summary lines. This picks the ones that are about A + CONNECTOR, and it does that THROUGH THE PLANT'S OWN ALIAS TABLE rather + than by recognising a product name in code: + + the summary says HK ST100'S + the plant says connectors.marks holds R82 + connectors.catalogue_alias.R82 is + 'HK Composites (ST100)' + so the two are joined on ST100 + + THE JOIN IS ON A CODE TOKEN, NEVER ON A WORD. A code token is one + carrying both a letter and a digit -- `ST100`, `R82` -- and a match is + EXACT after the summary's possessive `'S` is dropped. Matching on + `HK`, or on any substring, is how one product's requirement gets + applied to another product's connectors: `ST50` and `ST100` are both + 'HK Composites' and they are not the same part. The same exact-match + lesson as `connector_product`, one join along. + + (None, why) is a real answer and every caller must report it rather + than comparing against nothing. Three shapes of it, told apart because + they have three different repairs: + + * the design summary states no spacing at all -> nothing to compare; + * it states one, and no declared connector is named in it -> the + requirement is about something else (a bar, an embed), and reading + a bar's spacing as a connector's would be a confident wrong answer; + * it names a connector and the SPACING IS NOT A NUMBER (a table + variable, a blank on the sheet) -> the requirement is real and + un-valued, which is the one case a reader must not mistake for + agreement. + """ + typed = [r for r in (requirements or []) if isinstance(r, dict)] + spacings = [r for r in typed if r.get('kind') == 'spacing'] + if not spacings: + if not typed: + return None, ( + 'the design summary carries no line this tool could read as ' + 'a requirement, so it states no connector spacing to compare ' + 'against') + return None, ( + 'the design summary states {0} requirement(s) and none of them ' + 'is a spacing, so it calls for no connector pitch' + .format(len(typed))) + + wanted = {} + for mark in connector_marks(knowledge): + for token in _code_tokens(mark): + wanted.setdefault(token, []).append(mark) + key = connector_product(knowledge, mark)[0] + for token in _code_tokens(key): + wanted.setdefault(token, []).append(mark) + if not wanted: + return None, ( + 'no layer declares a connector part number (or a published ' + 'product for one), so no design-summary line could be ' + 'recognised as being about a connector') + + for requirement in spacings: + tokens = [as_text(t).strip().upper() + for t in (requirement.get('product_tokens') or [])] + hit = [t for t in tokens if t in wanted] + if not hit: + continue + marks = [] + for token in hit: + for mark in wanted[token]: + if mark not in marks: + marks.append(mark) + # `{0}` and not `{0!r}`: the summary line carries apostrophes and + # inch marks, and a repr of it reaches the report as backslashes the + # reader then has to mentally undo before he can find the line on + # the sheet. + why = ('the design summary line "{0}" names {1}, which this plant ' + 'declares as connector {2}' + .format(requirement.get('text'), ', '.join(hit), + ', '.join(marks))) + if requirement.get('spacing_in') is None: + return None, ( + '{0} -- but {1}, so there is no number to hold this piece to' + .format(why, requirement.get('spacing_unread_because') + or 'its spacing could not be read')) + return dict(requirement), why + + return None, ( + 'the design summary states {0} spacing requirement(s) and none of ' + 'them names a part number this plant declares as a connector ({1}), ' + 'so none of them is about connectors: {2}' + .format(len(spacings), ', '.join(connector_marks(knowledge)), + '; '.join([as_text(r.get('text')) for r in spacings]))) + + +def _code_tokens(text): + """The tokens of `text` that are a PART CODE -- letters and digits both. + + `'HK Composites (ST100)'` gives `['ST100']`: `HK` is a maker and + `Composites` is a word, and neither of them identifies a part. This is + what makes the join in `connector_spacing_requirement` safe to do at + all. + """ + import re as _re + out = [] + for raw in _re.split(r'[^A-Za-z0-9]+', as_text(text or '')): + token = raw.strip().upper() + if not token or token in out: + continue + if _re.search(r'[A-Z]', token) and _re.search(r'[0-9]', token): + out.append(token) + return out + + +def connector_spacing_tolerance(knowledge): + """(inches, why) -- how far the widest measured gap may sit above the + design's stated on-centre spacing before 5.9 calls it a defect. + + WHERE THE NUMBER COMES FROM, and it is not a plant ruling. The one + calibration that exists needed no tolerance at all: measured + 2026-08-31 on an insulated piece, the connectors sit at exactly 16.0 in + across the field against a design that states 1'-4", which is 16 in -- + the model honours the line to three decimal places. So this allowance + has never been exercised, and 0.5 in is a TOOL DEFAULT chosen to be + smaller than any spacing the summary can state (the finest the corpus + prints is 1/2 in) and larger than the rounding of a printed dimension. + It lives in `tolerances.connector_spacing_in` so a plant can rule on it + without a release, and the finding says which value it used. + """ + value = _number(get(knowledge, 'tolerances.connector_spacing_in')) + if value is None: + return None, ( + 'tolerances.connector_spacing_in is unset, so how far a gap may ' + 'exceed the design spacing before it is a defect is not written ' + 'down anywhere and nothing was assumed') + return value, ('{0} in, from tolerances.connector_spacing_in' + .format(_fmt_tolerance(value))) + + +def _fmt_tolerance(value): + if abs(value - round(value)) < 1e-9: + return '{0:.0f}'.format(value) + return '{0:g}'.format(value) + + +## +# ---------------------------------------------------------------- insulation +# +# THE SOLID ZONES, THE RECESSES, AND THE LEGEND THAT SAYS WHAT EACH HATCH +# MEANS. Added 2026-08-31 for master rows 5.11-5.15 and consumed by 5.10 as +# well, because the owner's edge-distance semantics rest on the same parts. +# +# EVERY ONE OF THESE IS A LOOKUP THAT CAN FAIL, AND EVERY ONE OF THEM SAYS +# WHY. `(None, why)` is the shape the whole module uses for "I could not +# read that", and it is the shape here: an unreadable legend, an unmapped +# concept and a plant that has not named its layout view are three different +# silences and the rows built on them print three different sentences. + +def insulation_layout_view(knowledge): + """(view name, why) for the ticket's insulation layout view. + + Landed BESIDE `drawings.bed_plan` and read the same way, because it is + the same kind of fact: what one plant's template calls one view. A + literal in the code would be the mistake `bed_plan` was created to + avoid, one view along. + """ + name = as_text(get(knowledge, 'drawings.insulation_layout') or '').strip() + if name: + return name, None + return None, ( + 'drawings.insulation_layout is unset: no layer says which view on ' + 'this plant\'s ticket is the insulation layout, so nothing could be ' + 'looked for on it. That is a missing plant fact, not a finding about ' + 'this piece') + + +def solid_zone_rule(knowledge): + """(family name, why) for the part that is a solid zone at this plant. + + The `family_verified` idiom, and it is doing the same work it does for + 6.6's parity rule: a family name that matches nothing counts ZERO zones + on every piece, and zero zones would read as "no lifter is missing one". + A permanent invisible pass over a whole defect class is exactly what the + flag exists to prevent, so an unverified name is a stand-down. + """ + family = as_text(get(knowledge, 'insulation.solid_zone.family') or '').strip() + if not family: + return None, ( + 'insulation.solid_zone.family is unset: no layer says which ' + 'family is a solid zone at this plant, so none could be ' + 'recognised') + if not get(knowledge, 'insulation.solid_zone.family_verified'): + return None, ( + 'insulation.solid_zone.family_verified is false -- the solid-zone ' + 'family name has not been confirmed against a live document, and ' + 'a name that matches nothing counts zero zones on every piece, ' + 'which would read as every lifter being fine. {0}' + .format(as_text(get( + knowledge, + 'insulation.solid_zone.family_unverified_because') or + 'No reason is recorded.'))) + return family, None + + +def lifter_accessories(knowledge): + """{child family name (upper) -> {'role': str or None, 'parents': [str, + ...]}} from `lifters.accessories` -- REVIT issue #26, 6.13. + + Schema landed by the MC plant layer: `: {parent: + , role: cup|patch}`. `parent` is accepted as + either a bare string (one parent family) or a list (DB_PATCH nests + under either LPA pin-anchor family) -- normalised to a list here so + every caller sees one shape and never has to branch on it again. An + entry with no `role` keeps `role: None` rather than a fabricated + default; a checker that needs the word for a message has to notice + the gap instead of printing one that was never actually plant fact. + + Keyed by the CHILD's own family name (upper-cased, exact match, no + substring folding) -- the same identity `ticketqc._read_nested_children` + reads off `Symbol.Family.Name` and `_read_nested_solid_zones` already + compares by exact string, not `_longest_substring_match`. Empty when + the plant has not named any accessory family; that is a plant fact + (no accessories exist here, or none are written down yet), never a + parsing failure. + """ + raw = get(knowledge, 'lifters.accessories', {}) + out = {} + if not isinstance(raw, dict): + return out + for child, entry in raw.items(): + if not isinstance(entry, dict): + continue + key = as_text(child).strip().upper() + if not key: + continue + parent = entry.get('parent') + if isinstance(parent, string_types): + parents = [as_text(parent).strip()] if as_text(parent).strip() else [] + elif isinstance(parent, (list, tuple)): + parents = [as_text(p).strip() for p in parent if as_text(p).strip()] + else: + parents = [] + role = as_text(entry.get('role') or '').strip().lower() or None + out[key] = {'role': role, 'parents': parents} + return out + + +def legend_swatch_concepts(knowledge): + """{filled-region type name (upper) -> concept id} from the plant legend. + + The legend's own vocabulary. Empty when the plant has not written it + down, and every caller reports that rather than assuming a mapping. + """ + table = get(knowledge, 'insulation.legend.swatch_types') + out = {} + if isinstance(table, dict): + for name in table: + key = as_text(name).strip().upper() + concept = as_text(table[name]).strip() + if key and concept: + out[key] = concept + return out + + +def recess_region_types(knowledge): + """The filled-region type names this plant draws a recess with.""" + values = get(knowledge, 'insulation.recess.region_types') or [] + out = [] + for value in values: + text = as_text(value).strip() + if text and text not in out: + out.append(text) + return out + + +def expected_hatch_pattern(knowledge, concept, legend_patterns=None): + """(pattern, source, why) -- what a concept's hatch is SUPPOSED to be. + + THE LEGEND FIRST, AND THE PLANT MAP SECOND AND NAMED. `legend_patterns` + is what was read off the sheet's own legend on this run; when it holds + the concept, that is the answer and `source` is 'legend'. Otherwise the + plant's `insulation.legend.expected_patterns` answers with source + 'plant', and every finding built on it says which -- because "the legend + on this sheet says ANSI35" and "a YAML table written on 2026-08-31 says + ANSI35" are different claims and only the first is self-updating. + + (None, None, why) when neither knows the concept. Never a borrowed + pattern from a neighbouring concept: the whole defect this row exists + for is one concept drawn with another's hatch. + """ + ident = as_text(concept).strip() + if not ident: + return None, None, 'no insulation concept was named.' + if isinstance(legend_patterns, dict): + for candidate in legend_patterns: + if as_text(candidate).strip() == ident: + value = as_text(legend_patterns[candidate]).strip() + if value: + return value, 'legend', ( + 'the legend printed on this ticket draws {0} as {1}' + .format(ident, value)) + table = get(knowledge, 'insulation.legend.expected_patterns') or {} + if isinstance(table, dict): + for candidate in table: + if as_text(candidate).strip() == ident: + value = as_text(table[candidate]).strip() + if value: + return value, 'plant', ( + 'the plant layer records {0} as {1}, measured off the ' + 'legend on {2} -- a COPY of the legend, not the ' + 'legend itself, so it can be stale' + .format(ident, value, + as_text(get( + knowledge, + 'insulation.legend.' + 'expected_patterns_measured_on') or + 'a date nobody recorded'))) + return None, None, ( + 'neither this ticket\'s legend nor insulation.legend.' + 'expected_patterns says what hatch {0} is drawn with, so what the ' + 'rendered pattern should have been is not written down anywhere. ' + 'No neighbouring concept\'s pattern was borrowed.'.format(ident)) + + +_DEPTH_IN_RE = None + + +def zone_depth_in(description): + """Inches read out of a solid zone's description, or None. + + `3" SOLID ZONE` -> 3.0. THIS IS A READING, NOT A MEASUREMENT, and every + caller marks it inferred. Depth is not a field on these parts; it is + text somebody typed, and a description that states no depth returns + None so the caller can stand down rather than default to either answer. + """ + global _DEPTH_IN_RE + if _DEPTH_IN_RE is None: + _DEPTH_IN_RE = re.compile(r'(\d+(?:\.\d+)?)\s*(?:"|in\b|inch)', + re.IGNORECASE) + text = as_text(description or '') + if not text: + return None + found = _DEPTH_IN_RE.search(text) + if not found: + return None + try: + return float(found.group(1)) + except Exception: + return None + + +# Owner, verbatim (2026-08-31): "如果 insulation 有 solid zone 被 cut +# through 而不只是 recess 的话,那么 edge 就是被 cut 之后的 edge。如果只是 +# recess,则按照原来的 edge 来算。" +def zone_cuts_through(knowledge, description, insulation_thickness_in): + """(True/False/None, why) -- does this solid zone go through the insulation? + + THE OWNER'S SEMANTICS, 2026-08-31: "if the insulation has a solid zone + that is cut through, not merely recessed, then the edge is the edge + after the cut. If it is only a recess, calculate using the original + edge." A zone at full insulation depth has + REPLACED the insulation, so its border is an insulation edge; a partial + one leaves insulation running underneath and moves nothing. + + None IS THE ANSWER WHENEVER EITHER NUMBER IS UNREADABLE, and the caller + is required to stand the pins near that zone down. Defaulting to False + would quietly measure to the old edge -- the permissive direction -- + and defaulting to True would invent an edge that may not exist. + """ + depth = zone_depth_in(description) + if depth is None: + return None, ( + 'this solid zone\'s description states no depth ({0!r}), so ' + 'whether it cuts through the insulation could not be read and ' + 'nothing near it was judged'.format(as_text(description or ''))) + thickness = _number(insulation_thickness_in) + if thickness is None: + return None, ( + 'the insulation thickness is not known, so a {0} in solid zone ' + 'could not be told from a through-cut, and nothing near it was ' + 'judged'.format(depth)) + through = depth >= thickness - 0.01 + return through, ( + 'INFERRED FROM NAMES, not measured: the zone\'s description states ' + '{0} in and the insulation is {1} in, so it reads as {2}' + .format(depth, thickness, + 'a through-cut, which moves the insulation edge' + if through else + 'partial depth, which leaves the original edge in place')) + + +def _plant_claims_tension_bar(knowledge, family_name): + """True when THIS plant's own layer says this family always ships + welded to the tension bar its with-bar rating depends on. + + `lifters.parts_shipped_with_tension_bar` names PLANT part codes (here: + 'P76'), matched as a substring of the modelled family name -- the same + way `catalogue_alias` is matched, because it is the plant's vocabulary, + not the manufacturer's designation. + + Absence of the claim is not a judgement about the hardware either way; + it means this plant has not stated one, and `lifter_capacity` reads the + catalogue's own working_load_lb exactly as published -- UNGATED, same as + the day before this gate existed. That is deliberate (requirement 3 of + the 2026-09-01 exercise this gate was built for): a plant that never + made the claim keeps today's behaviour, because gating it on a claim + nobody made would be inventing plant knowledge that is not written down. + """ + claimed = get(knowledge, 'lifters.parts_shipped_with_tension_bar', []) + if not claimed: + return False + up = as_text(family_name or '').upper() + return any(as_text(token).strip().upper() in up for token in claimed + if as_text(token).strip()) + + +def _tension_bar_corroborated(knowledge, assembly_families): + """(bool, why) -- was the welded tension bar actually seen on this piece? + + `lifters.tension_bar_families` names the SEPARATE family or families + that ARE the welded bar (SR5602, SR6999 at this plant) -- not a property + carried on the embed's own instance. Revit records no link from an + embed instance to a bar welded to it: + `interference.component_groups`'s own docstring says so outright ("no + parameter, no nested family carries it"). So the only corroboration + reachable at all is whether one of those family names shows up as an + OTHER MEMBER of the SAME ASSEMBLY -- `assembly_families`, which the + caller gathers from wherever it can see the whole piece: liftexport's + `boxes` at export time, or ticketqc's `t['bom']` (a family-name -> + count dict; iterating it yields the family names, same as a list). + Nothing here reads the model itself -- the caller does that and hands + the names in. + + This is assembly-level, not instance-level: Revit gives no way to say + THIS bar belongs to THIS embed among several on one piece, so a bar + seen anywhere on the assembly corroborates every gated embed on it. + That is a real limitation, not a mistake -- see the exercise notes for + why nothing stronger is available here -- and it is still a genuine + fact about the modelled piece, not a constant. + + assembly_families of None means the caller never had that list to hand + (an older code path, or a call site with no assembly in reach) and is + NOT the same as an assembly with no matching members: None can never + corroborate, and the reason says the check was not run rather than + that it failed. + """ + bars = get(knowledge, 'lifters.tension_bar_families', []) + bars = [as_text(b).strip().upper() for b in (bars or []) + if as_text(b).strip()] + if not bars: + return False, ('this plant declares no lifters.tension_bar_families ' + 'to look for, so the tension bar could not be ' + 'corroborated at all') + if assembly_families is None: + return False, ('the assembly\'s other members were not available ' + 'to this call, so the tension bar could not be ' + 'looked for') + present = [as_text(f).strip().upper() for f in assembly_families + if as_text(f).strip()] + hits = sorted(set(b for b in bars if any(b in f for f in present))) + if hits: + return True, ('{0} modelled among this assembly\'s {1} other ' + 'member(s)'.format('/'.join(hits), len(present))) + return False, ('none of {0} was found among this assembly\'s {1} other ' + 'member(s) -- the tension bar this rating depends on was ' + 'not seen on this piece'.format('/'.join(bars), + len(present))) + + +_ANGLE_TENSION_KEY_RE = re.compile(r'^tension_(\d+)_lb$') + + +def _angle_variant_tension_columns(direct): + """{angle_deg (int): value} for every `tension__lb` key present + in a `lifters.capacity_lb` dict entry, skipping any whose value is None. + + Empty when the entry carries no angle-split tension at all (the plain + `{tension_lb, shear_lb}` shape, or a bare number, neither of which ever + reach here as a dict with such keys) -- so callers can tell "no + angle-variant shape" from "angle-variant shape, nothing resolves" by + whether this comes back empty. + """ + columns = {} + for key, value in direct.items(): + if value is None: + continue + m = _ANGLE_TENSION_KEY_RE.match(as_text(key)) + if m: + columns[int(m.group(1))] = value + return columns + + +def _angle_choice_int(choice): + try: + return int(choice) + except (TypeError, ValueError): + return None + + +_NOT_RATED_STRINGS = frozenset(('', '-')) + + +def _field_rating_state(value): + """Classify one `tension_lb` / `shear_lb` field for `lifter_capacity`. + + Returns (state, value) where state is one of: + + 'forbidden' an explicit `false` or the string `'forbidden'` + (case-insensitive) -- the plant is stating outright + that this kind of load is never permitted on this + part. This IS a real zero, just not for the reason a + blank chart cell is one. + 'unrated' `None` (the key is absent), `0` / `0.0` (a chart or + override that prints a bare Zero), or `''` / `'-'` + (a table cell copied as blank or a dash). NOT a real + rating -- see `lifter_capacity`'s 2026-09-04 note. + 'rated' anything else numeric. Taken as stated, including a + genuine non-zero published figure. + + The raw value comes back unchanged in every case so a caller can still + describe exactly what it saw. + """ + if value is False: + return 'forbidden', value + if isinstance(value, string_types) and value.strip().upper() == 'FORBIDDEN': + return 'forbidden', value + if value is None: + return 'unrated', value + if isinstance(value, string_types) and value.strip() in _NOT_RATED_STRINGS: + return 'unrated', value + number = _number(value) + if number is not None and number == 0: + return 'unrated', value + return 'rated', value + + +def _back_cast_shear_from_tension(knowledge, family_name, part_key, fc_psi, + assembly_families): + """(value, basis, why) when this family's shear comes from its tension + per the plant chart's back-cast footnote, or plain `None` when the + footnote does not reach this family at all -- the caller keeps looking + elsewhere (the next table, then the catalogue) in that case. + + Three conditions, all the plant's own stated facts, none guessed here: + `lifting_anchors.back_cast_shear_equals_tension` is set, this part's + M8-style designation is named in + `lifting_anchors.back_cast_shear_equals_tension_applies_to`, and this + family is grouped as a FACE (back-cast) lifter + (`handling.lifter_groups.face.families` -- see `_is_face_lifter`). Once + all three match, the footnote IS the answer -- this returns a definite + (value-or-None, basis, why) triple, never plain `None` again, even when + the tension it depends on fails to resolve. + """ + if not get(knowledge, 'lifting_anchors.back_cast_shear_equals_tension'): + return None + applies = [as_text(p).strip().upper() for p in + (get(knowledge, 'lifting_anchors.' + 'back_cast_shear_equals_tension_applies_to', []) or [])] + if not part_key or as_text(part_key).strip().upper() not in applies: + return None + if not _is_face_lifter(knowledge, family_name): + return None + tension, _tb, t_why = lifter_capacity( + knowledge, family_name, fc_psi=fc_psi, kind='tension', + assembly_families=assembly_families) + if tension is None: + return None, None, ( + '{0} would take its shear from its tension (back-cast, per ' + 'the plant chart\'s footnote, lifting_anchors.' + 'back_cast_shear_equals_tension) but the tension did not ' + 'resolve: {1}'.format(family_name, t_why)) + return tension, 'plant_table', ( + '{0} is cast in the back of the panel (a face lifter), so its ' + 'shear equals its tension per the plant chart\'s footnote ' + '(lifting_anchors.back_cast_shear_equals_tension): {1} lb -- {2}' + .format(family_name, tension, t_why)) + + +def _resolve_dict_field(knowledge, family_name, direct, field, kind, + table_name, basis_label): + """Read one `tension_lb`/`shear_lb`-shaped field out of a single plant + or override dict entry (`lifters.capacity_lb` or `lifting_anchors. + parts`, whichever `table_name` names). + + Returns ('return', value, basis, why) for a terminal answer -- forbid, + rated, or a stand-down this table itself explains (an angle mismatch, + an `_unresolved_because`) -- or ('fallthrough', why_fragment) when this + table NAMES the family but prints nothing usable for `kind`, so the + caller should keep looking: the next table, the back-cast footnote, or + the catalogue. See `lifter_capacity`'s 2026-09-04 docstring note for + why a printed `0` is not automatically a terminal answer any more. + """ + value = direct.get(field) + state, _raw = _field_rating_state(value) + if state == 'forbidden': + return ('return', 0, basis_label, ( + '{0} is a job- or plant-pinned entry in {1}: {2} is explicitly ' + 'FORBIDDEN there -- a deliberate zero (this part may never be ' + 'loaded in {3}), not merely an unpublished rating' + .format(family_name, table_name, field, kind))) + if state == 'rated': + return ('return', value, basis_label, ( + '{0} is a job- or plant-pinned {1} in {4}.{2}, ' + 'taken as stated ({3} lb)'.format(family_name, kind, field, + value, table_name))) + # state == 'unrated' -- the printed/pinned field is 0, blank, a dash, + # or simply absent. Try the angle-split tension shape and the entry's + # own stated reason before conceding there is none here. + if kind != 'shear': + columns = _angle_variant_tension_columns(direct) + if columns: + published = [a for a in + (_angle_choice_int(a) for a in + (get(knowledge, + 'lifting_anchors.sling_angles_published', + []) or [])) + if a is not None] + choice_raw = get(knowledge, + 'lifting_anchors.sling_angle_choice', None) + if choice_raw is not None: + choice = _angle_choice_int(choice_raw) + if choice is not None and choice in columns: + return ('return', columns[choice], basis_label, ( + '{0} publishes tension by sling angle in ' + '{4} ({1}); ' + 'lifting_anchors.sling_angle_choice names {2} ' + 'deg, so {3} lb applies (per ' + 'lifting_anchors.sling_angle_choice)'.format( + family_name, + ', '.join('{0} deg = {1} lb'.format(a, columns[a]) + for a in sorted(columns)), + choice, columns[choice], table_name))) + return ('return', None, None, ( + '{0} publishes tension only at {1} deg in ' + '{4} (published sling angles: {2}), ' + 'but lifting_anchors.sling_angle_choice names {3}, ' + 'which has no matching tension_{3}_lb column here -- ' + 'standing down rather than guessing'.format( + family_name, + '/'.join(str(a) for a in sorted(columns)), + '/'.join(str(a) for a in sorted(published)) + if published else 'none stated', + choice_raw, table_name))) + # sling_angle_choice is null/unset -- fall through to the + # entry's own unresolved_because, exactly as before. + why = direct.get(field + '_unresolved_because') + if why: + return ('return', None, None, why) + if kind != 'shear': + columns = _angle_variant_tension_columns(direct) + if columns: + unknown = get(knowledge, + 'lifting_anchors.sling_angle_choice_unknown_because') + return ('return', None, None, ( + '{0} publishes tension by sling angle in {1} ({2}) but ' + 'lifting_anchors.sling_angle_choice is unset -- standing ' + 'down rather than guessing{3}'.format( + family_name, table_name, + ', '.join('{0} deg = {1} lb'.format(a, columns[a]) + for a in sorted(columns)), + (': ' + as_text(unknown).strip()) if unknown else ''))) + # Genuinely nothing here: no number, no angle-split shape, no stated + # reason. NOT the same as a real zero -- this table is not the last + # word, so the caller keeps looking rather than taking a printed blank + # or zero as the rating. + detail = ('prints a published/pinned zero' if _number(value) == 0 + else 'is blank' if value is not None else 'is not set') + return ('fallthrough', ( + '{0}.{1} for {2} {3}, not a real {4} rating'.format( + table_name, field, family_name, detail, kind))) + + +def lifter_capacity(knowledge, family_name, fc_psi=None, kind='tension', + assembly_families=None): + """(value, basis, why) -- rated working load for a lifter family. + + value is None when unknown; basis and why explain what was used and are + never blank when value is not None. Direct table first, then the + catalogue by way of the plant's alias. The direct table wins so that a + job can pin a value without anyone having to edit published product + data -- and it is taken as stated, since whoever typed it knew what + concrete they meant. + + basis is one of: + + 'direct' lifters.capacity_lb had this family and a real + rating for `kind`; taken as stated, no catalogue or + tension-bar question asked. + 'plant_table' lifting_anchors.parts (the plant's own tested + chart) had a real rating for `kind`, either printed + directly or via the back-cast-shear-equals-tension + footnote (`_back_cast_shear_from_tension`). + 'catalogue' the catalogue's own rating. Reached either + UNCONDITIONALLY (this part carries no tension-bar + gate, or it does and this plant has made no claim + about one -- see `_plant_claims_tension_bar`), or + because a plant override / plant table DID name + this family but had no real rating for `kind` -- + see the NOT RATED note below, where `why` names + every layer tried before the catalogue answered. + 'with_bar' the catalogue's WITH-tension-bar rating, because the + bar was actually corroborated on THIS piece. + 'without_bar' the part requires a tension bar for its published + rating, this plant claims the part ships with one, + but it could not be corroborated on THIS piece -- so + the catalogue's WITHOUT-bar figure applies instead. + NEVER silent: `why` always names both the rating + used and the reason the with-bar one was not. + None value is also None; nothing is known. + + fc_psi is the strength AT THE LIFT. Pass f'ci for stripping; the rating is + derated for it (see concrete_derate). Omitting it returns the catalogue + number unadjusted, which is the right answer only when the concrete is at + or above the table's basis. + + assembly_families is every OTHER member's family name in the same + assembly as this lifter -- a list, or a BOM dict whose keys are family + names (iterating either yields family-name strings). It is the only + corroboration this function can reach for the tension-bar gate; see + `_tension_bar_corroborated`. A caller with no such list to hand may omit + it, and a tension-bar-gated, plant-claimed part then falls back to the + without-bar rating with `why` saying corroboration was never attempted + -- which is the SAFE direction: an omitted list never earns the 2x + rating by accident. + + lifters.capacity_lb[family] is USUALLY a bare number -- the one rating + every existing plant file has ever written, returned here for either + `kind` because nobody who wrote one of those entries meant it to differ + between tension and shear. THAT SHAPE CANNOT SAY "tension and shear are + different numbers", which a real part can need: one plant's own tested + chart for one insert publishes 13,800 lb tension against 3,860 lb shear + -- a different number from the same designation's published catalogue + rating -- so pinning one flat number would silently hand the tension + figure back on a shear query, OVERSTATING it -- the unsafe direction. + Added 2026-09-02 for exactly that case: a dict entry with `tension_lb` / + `shear_lb` keys is read by `kind`. Missing the field `kind` asked for is + not an error -- it means the plant pinned the OTHER kind and said + nothing about this one -- and if `_unresolved_because` is present + that reason is returned instead of guessing (a sibling insert on the + same chart can have a genuinely unresolved tension -- depending on a + sling angle the chart never picked -- while its shear is a real, + published number, so a tension query stands down naming the unresolved + angle rather than silently choosing one over the other, and does so + without disturbing the shear side at all). + + A dict entry can ALSO carry the angle-split shape instead of a plain + `tension_lb`: `tension_60_lb` / `tension_75_lb` / ... (one key per + published sling angle) plus a `tension_lb_unresolved_because` explaining + that the chart would not pick one. Added 2026-09-02: when the PLANT has + since answered that question -- `lifting_anchors.sling_angle_choice` + names one of the published angles -- a tension query resolves to that + column instead of standing down, `why` naming the chosen angle. This is + scoped tight on purpose: it only fires for a dict entry that actually has + `tension__lb` keys, never for the plain `{tension_lb, shear_lb}` shape + or a bare number, and it still stands down -- naming the mismatch rather + than guessing -- when `sling_angle_choice` is unset or names an angle + this entry has no column for. + + ---- THREE STATES A `tension_lb` / `shear_lb` FIELD CAN HOLD, added + 2026-09-04 after a real FAIL that was actually "nobody rated this" ------ + + A number a plant or job pins is taken as stated, including a genuine + published zero -- some inserts really do carry no rating on one axis. + But `0`, `None`, `''` and `'-'` are NOT reliably "a real zero" on their + own: one plant's own tested chart prints a bare `Zero` in the shear + column of a back-cast (face) lifter not because the anchor carries no + shear, but because that printed cell is the EDGE-cast condition, and a + footnote elsewhere on the same page (`lifting_anchors. + back_cast_shear_equals_tension`) says a BACK-cast one takes its shear + from its tension instead. Reading the printed `0` as the rating -- + which this function did until 2026-09-04 -- reported a real face lifter + as having ZERO shear capacity and failed a wall on a load its own + hardware carries just fine. + + So a field of `0` / `None` / `''` / `'-'` is read as NOT RATED HERE, not + as a real zero, and is never returned as one. Instead: + + 1. the back-cast footnote is tried first, for a shear query only + (`_back_cast_shear_from_tension`) -- it is the plant's OWN stated + rule for exactly this printed shape, so it outranks falling + through to a generic number; + 2. failing that, the NEXT layer is tried: `lifters.capacity_lb` + (the job/plant override) falls through to `lifting_anchors.parts` + (the plant's own tested chart) before either falls through to the + published catalogue; + 3. failing every layer, the answer is `None` with a `why` naming + every layer that was tried and what each one said -- never a + silent 0. + + A plant that genuinely WANTS a hard zero -- a part that must never be + loaded on that axis at all, a real published prohibition rather than an + absent rating -- states it as `shear_lb: forbidden` (or `false`), which + this function still returns as `0`, `basis` naming the table that + forbade it and `why` saying it is deliberate. That is now the ONLY way + to get a `0` back out of this function for a dict-shaped entry; a bare + printed `0` never is one on its own any more. See `_field_rating_state`. + """ + field = 'shear_lb' if kind == 'shear' else 'tension_lb' + + # The plant's own tested parts chart -- `lifting_anchors.parts`, one + # entry per part number -- read by the part number the family name + # carries, regardless of whether the job/plant override below answers + # first; its part number (`part_key`) is what the back-cast footnote + # keys on, and its own fields are the second layer tried when the + # override's are not rated. Found 2026-09-03: a job whose plant had + # landed the whole chart still reported "no rating" because nothing + # read it. + part_key, chart_part = _longest_substring_key( + get(knowledge, 'lifting_anchors.parts', {}), family_name) + chart_direct = None + if isinstance(chart_part, dict): + chart_direct = dict((k, v) for k, v in chart_part.items() + if k in ('tension_lb', 'shear_lb') + or _ANGLE_TENSION_KEY_RE.match(as_text(k))) + + override_direct = _longest_substring_match( + get(knowledge, 'lifters.capacity_lb', {}), family_name) + + plant_reasons = [] + for candidate, table_name, basis_label in ( + (override_direct, 'lifters.capacity_lb', 'direct'), + (chart_direct, 'lifting_anchors.parts', 'plant_table')): + if candidate is None: + continue + if not isinstance(candidate, dict): + # The legacy bare-number override: one figure for either kind, + # taken as stated, no per-kind split possible. Only the + # override can ever be this shape -- `chart_direct` above is + # always built as a dict or None. + return candidate, 'direct', ( + '{0} is a job- or plant-pinned rating in ' + 'lifters.capacity_lb, taken as stated ({1} lb)' + .format(family_name, candidate)) + outcome = _resolve_dict_field(knowledge, family_name, candidate, + field, kind, table_name, basis_label) + if outcome[0] == 'return': + return outcome[1], outcome[2], outcome[3] + plant_reasons.append(outcome[1]) + + if kind == 'shear': + footnote = _back_cast_shear_from_tension( + knowledge, family_name, part_key, fc_psi, assembly_families) + if footnote is not None: + return footnote + + plant_reason = '; '.join(plant_reasons) if plant_reasons else None + + designation = catalogue_item(knowledge, family_name) + if not designation: + no_alias = ( + 'no catalogue alias for {0}; lifters.catalogue_alias does not ' + 'name it'.format(family_name)) + return None, None, ( + '{0}; {1}'.format(plant_reason, no_alias) if plant_reason + else no_alias) + entry = (get(knowledge, 'catalogue.inserts', {}) or {}).get(designation) + if not isinstance(entry, dict): + if entry is None: + not_published = ( + '{0} maps to catalogue designation {1}, which is not ' + 'published in catalogue.inserts'.format(family_name, + designation)) + return None, None, ( + '{0}; {1}'.format(plant_reason, not_published) + if plant_reason else not_published) + bare_why = ( + '{0} maps to catalogue.inserts.{1}, a bare published number' + .format(family_name, designation)) + return entry, 'catalogue', ( + '{0}; {1}'.format(plant_reason, bare_why) if plant_reason + else bare_why) + cat_field = 'shear_load_lb' if kind == 'shear' else 'working_load_lb' + rated = entry.get(cat_field) + if rated is None: + # No usable `shear_lb` / `tension_lb` on this catalogue.inserts + # entry -- issue #15: a P78 (alias QL184G) publishes only an + # ultimate_mechanical_tension_lb, nothing in the SWL vocabulary, so + # without this hop it reports LIFT-UNKNOWN. Answer from the + # insulated-panel anchor capacity table when it publishes this part. + from_table = _insulated_panel_anchor_capacity( + knowledge, family_name, designation, kind, fc_psi, + assembly_families, plant_reason) + if from_table is not None: + return from_table + no_field = ( + '{0} maps to catalogue.inserts.{1}, which publishes no {2}' + .format(family_name, designation, cat_field)) + return None, None, ( + '{0}; {1}'.format(plant_reason, no_field) if plant_reason + else no_field) + + if (kind == 'tension' and entry.get('requires_tension_bar_for_rating') + and _plant_claims_tension_bar(knowledge, family_name)): + earned, gate_why = _tension_bar_corroborated(knowledge, + assembly_families) + if earned: + with_bar_why = ( + '{0} ({1}) is rated {2} lb WITH its tension bar -- {3}' + .format(family_name, designation, rated, gate_why)) + return rated * concrete_derate(entry, fc_psi), 'with_bar', ( + '{0}; {1}'.format(plant_reason, with_bar_why) + if plant_reason else with_bar_why) + fallback = entry.get('tension_without_tension_bar_lb') + if fallback is None: + no_fallback = ( + '{0} ({1}) requires a tension bar for its {2} lb rating, ' + 'the bar could not be corroborated ({3}), and the ' + 'catalogue publishes no tension_without_tension_bar_lb to ' + 'fall back to'.format(family_name, designation, rated, + gate_why)) + return None, None, ( + '{0}; {1}'.format(plant_reason, no_fallback) + if plant_reason else no_fallback) + without_bar_why = ( + '{0} ({1}) requires its tension bar for the {2} lb rating and ' + 'it could not be corroborated on this piece ({3}), so the ' + 'WITHOUT-bar rating of {4} lb applies instead' + .format(family_name, designation, rated, gate_why, fallback)) + return fallback * concrete_derate(entry, fc_psi), 'without_bar', ( + '{0}; {1}'.format(plant_reason, without_bar_why) + if plant_reason else without_bar_why) + + catalogue_why = ( + '{0} maps to catalogue.inserts.{1}, rated {2} lb {3}' + .format(family_name, designation, rated, kind)) + return rated * concrete_derate(entry, fc_psi), 'catalogue', ( + '{0}; {1}'.format(plant_reason, catalogue_why) if plant_reason + else catalogue_why) + + +def _insulated_panel_anchor_capacity(knowledge, family_name, designation, + kind, fc_psi, assembly_families, + plant_reason): + """(value, basis, why) answered from catalogue.insulated_panel_anchor_ + capacities when that table publishes `designation`, or None when it does + not and the caller keeps its existing stand-down. + + Issue #15's wire: a P78-style alias that resolves to a + catalogue.inserts entry carrying only an ultimate mechanical rating -- + no usable `shear_lb` / `tension_lb` -- must answer from this capacity + table instead of reporting LIFT-UNKNOWN. The table carries the SWL + columns (`shear_4_1_lb`, `reinforced_tension_4_1_lb`) the inserts + entry lacks, per `panel_group`, so the worst (lowest) group of the + rows naming the part is taken -- the catalogue marks its own panel-group + caption semantics uncertain (`panel_group_triple_semantics_uncertain`), + so no one group may be privileged over another. + + Shear is taken straight from the table (basis 'catalogue'). Tension is a + WITH-bar rating and runs through the same tension-bar corroboration the + catalogue path applies: when this plant claims the bar and it is + corroborated on the piece the answer is 'with_bar'; when the claim is + made but the bar is not corroborated, and the table prints no without-bar + figure, the answer stands down naming that -- never a guessed number. + """ + table = get(knowledge, 'catalogue.insulated_panel_anchor_capacities', {}) + if not isinstance(table, dict): + return None + rows = table.get('rows') + if not isinstance(rows, list): + return None + target = as_text(designation).strip().upper() + part_rows = [row for row in rows + if isinstance(row, dict) + and as_text(row.get('part_number')).strip().upper() + == target] + if not part_rows: + return None + + col = ('shear_4_1_lb' if kind == 'shear' + else 'reinforced_tension_4_1_lb') + raws = [row.get(col) for row in part_rows] + values = [_number(raw) for raw in raws] + if any(v is None for v in values): + missing = [row.get('part_number') for row, v in zip(part_rows, values) + if v is None] + no_rating = ( + '{0} maps to catalogue.inserts.{1}, but the ' + 'insulated_panel_anchor_capacities rows for it publish no ' + 'usable {2} (missing on: {3})' + .format(family_name, designation, col, + ', '.join(as_text(m) for m in missing))) + return None, None, ( + '{0}; {1}'.format(plant_reason, no_rating) if plant_reason + else no_rating) + + basis_fc_psi = table.get('_basis_fc_psi') + basis_entry = {'basis_fc_psi': basis_fc_psi} if basis_fc_psi else {} + factor = concrete_derate(basis_entry, fc_psi) + value = min(values) + valued = value * factor + + panel_groups = ', '.join( + as_text(row.get('panel_group')) for row in part_rows) + rebar_note = ( + 'the table prints rebar_size {0} and the owner states #6 -- both ' + 'unresolved'.format( + as_text(part_rows[0].get('rebar_size')))) + + uncertain = bool(table.get('panel_group_triple_semantics_uncertain')) + lowest_why = ( + 'the LOWEST group was taken because the catalogue marks its ' + 'panel-group caption semantics uncertain' + if uncertain else + 'the LOWEST group was taken') + + if kind == 'shear': + scale_why = '' + if factor != 1.0: + scale_why = ' -- scaled by {0} for f\'c {1} psi against a {2} ' + 'psi basis'.format(factor, fc_psi, basis_fc_psi) + table_why = ( + '{0} maps to catalogue.inserts.{1}, answered from the ' + 'insulated_panel_anchor_capacities table ({2} rows, panel ' + 'groups: {3}): shear takes its 4:1 SWL {4} lb as the lowest on ' + 'the part ({5}); basis f\'c {6} psi{7}. {8}' + .format(family_name, designation, len(part_rows), panel_groups, + valued, lowest_why, basis_fc_psi, scale_why, rebar_note)) + return valued, 'catalogue', ( + '{0}; {1}'.format(plant_reason, table_why) if plant_reason + else table_why) + + # Tension -- a WITH-bar rating, gated exactly like the catalogue's own + # tension bar handling. + claimed = _plant_claims_tension_bar(knowledge, family_name) + if claimed: + earned, gate_why = _tension_bar_corroborated(knowledge, + assembly_families) + if earned: + with_bar_why = ( + '{0} maps to catalogue.inserts.{1}, answered from the ' + 'insulated_panel_anchor_capacities table ({2} rows, panel ' + 'groups: {3}): reinforced tension {4} lb WITH its bar ' + '(the LOWEST on the part, {5}) -- {6}; basis f\'c {7} psi. ' + '{8}' + .format(family_name, designation, len(part_rows), + panel_groups, valued, lowest_why, gate_why, + basis_fc_psi, rebar_note)) + return valued, 'with_bar', ( + '{0}; {1}'.format(plant_reason, with_bar_why) + if plant_reason else with_bar_why) + no_bar = ( + '{0} maps to catalogue.inserts.{1}, answered from the ' + 'insulated_panel_anchor_capacities table, but it publishes ' + 'only the REINFORCED (with-bar) tension rating ({2} lb) and ' + 'that bar was not corroborated on this piece ({3}) -- ' + 'standing down rather than guessing. {4}' + .format(family_name, designation, valued, gate_why, rebar_note)) + return None, None, ( + '{0}; {1}'.format(plant_reason, no_bar) if plant_reason + else no_bar) + + table_why = ( + '{0} maps to catalogue.inserts.{1}, answered from the ' + 'insulated_panel_anchor_capacities table ({2} rows, panel groups: ' + '{3}): reinforced tension {4} lb WITH its bar (the LOWEST on the ' + 'part, {5}); basis f\'c {6} psi. {7}' + .format(family_name, designation, len(part_rows), panel_groups, + valued, lowest_why, basis_fc_psi, rebar_note)) + return valued, 'catalogue', ( + '{0}; {1}'.format(plant_reason, table_why) if plant_reason + else table_why) + + +def _catalogue_entry(knowledge, family_name): + designation = catalogue_item(knowledge, family_name) + entry = (get(knowledge, 'catalogue.inserts', {}) or {}).get(designation) \ + if designation else None + return entry if isinstance(entry, dict) else None + + +def lifter_edge_distance(knowledge, family_name): + """The catalogue's minimum edge distance for a lifter family, or None.""" + entry = _catalogue_entry(knowledge, family_name) + return entry.get('min_edge_distance_in') if entry else None + + +def lifter_safety_factor(knowledge, family_name, kind='tension'): + """(factor, source, why) for one lifter family, or (None, None, why). + + THE PART'S OWN NUMBER OR NOTHING. There is deliberately no fallback: + `catalogue.safety_factors` is null, and until 2026-08-31 it held a + global `shear_fos: 2.67` with no published source that every insert in + the file -- from any manufacturer -- inherited. The page that covers the + parts it actually reached (ALP catalog p53, 02/2024) heads the capacity + table "Capacity in Concrete 4:1 SWL" over BOTH load columns, so 4:1 is + the shear factor there too, and the entries now say so themselves. + + Borrowing is the failure this refuses. `QL589SPG` in the same file + publishes 4,010 lb shear against 16,000 lb tension -- nothing like the + 1:1 the QuikLift disk anchors show -- so a factor read off one product's + sheet says nothing about another's. (7,690 was that entry's tension + until 2026-09-01, when the with-tension-bar column replaced the + without-bar one; the contrast the paragraph draws is unchanged.) + + A global value is still READ if some layer sets one, because a plant + that has its own tested factor must be able to state it; it comes back + as source 'global' and the sentence says it is not this part's number. + """ + field = 'shear_fos' if kind == 'shear' else 'tension_fos' + entry = _catalogue_entry(knowledge, family_name) + if entry: + block = entry.get('safety_factors') + if isinstance(block, dict): + value = _number(block.get(field)) + if value: + cite = as_text(block.get('source') or '').strip() + return value, 'published', ( + '{0}:1 is {1}\'s own published {2} factor{3}' + .format(value, family_name, kind, + ' -- ' + cite if cite else + ', and no source is recorded beside it')) + fallback = _number(get(knowledge, 'catalogue.safety_factors.' + field)) + if fallback: + return fallback, 'global', ( + '{0}:1 comes from the file-wide catalogue.safety_factors, not ' + 'from {1}\'s own datasheet -- a factor belongs to the part that ' + 'was tested, so this is a WEAKER claim'.format(fallback, + family_name)) + return None, None, ( + 'no {0} safety factor is published for {1} and the catalogue ' + 'declares no file-wide one, so an ultimate rating cannot be turned ' + 'into a working load for it. Nothing was borrowed from another ' + 'product.'.format(kind, family_name)) + + +def lifter_spacing_minimum(knowledge, family_name): + """(inches, source, why) for the minimum spacing between two anchors. + + ALP catalog p53, 02/2024, footnote: "Minimum anchor spacing is 2x the + published edge distance". Nothing in this tool had ever asked the + question, so two 8-ton anchors 20 in apart passed every check while the + page asked for 28. + + Three outcomes, and the middle one is the reason this is not a constant + in a check function: + + 'published' the entry states `min_spacing_rule.published_in` -- a + sheet that gives a spacing outright OVERRIDES the + multiple, because a rule of thumb never beats a + measured number for the same part. + 'derived' the multiple applied to the part's OWN published edge + distance. The finding prints the arithmetic, because + "28 inches" and "2 x 14 inches, the published edge + distance" are different claims and a reader must be + able to check the second. + None no rule, or a rule with no edge distance to double. + That is a could-not-judge and it is named; it is never + satisfied by another part's number. + """ + entry = _catalogue_entry(knowledge, family_name) + rule = entry.get('min_spacing_rule') if entry else None + if not isinstance(rule, dict): + return None, None, ( + 'no minimum anchor spacing is published for {0}, so how far ' + 'apart two of them must sit is not written down anywhere' + .format(family_name)) + cite = as_text(rule.get('source') or '').strip() + tail = ' -- ' + cite if cite else '' + published = _number(rule.get('published_in')) + if published: + return published, 'published', ( + '{0}" is {1}\'s own published minimum anchor spacing{2}' + .format(_trim_number(published), family_name, tail)) + multiple = _number(rule.get('multiple_of_edge_distance')) + edge = _number(entry.get('min_edge_distance_in')) + if multiple and edge: + return multiple * edge, 'derived', ( + '{0}" is {1:g}x {2}\'s published edge distance of {3}"{4}' + .format(_trim_number(multiple * edge), multiple, family_name, + _trim_number(edge), tail)) + if multiple: + return None, None, ( + '{0}\'s spacing rule is {1:g}x its published edge distance and ' + 'the catalogue publishes no edge distance for it, so there is ' + 'nothing to double'.format(family_name, multiple)) + return None, None, ( + 'the spacing rule recorded for {0} states neither a published ' + 'spacing nor a multiple of the edge distance'.format(family_name)) + + +def _trim_number(value): + """9.0 -> '9', 12.5 -> '12.5'. A minimum printed as "18.0 inches" reads + like a measurement; it is a whole number off a table.""" + try: + if float(value) == int(float(value)): + return str(int(float(value))) + except (TypeError, ValueError): + pass + return str(value) + + +def lifter_basis_fc(knowledge, family_name): + """The concrete strength a lifter's catalogue ratings were derived at. + + Handed on rather than applied here when the ratings are going somewhere + that will do its own derating -- a handling report checks one insert at + several concrete strengths, so a single pre-derated number cannot be + right for all of them. + """ + entry = _catalogue_entry(knowledge, family_name) + return entry.get('basis_fc_psi') if entry else None + + +# ----------------------------------------------------------- starter file + +STARTER = u"""# Ticket QC knowledge -- hand written, grows with the job. +# +# Every key here overrides a built-in default; anything you leave out keeps +# the default. Run the Ticket QC button after editing: it reloads this file +# on every run, no Revit restart needed. +# +# This file lives beside the model and is NOT part of the tool's repository. +# It is where your office's naming conventions belong. +# +# It is the LAST of three layers -- defaults, then the plant file, then this. +# Anything true of the plant rather than of this job belongs one layer up: +# +# %APPDATA%\\WillTools\\knowledge.yaml +# +# Naming conventions and the casting bed live there. Override one here only +# when this particular job breaks the plant's own rule. + +# ------------------------------------------------------------------- plant +# +# WHICH PLANT THIS JOB IS PRODUCED AT. One folder name, and it is the key +# that CHOOSES the plant layer above: the plant file is read from +# //knowledge.yaml. +# +# job: +# plant: northgate +# +# Leave it out and the machine-wide plant file (/knowledge.yaml) is +# read instead, which is how this tool worked before per-plant folders and +# is perfectly normal. Name a plant with no folder and every button says so +# and stops -- it does NOT quietly fall back, because running one plant's job +# on another plant's rules is worse than not running at all. + +# Parameter names are NOT here either. They belong to the office's Revit +# template, which outlives any one job, so `parameters:` lives in the plant +# file too. Put a role here only when THIS job departs from the template. + +# ------------------------------------------------------------------- scope +# +# WHICH PIECES OF THIS JOB ARE YOURS TO JUDGE. Several engineers work one +# job; the knowledge in these files was validated against the pieces you +# detail and NOT against everybody else's. Naming the prefixes your pieces' +# marks start with stops every button issuing verdicts on work you cannot +# stand behind. +# +# Leave it out and NOTHING is filtered: every report judges every piece and +# says on its own face that no scope was declared. That is the honest +# default, not the safe-looking one -- an empty list here has never meant +# "judge nothing". +# +# Whatever you do declare, every report still prints one line naming how many +# pieces it left alone and of what kind. Out of scope is a state a report +# shows you, never work that quietly disappears. Anything you SELECT +# yourself is judged whatever its prefix, with a line saying the verdict +# rests on unvalidated knowledge. +# +# scope: +# mark_prefixes: [PNL] # the letters your marks start with +# declared_by: # so somebody can argue with it +# note: > +# one sentence on why this is the boundary. + +tolerances: + dimension_in: 0.375 # how far a dimension may drift before it fails + psi: 1.0 + it_lb: 100.0 # jacking force vs design intent, in POUNDS + # (Will, 2026-08-30) + +legend: + # A design's strand-pattern legend prints one strand count. When true, that + # printed count must equal the strands actually modelled on each piece. + # Set false if the printed number is a typical maximum for the design. + printed_strand_count_is_per_piece: true + width_must_match_panel: true + +sheet: + # A ticket's printed length is nominal and runs a fraction over the + # modelled length on stemmed pieces by design. Turn on only if this + # project expects the two to agree. + check_tkt_dimensions: false + +# ---------------------------------------------------------------- lifters +# +# Working load in pounds, per insert. Keys match as substrings against the +# family name, longest match wins. Fill these in and the lifter capacity +# check starts running; leave a family out and it is reported as unknown +# rather than silently passing. +lifters: + dynamic_factor: 1.5 # piece weight is multiplied by this + min_edge_distance_in: null # flag a lifter closer than this to an end + # `{}` and not a bare key: a bare `capacity_lb:` parses as null, and null + # here is the "a value was deleted" alarm shape `_note_capacity_override_emptied` + # refuses on (owner ruling 2026-09-05: keep refusing). Until 2026-09-05 + # this starter wrote the bare key, so EVERY job created from it refused + # Panel Lift Check and Reinforcing QC the moment that check landed (04 Sep). + # To override a rating, replace the braces with entries: + # capacity_lb: + # ANCHOR_2T: 4000 # substring of the insert family name: lb + # ANCHOR_4T: 8000 + capacity_lb: {} + +concrete: + min_fci_ratio: null # e.g. 0.6 to require f'ci >= 0.6 * f'c +""" + + +def starter_text(plant=None, plant_basis=None): + """The text `write_starter` would write. Revit-free. + + With a non-empty `plant`, the job block naming it is active (uncommented) + right after the plant commentary, so a new job knows which plant layer it + reads instead of silently falling back to the machine-wide file (issue + #3). `plant_basis` is the short sentence the block cites for why that + plant is named; defaulting to 'Design Extract, from the model's producer + name' when a plant is given and none passed. With no plant, the text is + byte-identical to `STARTER`. + """ + if not plant: + return STARTER + basis = plant_basis if plant_basis else 'Design Extract, from the model\'s producer name' + block = ( + 'job:\n' + ' plant: {0} # named by {1}\n' + '\n' + ).format(plant, basis) + anchor = '# ------------------------------------------------------------------- scope' + return STARTER.replace(anchor, block + anchor) + + +def write_starter(doc, overwrite=False, plant=None, plant_basis=None): + """Drop a commented knowledge.yaml next to the model. Never clobbers. + + `plant`/`plant_basis` are passed to `starter_text`, so a fresh job names + the plant it was created for rather than running un-named. + """ + from vibe import designspec + path = designspec.knowledge_path(doc) + if os.path.isfile(path) and not overwrite: + return path, False + with io.open(path, 'w', encoding='utf-8') as fh: + fh.write(starter_text(plant=plant, plant_basis=plant_basis)) + return path, True + + +# Owner, verbatim: "所有的出入都是 bed offset" -- all the discrepancy is +# bed offset. +PLANT_STARTER = u"""# WillTools -- plant standards. +# +# This file is READ BY EVERY PROJECT on this machine. It holds what is true +# of the plant rather than of one job: above all, how the plant names things +# and how its casting beds are laid out. A project's own knowledge.yaml is +# read after this one and overrides it key by key, so put a value here when it +# is the plant's rule and there only when one job breaks it. +# +# Read fresh on every run -- no Revit restart after editing. +# +# Like the project file, this is NOT part of the tool's repository and must +# not be committed to one. To share it across a plant, put it on a file share +# and point the WILLTOOLS_KNOWLEDGE environment variable at the full path -- +# at the FILE for one plant, or at the FOLDER the per-plant folders sit in. +# +# ONE FILE PER PLANT. A job says which plant it is produced at with +# `job.plant:` in its own knowledge file, and that name is a FOLDER beside +# this one: //knowledge.yaml. A second plant is a new folder, +# never an edit to this file. A job that names no plant reads the +# machine-wide /knowledge.yaml instead, which is this file. + +# ------------------------------------------------------ face designations +# +# The parenthetical suffix on a bar or embed call-out -- `SR324 (F.F.)`, +# `SR310 (E.L.)`, `P76 W/ SR5602 (S.I.F.)` -- saying which face of the piece +# the part goes on. The abbreviations themselves are the trade's and are +# published in the tool's own knowledge/catalogue.yaml; WHICH SET this plant +# uses is a house habit and belongs here. +# +# SELECT BY ID, never copy the tokens down. A list, so it REPLACES rather +# than merges: what is named here is exactly what is in force. +# +# face_designations: +# use: [near_far, in_form] +# +# near_far N.F. / F.F. / E.L. measured from the VIEWING DIRECTION +# in_form T.I.F. / B.I.F. / S.I.F. measured from the CASTING BED +# +# Two vocabularies at once is normal: near/far names a layer position and +# in-form names a casting orientation, and one ticket prints both. The frame +# of reference is the whole point -- near/far FLIPS when the view flips and +# in-form does not -- so the two must never be merged into one list of words. +# +# A plant using a token nobody published states its own vocabulary here. +# `vocabularies` is a map, so this is an addition and the published ones stay: +# +# face_designations: +# use: [near_far, house_bed] +# vocabularies: +# house_bed: +# frame: form # or `view`; see catalogue.yaml frames +# measured_from: the casting bed +# tokens: +# - token: U.S. +# means: up side +# side: top +# +# Leave `use` unset and the face-designation check stands down and says so. +# It never guesses: reading a form-relative token as a view-relative one +# produces confident, wrong verdicts. + +# ------------------------------------------------------------ nomenclature +# +# Check this section first on a new job. Everything downstream keys off it, +# and a wrong pattern here does not raise an error -- it quietly matches the +# wrong design, which is the one failure this tool cannot catch for you. +# +# THE DESIGN NUMBER +# +# A design is documented by a packet of sheets. Its cover sheet is also the +# design summary, and because it is a cover page it carries a page suffix: +# design PNL1 has cover sheet PNL1.0, then PNL1.A / PNL1.B for the rest of the +# packet. A piece's design number parameter may carry either form. So both +# sheet numbers and piece parameters are reduced through design_number_re +# before anything is compared, and the captured groups are joined to make the +# key. +# +# ^([A-Za-z]+)\\s*(\\d+) +# +# PNL1.0 -> PNL1 SWA1.0M -> SWA1 DBT1 -> DBT1 +# PNL1.B1 -> PNL1 RBM9.0A -> RBM9 +# +# Suffixes are packet pagination or an added note, not a different design: +# PNL3.A is PNL3 plus a note -- say an erection-only element that is removed +# in the field -- and is checked against PNL3's design. A plant that numbers +# designs 12-345, or W/1.0, or 1234-PNL-1, needs a different pattern here. +# +# WHICH SHEET IS THE DESIGN +# +# design_folder is the project-browser folder the packets live in. Sheets +# outside it are never treated as designs, which is what keeps hundreds of +# shop tickets out of the picker. Leave it empty to search every folder. +# +# VIEW NAMES DO NOT MATTER +# +# A view belongs to the design of the sheet it is placed on, whatever it is +# called -- a view still titled PNL2.0 sitting on sheet PNL1.0 is PNL1.0's. +# There is deliberately no rule about view naming. + +nomenclature: + design_number_re: '^([A-Za-z]+)\\s*(\\d+)' + design_folder: '' # e.g. DESIGN PRODUCT + ignore_sheet_prefixes: # dead sheets, skipped outright + - zvoid + - zzvoid + - xxx + stale_view_re: '(\\s+Copy\\s+\\d+|\\s+old)\\s*$' # superseded duplicates + +# ------------------------------------------------------------------ rails +# +# A design with strands always dimensions where they sit: a chain across the +# pattern, first segment from a rail to the first strand, then strand to +# strand, last segment back to the far edge. N segments locate N-1 strands +# and the chain total is the piece width. +# +# strand_datum the edge the chain is measured from. 'left_rail' means +# the left-hand edge where the concrete starts. +# +# left_rail_is_fixed whether the left rail is the bed's FIXED rail. +# +# true it is. Tolerance accumulates away from it and +# the dimension chain has a direction. +# false it is not -- and that is the whole statement. +# It does NOT mean the right rail is fixed +# instead: a fixed RIGHT rail does not exist in +# this industry (Will, 2026-08-30). false means +# this bed has no fixed rail to accumulate from. +# null nobody has recorded it. Do not guess. +# +# false does NOT stop the reasoning -- it starts a +# different one. With no fixed rail the piece can sit +# anywhere across the bed, so the strand pattern is +# free to slide off the design chain, and the whole +# difference is bed offset (Will: "all the discrepancy +# is bed offset"). The spacings between strands still have to +# be reproduced exactly; only the distance from the +# rail to the first strand may move, and only as far as +# max_bed_width_in leaves over. So false means: read +# max_bed_width_in and run BED-OFFSET. +# +# null is the one that stops: nobody has said, so +# nothing may be assumed either way. +# +# CLOSED 2026-08-30: _check_bed_offset now reads this +# flag, via ticketpage.bed_offset_status. true no +# longer falls through to the false behaviour -- it +# reports the offset as a WARN and says it was not +# judged, because no fixed-rail tolerance has been +# recorded. STILL OPEN for the engineer: what a +# true plant should actually tolerate. Until that is +# answered a true plant gets a WARN on every ticket +# whose pattern is off the chain at all. + +# +# max_bed_width_in clear width of the bed, in inches. The strands are +# stressed across the whole bed, so a piece narrower than +# the bed can sit anywhere in it and the pattern slides +# with it. How far it may slide is not a judgement call: +# +# |offset| <= max_bed_width_in - panel width +# +# A 12'-0" bed leaves an 11'-11" piece exactly 1". Fill +# this in and BED-OFFSET starts checking it; leave it null +# and the spacings are still checked, the slide is not. + +rails: + strand_datum: left_rail + left_rail_is_fixed: null + max_bed_width_in: null # e.g. 144 for a 12'-0" bed + +# --------------------------------------------------------------- drawings +# +# full_length_bar_fraction +# Checklist 5.2. The standards place reinforcing by +# class: a FULL LENGTH bar is drawn in Section A-1, a +# PARTIAL length bar and any trim / corner bar in the +# plan, a FULL WIDTH transverse bar in Section B-1. +# The manual never says how much of the panel "full" +# is, so this is the plant's number, as a fraction: +# +# 0.9 a bar reaching 90% of the panel counts +# null nobody has said -- 5.2 checks the embeds +# and stands its reinforcing half down, +# saying so, rather than guessing. +# +# Guessing it is the expensive mistake: too high and +# every full-length bar is reported missing from the +# BED PLAN where it must not appear at all. + +# reinforcing_families +# Which modelled families are reinforcing bars. Revit's +# own category ('Structural Rebar') answers it for real +# rebar and is used first; a plant that models bars as +# generic-model families must name them here, matched as +# substrings, or nothing in the model says they are bars. +# +# Empty is safe but not free: a bar this does not name +# keeps going through the old residual and can be +# reported as missing from the BED PLAN when it belongs +# in a section instead. + +# reinforcing_family_patterns +# THE SAME FACT AS reinforcing_families, written as a +# REGULAR EXPRESSION per entry instead of a literal +# substring -- for a family name that wraps an OPEN-ENDED +# CODE no enumeration could keep up with, e.g. a bend-type +# code inside a fixed wrapper: +# +# reinforcing_family_patterns: +# - '^ACME-.+_\\(SCHEDULED\\)$' +# +# Checked case-insensitively with re.search, AFTER the +# plain substrings above, and counts as the SAME evidence +# class as reinforcing_families -- either one being +# non-empty is 'list' mode and wins over the rule. A +# pattern that fails to compile never matches, silently, +# the same fate a plain-list token gets when it never +# appears in a family name. + +# reinforcing_rule THE SAME FACT AS A RULE, AND USUALLY THE BETTER SHAPE. +# A list is an enumeration and it goes stale in silence: +# the day a bar family is added and nobody re-lists it, +# 5.6 and 5.7 stop seeing those bars and report nothing, +# which reads exactly like nothing being wrong. +# +# A rule says it once -- a PARAMETER to read, plus a +# TOKEN to find in its VALUE -- and a family added +# tomorrow is recognised with no edit: +# +# reinforcing_rule: +# parameter: member.manufacture_component +# parameter_verified: true +# read_from: [instance, type] +# value_contains: [rebar] +# +# `parameter` names a ROLE, not a parameter name: map the +# role to your own parameter under `parameters:` below. +# READ_FROM MATTERS. On at least one plant the parameter +# lives on the INSTANCE for some elements and on the TYPE +# for others, never both -- so reading one alone finds +# nothing on half the model and looks entirely clean. +# +# ALL THREE ARE REQUIRED or the rule does not run: the +# role, at least one token, and `parameter_verified`. +# Until somebody has read the parameter off a real model +# leave the flag FALSE and put the reason in +# `parameter_unverified_because` -- the rule is then +# dormant AND SAYS SO. A guessed parameter +# un-recognises every bar at once. +# +# scopes: lets checklist 5.6 and 5.7 see different +# populations, because the token is not a clean boundary +# -- some bar-formed hardware carries it and some real +# reinforcement does not. Empty until somebody rules on +# it; empty means both rows see the same population. +# +# reinforcing_families WINS WHERE IT IS SET. Empty the +# list to hand over to the rule. + +# reinforcing_family_overrides +# Per family, exact name, case-insensitive: +# `MY_FAMILY: true` forces one in, `false` forces one +# out. Checked FIRST, so a job corrects one family +# without restating the list or the pattern. A map, so +# the layers deep-merge. + +drawings: + full_length_bar_fraction: null # e.g. 0.9 + reinforcing_families: [] # e.g. [SR, TRIM_BAR] + reinforcing_family_patterns: [] # e.g. ['^ACME-.+_\\(SCHEDULED\\)$'] + reinforcing_rule: + parameter: member.manufacture_component # a ROLE -- map it below + parameter_verified: false # VERIFY IT AGAINST A MODEL FIRST + parameter_unverified_because: null + read_from: [instance, type] # mutually exclusive per element + value_contains: [] # e.g. [rebar] + scopes: {} # e.g. {'5.7': {except: [LIFTING]}} + scopes_unresolved_because: null + reinforcing_family_overrides: {} # e.g. {ODD_BAR_FAMILY: true} + +# ------------------------------------------------------------- parameters +# +# What this office's Revit template calls each thing the tool reads. Nothing +# works until this is filled in -- the code knows only roles, never names. +# Self Check lists every role still unmapped. +# +# This belongs to the plant because it belongs to the TEMPLATE, and the +# template outlives any one job. Override a role in a project's own file only +# when that job genuinely departs from the template. +# +# One name, or several tried in order: +# length: PANEL_LENGTH +# length: [PANEL_LENGTH, Length] +# +# parameters: +# panel: +# design_number: PANEL_DESIGN_NO # groups pieces to a design summary +# length: PANEL_LENGTH +# width: PANEL_WIDTH +# unit_weight: PANEL_UNIT_WEIGHT # concrete density, pcf +# weight: PANEL_CAST_WEIGHT # lb +# member: +# mark: MEMBER_MARK +# length: MEMBER_DEV_LENGTH # a bent bar's DEVELOPED length +# manufacture_component: MEMBER_CLASS # what the plant says a member is +# panel_type: +# thickness: PANEL_THICKNESS # on the type, not the instance +# strand_type: +# diameter: STRAND_DIAMETER +# description: STRAND_DESCRIPTION # carries the grade, e.g. '270 KSI' +# wwf_type: +# description: MESH_DESCRIPTION +# sheet: +# design_number: TICKET_DESIGN_NO +# length: TICKET_LENGTH +# width: TICKET_WIDTH +# depth: TICKET_DEPTH +# plant: TICKET_PLANT # optional + +# ---------------------------------------------------------------- families +# +# What counts as what, matched as case-insensitive substrings of the family +# name. These are plant-wide: the same catalogue is used on every job. +# +# families: +# strand_include: [STRAND] +# strand_exclude: [LOOP, GROMMET] +# lifter_include: [LIFT, ANCHOR] +# lifter_exclude: [SHEAR] +# wwf_prefix: WWF + +# ----------------------------------------------------------------- lifters +# +# A lifter's rating is looked up in TWO hops, because a plant models its own +# part number rather than the manufacturer's designation: +# +# family name in the model +# -> lifters.catalogue_alias (here: what our part number really is) +# -> catalogue.inserts (product data: what it is rated for) +# +# Put the alias here. Put the rating in the catalogue -- it is published +# product data, the same for everyone, and it does not belong to this plant. +# +# lifters: +# dynamic_factor: 1.5 +# min_edge_distance_in: null +# catalogue_alias: +# P000: SAMPLE_ANCHOR_4T # our part number -> catalogue item +# capacity_lb: +# # Direct override, checked BEFORE the catalogue. For a one-off that +# # nobody has catalogued yet -- not the place for ordinary hardware. +# ONE_OFF_INSERT: 5000 +# # A part whose tension and shear are DIFFERENT numbers needs the dict +# # shape instead of a bare number -- see lifter_capacity()'s docstring +# # for the full three-state rule (a real number / not rated / a +# # deliberate "forbidden"): +# SPLIT_RATING_INSERT: +# tension_lb: 13800 +# shear_lb: 3860 # a bare 0 here means NOT RATED, not zero +# # shear_lb: forbidden # this is how to state a REAL zero + +# --------------------------------------------------------------- handling +# +# How a piece is picked up and carried. Plant knowledge: the same way every +# job leaves the plant. +# +# handling: +# rig_mode: rolling # valid: rolling | static (NOT static_lifting) +# sling_angle_deg: 60 +# dunnage: +# mode: null # where the piece sits on the trailer: +# # null old lift approximation +# # lifter_rows same lines as stripping +# # fraction inset fraction*L each end +# fraction: null # required when mode is fraction, e.g. 0.2 + +# --------------------------------------------------------------- catalogue +# +# Published product data. Not secret, not job-specific -- but still data, so +# that correcting a rating is an edit rather than a release. +# +# catalogue: +# inserts: +# SAMPLE_ANCHOR_4T: +# working_load_lb: 8000 + +# ----------------------------------------------------------------- legend +# +# A ticket's strand/reinforcing call-outs live in a pattern-legend VIEW on +# plants that draw one. The DEFAULT prefixes are empty -- a plant that draws +# none must not fail tickets for a view it never draws -- so a plant that +# DOES draw one names its views here and the LGD checks re-engage: +# +# legend: +# pattern_prefixes: [STRAND PATTERN, REINFORCING PATTERN] +# section_strand_view: '' # view whose text prints the per-piece (n) + +# ------------------------------------------------------------------ notes +# +# Checklist 6.5. The standing instructions on a ticket -- stacking, erection, +# picking/stripping -- are each their own small view. Name them and the check +# reports any that print a heading with nothing under it. Left empty (the +# default) it reports NOT RUN, never a pass: +# +# notes: +# instruction_views: [DUNNAGE, ERECTION, PICKING-STRIPPING] +# +# A plant that draws SEVERAL VARIANTS of the same note -- a ticket places one +# of them -- names them as a group instead, and the check wants any one: +# +# notes: +# instruction_views: +# ERECTION: [ERECTION, ERECTION (V2)] +# +# A LIST replaces the layer below it; a MAP deep-merges into it. Where the +# note views differ by PRODUCT TYPE, name each family under +# instruction_view_sets (a map, so it deep-merges) and only the family a +# ticket actually carries is required of it. + +# ---------------------------------------------------------- nomenclature +# +# Checklist 1.4 reads the design label a VIEW NAME carries ('TICKET NOTE - +# WP2- ...' placed on a WP1 ticket). It derives the pattern from +# design_number_re by dropping the '^' anchor; set this only if that +# derivation is wrong for your view names: +# +# nomenclature: +# view_design_token_re: '([A-Za-z]+)\\s*(\\d+)' +""" + + +def write_plant_starter(overwrite=False, plant=None): + """Create a plant knowledge file. Never clobbers an existing one. + + With no `plant` this writes the machine-wide file, as it always has. + Naming a plant writes `//knowledge.yaml` -- which is how a + SECOND plant is set up: a new folder, not an edit to the first one's + file. An unusable plant id writes nothing and says so. + """ + from vibe import designspec + path = designspec.plant_knowledge_path(plant) + if path is None: + return None, False + folder = os.path.dirname(path) + try: + if folder and not os.path.isdir(folder): + os.makedirs(folder) + except Exception: + pass + if os.path.isfile(path) and not overwrite: + return path, False + with io.open(path, 'w', encoding='utf-8') as fh: + fh.write(PLANT_STARTER) + return path, True diff --git a/WillTools.extension/lib/vibe/latextract.py b/WillTools.extension/lib/vibe/latextract.py new file mode 100644 index 0000000..8b7b5cf --- /dev/null +++ b/WillTools.extension/lib/vibe/latextract.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +"""The Revit half of lateral extraction. READ-ONLY. No Transaction, ever. + +This is the `ticketchecks.py` of the pair: it imports the Revit API at module +scope and therefore cannot be unit tested, which is exactly why it does as +little thinking as possible. It reads parameters and geometry, hands plain +dicts to `vibe.latmodel`, and makes no judgement of its own beyond "the +model did not tell me". + +WHAT WAS MEASURED IN THE LIVE MODEL, 2026-08-30 +----------------------------------------------- +Two findings shaped this file, and both are the reason it refuses things +rather than inventing them. + +1. ZERO elements carry the lateral marker today. Every string parameter of + every structural framing, wall, foundation and floor element, plus 1200 + generic models, was read: nothing anywhere contains 'lateral' or + 'resist'. The marker exists as a convention and has not been applied yet. + So the first honest run of this tool reports NOTHING MARKED, and that + must not look like an answer. + +PARAMETER NAMES ARE NOT IN THIS FILE +------------------------------------ +Not one Revit parameter is named here by its literal name. Every one is +reached through a ROLE resolved against the plant's knowledge file -- +`member.identity_description` for the field the marker is typed into, +`member.mark` for the piece mark. That is not tidiness: a parameter name IS +this office's template, and a name written into shareable code means the +next plant cannot be served without editing code. + +An unmapped role therefore makes this stand DOWN, with a sentence naming +the role. It never falls back to a name that happens to work here -- a +fallback would work on this model and silently find nothing on the next +one, which is precisely the failure this codebase is built against. The +only exception is Revit's own built-in Mark, used purely to label a row in +the report; it is a Revit concept, not a plant's, and nothing depends on it. + +2. Every structural framing element in that model has a LocationPoint, not + a LocationCurve. They are point-placed precast families. A member axis + therefore cannot be read the usual way, and the bounding box is not a + substitute: a column whose COLUMN_LENGTH parameter reads 90'-5" has a + bounding box 140 ft tall, because the family carries geometry beyond the + member. Deriving an axis from that box would produce coordinates that + look completely reasonable and are wrong by 50 feet. + + So point-placed elements are SKIPPED with that reason, unless a plant + explicitly opts in via `lateral.point_member_axis`. The default is None + -- refuse -- for the same reason `rails.left_rail_is_fixed` defaults to + None: a wrong default here is invisible. + +IronPython 2.7. +""" +# pylint: disable=import-error,invalid-name,broad-except +from Autodesk.Revit.DB import (FilteredElementCollector, BuiltInCategory, + BuiltInParameter, ElementId, StorageType, + LocationCurve, LocationPoint, Line) + +from vibe import knowledge as K +from vibe import latmodel + + +# The role the marker field is reached through. The NAME behind it lives in +# the plant's knowledge file and deliberately not here. +DESC_ROLE = 'member.identity_description' +MARK_ROLE = 'member.mark' + +# Scanned when the user has selected nothing. Never unscoped -- an +# unfiltered collector took Revit down on 2026-08-18. +DEFAULT_CATEGORIES = [ + 'OST_StructuralFraming', 'OST_StructuralColumns', 'OST_Walls', + 'OST_StructuralFoundation', 'OST_GenericModel', +] + + +def _eid(el): + try: + return el.Id.IntegerValue + except Exception: + return el.Id.Value + + +def _sparam(el, name): + try: + pr = el.LookupParameter(name) + if pr is None or pr.StorageType != StorageType.String: + return None, False + v = pr.AsString() + return (v.strip() if v else None), True + except Exception: + return None, False + + +def read_description(el, param_names): + """(text, source_param, any_such_param_exists). + + `param_names` comes from the `member.identity_description` role. This + function never invents a name of its own. + + The third value is the one that matters. If NO mapped parameter exists + on any element, the marker cannot have been written anywhere, and that + is 'could not judge' -- not 'nothing is marked'. + """ + exists = False + for name in param_names: + v, present = _sparam(el, name) + exists = exists or present + if v: + return v, name, True + return None, None, exists + + +def _mark(el, know): + """The piece mark, for labelling report rows only. + + The plant's own mark parameter first, through its role. Revit's built-in + Mark second -- that is a Revit concept rather than an office one, it is + the sanctioned general fallback (see knowledge.OPTIONAL_ROLES), and + nothing but a table cell depends on it. + """ + for name in K.param_names(know, MARK_ROLE): + v, _ = _sparam(el, name) + if v: + return v + try: + pr = el.get_Parameter(BuiltInParameter.ALL_MODEL_MARK) + if pr is not None: + v = pr.AsString() + if v and v.strip(): + return v.strip() + except Exception: + pass + return None + + +def _category(el): + try: + return el.Category.Name if el.Category else None + except Exception: + return None + + +def _type_name(el): + """The Revit type name -- a section LABEL, never section properties. + + `.Name` on a Revit type is ambiguous under IronPython on some bindings + (it came back empty for every element in the 2026-08-30 measurement + pass), so the built-in type parameter is tried first and reflection + second. Both are Revit's own, not this office's. + """ + try: + pr = el.get_Parameter(BuiltInParameter.ELEM_TYPE_PARAM) + if pr is not None: + v = pr.AsValueString() + if v and v.strip(): + return v.strip() + except Exception: + pass + for getter in (lambda: el.Symbol.Name, + lambda: el.WallType.Name, + lambda: el.Name): + try: + v = getter() + if v and v.strip(): + return v.strip() + except Exception: + continue + return None + + +def _material(el): + try: + pr = el.get_Parameter(BuiltInParameter.STRUCTURAL_MATERIAL_PARAM) + if pr is None: + return None + mid = pr.AsElementId() + if mid is None or mid == ElementId.InvalidElementId: + return None + me = el.Document.GetElement(mid) + if me is None: + return None + return me.Name + except Exception: + return None + + +def _pt(xyz): + return (xyz.X, xyz.Y, xyz.Z) + + +def axis_of(el, know): + """((i, j), source, None) or (None, None, why_not). Feet. Read-only. + + Feet because that is Revit's internal unit and nothing here converts. + The tuple carries no unit of its own; `latmodel.LENGTH_UNIT` is the one + place the unit is stated, and it is stated once. + """ + try: + loc = el.Location + except Exception: + loc = None + + if isinstance(loc, LocationCurve): + try: + crv = loc.Curve + except Exception: + return None, None, 'the location curve could not be read' + if not isinstance(crv, Line): + return (None, None, + 'the location curve is a {0}, not a straight line. A ' + 'curved member is not a single analysis member and is ' + 'not split into one here.'.format(type(crv).__name__)) + try: + return ((_pt(crv.GetEndPoint(0)), _pt(crv.GetEndPoint(1))), + 'location_curve', None) + except Exception as ex: + return None, None, 'end points unreadable: {0}'.format(ex) + + if isinstance(loc, LocationPoint): + mode = K.get(know, 'lateral.point_member_axis', None) + if not mode: + return (None, None, + 'placed as a point-based family, so it has no location ' + 'curve and no member axis. Its bounding box is not used: ' + 'measured on this model, a 90 ft column has a 140 ft ' + 'bounding box because the family carries geometry beyond ' + 'the member, so an axis taken from it would be wrong by ' + 'tens of feet while looking entirely plausible. Set ' + 'lateral.point_member_axis in knowledge.yaml to opt in.') + if mode != 'bounding_box_longest': + return (None, None, + 'lateral.point_member_axis is set to "{0}", which this ' + 'version does not implement.'.format(mode)) + try: + bb = el.get_BoundingBox(None) + except Exception: + bb = None + if bb is None: + return None, None, 'point-based, and it has no bounding box either' + lo, hi = _pt(bb.Min), _pt(bb.Max) + spans = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]] + k = spans.index(max(spans)) + mid = [(lo[n] + hi[n]) / 2.0 for n in range(3)] + a, b = list(mid), list(mid) + a[k], b[k] = lo[k], hi[k] + return (tuple(a), tuple(b)), 'bounding_box', None + + return (None, None, + 'no Location of a kind that carries an axis (got {0})' + .format(type(loc).__name__ if loc is not None else 'None')) + + +def _wall_skip_reason(el): + """Why a marked wall is not turned into a line member. + + A shear wall is a panel. RISA models it as a wall panel or a mesh of + plates, and this tool emits no plates at all -- so turning the wall's + base curve into a horizontal line member would put a beam where the + engineer put a wall. The geometry is recorded in the skip row so that + nothing is lost, but no member is invented. + """ + bits = [] + try: + loc = el.Location + if isinstance(loc, LocationCurve): + c = loc.Curve + a, b = _pt(c.GetEndPoint(0)), _pt(c.GetEndPoint(1)) + bits.append('base line ({0:.3f},{1:.3f},{2:.3f}) to ' + '({3:.3f},{4:.3f},{5:.3f}) ft' + .format(a[0], a[1], a[2], b[0], b[1], b[2])) + except Exception: + pass + try: + pr = el.get_Parameter(BuiltInParameter.WALL_USER_HEIGHT_PARAM) + if pr is not None: + bits.append('height {0:.3f} ft'.format(pr.AsDouble())) + except Exception: + pass + geom = '; '.join(bits) or 'geometry not readable' + return ('a wall is a shear PANEL, not a line member. This tool emits no ' + 'plates or wall panels, and will not put a beam where the ' + 'engineer put a wall. Recorded geometry: ' + geom) + + +def candidates(doc, selection=None, know=None): + """The elements to inspect. Always scoped -- never a bare collector.""" + if selection: + return list(selection) + cats = K.get(know, 'lateral.categories', None) or DEFAULT_CATEGORIES + out = [] + for cn in cats: + bic = getattr(BuiltInCategory, cn, None) + if bic is None: + continue + try: + out.extend(FilteredElementCollector(doc).OfCategory(bic) + .WhereElementIsNotElementType().ToElements()) + except Exception: + continue + return out + + +NOT_RUN_UNMAPPED = 'role_unmapped' +NOT_RUN_ABSENT = 'parameter_absent' + + +def scan(doc, selection=None, know=None): + """Inspect candidates for the marker. Returns a dict, or (None, reason). + + A tuple `(None, reason)` means COULD NOT JUDGE, and there are exactly + two ways to get there: + + NOT_RUN_UNMAPPED the `member.identity_description` role has no + parameter name behind it, so this does not know + where the marker would be written. + NOT_RUN_ABSENT the role is mapped, but no candidate element + actually carries that parameter. + + In both, the absence of marked members says nothing at all. A returned + dict with empty `marked` means the opposite: the question was asked + properly and the answer is that nothing is marked. The button reports + the two differently, which is the whole reason this is not just an + empty result. + """ + know = know or {} + params = K.param_names(know, DESC_ROLE) + if not params: + return None, NOT_RUN_UNMAPPED + tokens = K.get(know, 'lateral.marker_tokens', None) \ + or latmodel.DEFAULT_MARKER_TOKENS + + els = candidates(doc, selection=selection, know=know) + any_field = False + marked, ambiguous, unmarked = [], [], 0 + + for el in els: + text, source, present = read_description(el, params) + any_field = any_field or present + verdict, token = latmodel.match_marker(text, tokens) + if verdict == latmodel.MATCH_NO: + unmarked += 1 + continue + row = {'element': el, 'element_id': _eid(el), 'desc': text, + 'desc_param': source, 'token': token, + 'category': _category(el), 'mark': _mark(el, know)} + (marked if verdict == latmodel.MATCH_YES else ambiguous).append(row) + + if not any_field: + return None, NOT_RUN_ABSENT + return {'params_tried': params, 'tokens': tokens, + 'candidates': len(els), 'marked': marked, + 'ambiguous': ambiguous, 'unmarked': unmarked}, None + + +def gather(doc, selection=None, know=None): + """(model, scan, None) -- or (None, None, reason) when it could not judge. + + Read-only from start to finish. No Transaction is opened anywhere in + this module and none should ever be: the production model this runs on + is somebody's live work. + """ + found, why = scan(doc, selection=selection, know=know) + if found is None: + return None, None, why + + raws = [] + for row in found['marked']: + el = row['element'] + cat = row['category'] or '' + if 'Wall' in cat: + raws.append({'element_id': row['element_id'], 'mark': row['mark'], + 'category': cat, 'desc': row['desc'], + 'token': row['token'], 'i': None, 'j': None, + 'axis_source': None, 'section': None, + 'material': _material(el), + 'note': _wall_skip_reason(el)}) + continue + ends, source, why = axis_of(el, know) + tname = _type_name(el) + raws.append({ + 'element_id': row['element_id'], 'mark': row['mark'], + 'category': cat, 'desc': row['desc'], 'token': row['token'], + 'i': ends[0] if ends else None, + 'j': ends[1] if ends else None, + 'axis_source': source, + # A type NAME, flagged unverified. See latmodel.build for why no + # dimension from GetStructuralSection() is trusted on this model. + 'section': ({'name': tname, 'source': 'revit_type_name', + 'verified': False} if tname else None), + 'material': _material(el), + 'note': why, + }) + + tol = K.get(know, 'lateral.node_tolerance_ft', None) + model = latmodel.build(raws, node_tol_ft=tol, model_title=doc.Title) + + # latmodel.build only knows "no axis". The REASON lives out here, where + # the Revit object was, so put it back on the skip row -- a skip without + # a reason is the thing this codebase keeps refusing to ship. + by_id = {} + for r in raws: + if r.get('note'): + by_id[r['element_id']] = r['note'] + for s in model['skipped']: + reason = by_id.get(s.get('element_id')) + if reason: + s['reason'] = reason + return model, found, None diff --git a/WillTools.extension/lib/vibe/latmodel.py b/WillTools.extension/lib/vibe/latmodel.py new file mode 100644 index 0000000..ae0c0c0 --- /dev/null +++ b/WillTools.extension/lib/vibe/latmodel.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +"""The Revit-free half of lateral extraction: marker, model, node graph. + +This file is the `ticketpage.py` of the lateral tool -- no Revit import at +module scope, no I/O, no `doc`. `vibe.latextract` is the `ticketchecks.py` +side: it walks the model and hands raw dicts in here. `vibe.latrisa` turns +what comes out into files. + +WHY THE SHAPE IS THIS CAREFUL +----------------------------- +Every other tool in this repository produces something a human reads and +judges. This one produces an input file for structural analysis software. +The failure mode is therefore new: a file that OPENS and is subtly wrong is +much worse than no file at all, because the wrongness arrives wearing the +authority of a finished model. + +So the rule throughout: **nothing this cannot determine is ever defaulted.** +A member whose section could not be read does not get a section -- it gets +`section = None`, its label goes in `member['unresolved']`, and the report +names it. The RISA draft writes an obviously-bogus shape label for it so +that RISA refuses the member rather than analysing a guess. + +And the standing distinction this codebase already draws (see the NOT RUN +discussion in `ticketchecks.py`): "could not judge" is not "judged and found +nothing". `collect()` returns `None` for a run that could not happen at all +-- no marker configured, nothing selectable -- and an empty model for a run +that happened and found nothing marked. The button reports those two +differently, on purpose. + +IronPython 2.7: no f-strings, no dataclasses, no typing syntax. +""" +# pylint: disable=invalid-name,broad-except +import math +import re + + +# --------------------------------------------------------------- the marker +# +# The user marks lateral-resisting members BY HAND, by typing into a +# free-text identity field. The owner's own spelling is "lateral-resistent"; +# the conventional English is "lateral-resistant". Both are accepted, because +# the field is typed by a person and a tool that rejects the spelling its own +# owner uses is a tool nobody marks anything for. +# +# These are the DEFAULTS. The live values come from the plant layer under +# `lateral.marker_tokens` (declared in knowledge.DEFAULTS) so a plant that +# writes something else -- "LFRS", "seismic" -- changes a YAML line rather +# than this file. +DEFAULT_MARKER_TOKENS = ['lateral-resistent', 'lateral-resistant'] + +# Words that, immediately in front of the marker, invert it. "NOT +# LATERAL-RESISTENT" is a phrase a detailer plausibly types, and a plain +# substring match would read it as a mark. We neither include it nor drop it +# silently: it comes back as AMBIGUOUS and the report names the element and +# quotes the text, for a human to settle. Guessing either way here is the +# kind of quiet error this whole file exists to avoid. +NEGATORS = ('not', 'non', 'no', 'never', 'excl', 'exclude', 'excluded') + +MATCH_NO = 'no' +MATCH_YES = 'yes' +MATCH_AMBIGUOUS = 'ambiguous' + +_NONWORD = re.compile(r'[^a-z0-9]+') + + +def normalise(text): + """Lowercase; every run of non-alphanumerics becomes one space. + + This is what makes the match tolerant of how the token was typed: + 'Lateral-Resistent', 'LATERAL RESISTANT', 'lateral_resistent' and + 'lateral resistent' all normalise to the same thing. + + It deliberately does NOT remove the separator entirely, so the run-together + 'lateralresistent' does not match. That is a judgement call: accepting it + would mean 'lateral' followed by anything starting with 'resistent' also + matches, and the tool cannot tell a typo from a different phrase. An + unmatched element is visible in the report as "not marked"; a wrongly + matched one is invisible. + """ + if text is None: + return '' + try: + low = text.lower() + except Exception: + low = str(text).lower() + return _NONWORD.sub(' ', low).strip() + + +def match_marker(text, tokens=None): + """(verdict, matched_token_or_None) for one free-text field. + + verdict is MATCH_YES / MATCH_NO / MATCH_AMBIGUOUS. + + ACCEPTS (verdict YES): + 'lateral-resistent' exactly, any case + 'lateral-resistant' the conventional spelling + 'LATERAL RESISTENT' any separator run: - _ space . / + 'shear wall, lateral-resistent' surrounded by other text + 'lateral-resistent (see S2.1)' trailing text + REJECTS (verdict NO): + 'lateral' on its own -- half a token + 'resistant' on its own + 'lateralresistent' no separator at all (see normalise) + 'laterally resistant' 'laterally' is not 'lateral' + '' / None an empty field is not a mark + FLAGS (verdict AMBIGUOUS -- never counted either way, always reported): + 'NOT lateral-resistent' + 'non lateral resistant' + """ + hay = normalise(text) + if not hay: + return MATCH_NO, None + padded = ' ' + hay + ' ' + for raw in (tokens or DEFAULT_MARKER_TOKENS): + needle = normalise(raw) + if not needle: + continue + # Whole-token match: the marker must sit on word boundaries, so + # 'laterally resistant' does not match 'lateral resistant'. + at = padded.find(' ' + needle + ' ') + if at < 0: + continue + before = padded[:at].strip().split(' ') + if before and before[-1] in NEGATORS: + return MATCH_AMBIGUOUS, raw + return MATCH_YES, raw + return MATCH_NO, None + + +# ------------------------------------------------------------------- units +# +# Revit's internal length unit is decimal FEET. Always. Handing inches to +# XYZ() is a 12x error that has already cost this project real time, so the +# unit travels WITH the number everywhere in this structure rather than +# being remembered. Nothing in this file converts anything: feet in, feet +# out. RISA's own default length unit is feet too, which is why the emitter +# has no conversion either -- but it states the unit in the file instead of +# relying on that. +LENGTH_UNIT = 'ft' + +# How close two member endpoints must be to count as the same node. +# 1/8 inch. Not a modelling tolerance -- a "did the detailer snap these +# together" tolerance. Anything looser starts welding members that a +# structural engineer means to be separate. +DEFAULT_NODE_TOL_FT = 0.0104167 # 1/8" + + +# ----------------------------------------------------------- raw member in +# +# What `latextract` hands over per element. Every field may be None, and +# None means "the model did not tell us", never "zero" or "default": +# +# element_id int +# category 'OST_Walls' +# mark piece mark / control mark, or None +# desc the identity-description text the marker was found in +# token which marker spelling matched +# i, j (x, y, z) tuples in FEET, or None if no axis could be found +# axis_source 'location_curve' | 'bounding_box' | None +# section {'name':..,'source':..} or None +# material str or None +# note free text from the gatherer + + +def _key(pt, tol): + """Grid key for endpoint welding, at the tolerance.""" + return (int(round(pt[0] / tol)), int(round(pt[1] / tol)), + int(round(pt[2] / tol))) + + +def _dist(a, b): + return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + + (a[2] - b[2]) ** 2) + + +def build(raw_members, node_tol_ft=None, model_title=None): + """Raw gathered members -> the intermediate structure. Pure. + + Returns a dict. Never raises on a bad member: a member that cannot be + turned into two nodes is moved into `skipped` with a reason, because a + silently dropped member is exactly the failure this tool must not have. + """ + tol = node_tol_ft or DEFAULT_NODE_TOL_FT + nodes = [] + index = {} + members = [] + skipped = [] + + def node_for(pt): + """Weld to an existing node within tol, else make a new one. + + Snapping is to a GRID at the tolerance, then confirmed by true + distance against the candidates in the 27 neighbouring cells -- a + plain grid alone would fail to weld two points a hair either side of + a cell boundary. + """ + k = _key(pt, tol) + best = None + best_d = None + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for dz in (-1, 0, 1): + for idx in index.get((k[0] + dx, k[1] + dy, k[2] + dz), ()): + d = _dist(pt, (nodes[idx]['x'], nodes[idx]['y'], + nodes[idx]['z'])) + if d <= tol and (best_d is None or d < best_d): + best, best_d = idx, d + if best is not None: + return best + idx = len(nodes) + nodes.append({'label': 'N{0}'.format(idx + 1), + 'x': pt[0], 'y': pt[1], 'z': pt[2], + 'members': []}) + index.setdefault(k, []).append(idx) + return idx + + for n, raw in enumerate(raw_members): + i_pt, j_pt = raw.get('i'), raw.get('j') + label = 'M{0}'.format(n + 1) + if not i_pt or not j_pt: + skipped.append({ + 'element_id': raw.get('element_id'), + 'mark': raw.get('mark'), + 'category': raw.get('category'), + 'reason': 'no member axis could be established ' + '(no location curve, and no usable bounding box)'}) + continue + if _dist(i_pt, j_pt) <= tol: + skipped.append({ + 'element_id': raw.get('element_id'), + 'mark': raw.get('mark'), + 'category': raw.get('category'), + 'reason': 'the two ends are the same point to within the node ' + 'tolerance -- a zero-length member is not analysable'}) + continue + ii, jj = node_for(i_pt), node_for(j_pt) + if ii == jj: + skipped.append({ + 'element_id': raw.get('element_id'), + 'mark': raw.get('mark'), + 'category': raw.get('category'), + 'reason': 'both ends welded to the same node at the ' + '{0} ft tolerance'.format(tol)}) + continue + + unresolved = [] + sec = raw.get('section') + if not sec: + unresolved.append('section') + elif not sec.get('verified'): + # A Revit type NAME is not a section property. It is the thing an + # engineer maps to a RISA shape by hand, and it is worth carrying + # -- but calling it a section would be the exact silent default + # this file exists to refuse. Measured on this model 2026-08-30: + # a type named 28" x 48" reports a parameterised rectangle 28" + # wide and 20 FEET deep. The API's own section object is wrong + # here, so no dimension read from it is trusted. + unresolved.append('verified section properties ' + '(only a type name was read)') + if not raw.get('material'): + unresolved.append('material') + # End releases are NEVER extracted -- see `notes` below. Listing it + # per member keeps it in front of whoever reads one member's row, + # not only in the preamble. + unresolved.append('end releases') + + mem = { + 'label': label, + 'element_id': raw.get('element_id'), + 'mark': raw.get('mark'), + 'category': raw.get('category'), + 'desc': raw.get('desc'), + 'token': raw.get('token'), + 'i_node': nodes[ii]['label'], + 'j_node': nodes[jj]['label'], + 'length_ft': _dist(i_pt, j_pt), + 'axis_source': raw.get('axis_source'), + 'section': raw.get('section'), + 'material': raw.get('material'), + 'releases': None, + 'unresolved': unresolved, + 'note': raw.get('note'), + } + members.append(mem) + nodes[ii]['members'].append(label) + nodes[jj]['members'].append(label) + + model = { + 'schema': 'willtools.lateral/1', + 'model_title': model_title, + 'length_unit': LENGTH_UNIT, + 'coordinate_note': 'Revit project internal coordinates, decimal feet. ' + 'Not shared coordinates, not a survey datum.', + 'node_tol_ft': tol, + 'nodes': nodes, + 'members': members, + 'skipped': skipped, + } + model['connectivity'] = connectivity(model) + model['notes'] = notes(model) + return model + + +def connectivity(model): + """What the node graph does and does not say. Facts, not a verdict. + + Two things a structural model needs that a Revit precast model does not + hand over, both reported here rather than fixed: + + free_ends a node only one member reaches. Fine at a support, a + disconnected member anywhere else -- and this cannot + tell which, because supports are not extracted. + near_misses two endpoints close enough to be suspicious but further + apart than the weld tolerance. Almost always a detailer + gap the analysis model should close; NEVER closed here. + """ + free = [n['label'] for n in model['nodes'] if len(n['members']) == 1] + tol = model['node_tol_ft'] + # "Suspicious" = up to 6 inches. Wide enough to catch a real gap, narrow + # enough that it is not just listing the whole model. + near = [] + ns = model['nodes'] + # The near-miss search is O(n^2). Above this it would freeze Revit's UI + # thread, which is a hard rule here. Skipping it is reported as skipped + # (near_miss_checked False) rather than as "no near misses found" -- + # the same could-not-judge / judged-and-found-nothing distinction the + # rest of this file keeps. + checked = len(ns) <= 1200 + if not checked: + return {'node_count': len(ns), 'member_count': len(model['members']), + 'free_ends': free, 'near_misses': [], 'near_miss_total': None, + 'near_miss_checked': False, + 'interior_intersections_checked': False} + for a in range(len(ns)): + for b in range(a + 1, len(ns)): + d = _dist((ns[a]['x'], ns[a]['y'], ns[a]['z']), + (ns[b]['x'], ns[b]['y'], ns[b]['z'])) + if tol < d <= 0.5: + near.append({'a': ns[a]['label'], 'b': ns[b]['label'], + 'gap_ft': d}) + near.sort(key=lambda r: r['gap_ft']) + return { + 'node_count': len(ns), + 'member_count': len(model['members']), + 'free_ends': free, + 'near_misses': near[:200], + 'near_miss_total': len(near), + 'near_miss_checked': True, + # A member end landing part-way along another member is NOT a + # connection here and is not searched for. Saying so is the point. + 'interior_intersections_checked': False, + } + + +def notes(model): + """The standing caveats, as sentences, carried inside the structure. + + They travel with the data rather than living only in the button's report, + so that the JSON handed to somebody three months from now still says what + it does not contain. + """ + out = [ + 'Coordinates are Revit internal decimal FEET, in project coordinates.', + 'END RELEASES ARE NOT EXTRACTED. Revit carries no reliable release ' + 'information for these precast families, so every member here is ' + 'released nowhere. Loaded as-is, an analysis program will treat every ' + 'joint as fully fixed, which for a precast lateral system is very ' + 'likely wrong. Set releases by hand.', + 'SUPPORTS ARE NOT EXTRACTED. No boundary conditions are emitted at ' + 'all. The model as written is unrestrained and will not solve until ' + 'supports are added.', + 'LOADS ARE NOT EXTRACTED. No load cases, combinations, diaphragms, ' + 'masses or seismic parameters are present.', + 'Members are connected only where their END POINTS coincide within ' + '{0} ft. A member ending part-way along another member is NOT ' + 'connected to it, and no node is inserted to make it so.' + .format(model['node_tol_ft']), + ] + unres_sec = [m['label'] for m in model['members'] + if any(u.startswith('section') or u.startswith('verified section') + for u in m['unresolved'])] + if unres_sec: + out.append('{0} member(s) have no verified section property. They are ' + 'not given one. See the unresolved list.'.format(len(unres_sec))) + unres_mat = [m['label'] for m in model['members'] if 'material' in m['unresolved']] + if unres_mat: + out.append('{0} member(s) have no material. They are not given one.' + .format(len(unres_mat))) + return out + + +def unresolved_rows(model): + """One row per member per missing thing -- element by element, not counts.""" + rows = [] + for m in model['members']: + for what in m['unresolved']: + rows.append({'label': m['label'], 'element_id': m['element_id'], + 'mark': m['mark'], 'missing': what}) + return rows + + +def is_analysable(model): + """False whenever loading this straight into analysis software would lie. + + Never used to suppress the export -- the export is always written and + always labelled. Used to decide how loudly the report shouts. + """ + if not model or not model['members']: + return False + if any(m['unresolved'] for m in model['members']): + return False + if model['connectivity']['free_ends']: + return False + return True diff --git a/WillTools.extension/lib/vibe/latrisa.py b/WillTools.extension/lib/vibe/latrisa.py new file mode 100644 index 0000000..3232662 --- /dev/null +++ b/WillTools.extension/lib/vibe/latrisa.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +"""Intermediate lateral model -> files. Revit-free, no I/O at module scope. + +THREE OUTPUTS, AND WHY THE RISA ONE IS A DRAFT +---------------------------------------------- +`emit_json` the intermediate structure verbatim. This is the ground + truth and the only output that claims to be complete. +`emit_csv` nodes and members as two plain tables. Any engineer can open + these, and any script can read them. No format risk at all. +`emit_risa_draft` + RISA-3D .r3d text. **DRAFT. NOT VERIFIED.** + +What was established about the RISA-3D format, and from where: + + RISA's own Appendix D - File Format + https://help.risa.com/risahelp/risa3d/Content/Appendices/Appendix%20D%20(3D).htm + + * .r3d is plain ASCII, keyword driven, editable in Notepad. + * Data is divided into segments delimited by bracketed keyword labels: + [NODES], [MEMBERS], [PLATES], [BOUNDARY_CONDITIONS], [BASIC_LOAD_CASES], + [UNITS], [VERSION_NO], [PROGRAM_INFO], and the file opens with + [RISA-3D_INPUT_DATA]. + * The record count follows the keyword label in angle brackets: ``. + * Every record ends with a semicolon, except the project-description + records between [.PROJECT_DESCRIPTION] and [.END_PROJECT_DESCRIPTION]. + * Labels are quoted strings. A member label may be up to 20 characters. + * A node record is "LABEL" x y z ... ; + * A member record is "LABEL" "DESIGN_LIST" "SHAPE_LABEL" iNode jNode ... ; + with later fields covering K node, rotation, section-set offset, + tension/compression, design rules, material, end releases, end offsets. + +What was NOT established, and this is the reason for the word DRAFT: + + The documentation designates the fields of each record by letter (a, b, c, + ...) and the table that maps those letters to meanings could not be + retrieved -- the mirror that carries it returns 403, and no complete + worked example of a [NODES] or [MEMBERS] record was found in public + sources. So the field COUNT, the field ORDER, and the encoding of the + [UNITS] line are not known with confidence. Nor is which RISA version's + schema a given install expects. + + Guessing them would produce a file that very plausibly opens and is + quietly wrong about which column is which -- the single worst outcome + available to this tool. So the RISA output is written with a + `.r3d.DRAFT.txt` extension. That is deliberate and is not a naming + accident: it cannot be double-clicked into RISA, and anybody who wants to + try it has to rename it, which is the moment they read the header. + + To finish this properly: open any existing .r3d from this office in + Notepad, paste its [UNITS], [NODES] and [MEMBERS] segments into the repo + issue, and the emitter becomes a real one in about an hour. Until then the + CSV and the JSON are the outputs to use. + +IronPython 2.7: no f-strings. +""" +# pylint: disable=invalid-name,broad-except +import json + +from vibe import latmodel + + +# A shape label RISA cannot possibly resolve. Used wherever a section is +# unknown. The point is that RISA fails the member rather than analysing it +# with whatever the current default shape happens to be -- an unanalysable +# file beats a wrong answer. +UNRESOLVED_SHAPE = 'WILLTOOLS_UNRESOLVED_SECTION' +UNRESOLVED_MATERIAL = 'WILLTOOLS_UNRESOLVED_MATERIAL' + + +def emit_json(model): + """The intermediate structure. Complete, and the only output that is.""" + return json.dumps(model, indent=2, sort_keys=True) + + +def _csv_cell(v): + if v is None: + return '' + s = v if isinstance(v, str) else str(v) + if any(c in s for c in ',"\n'): + return '"' + s.replace('"', '""') + '"' + return s + + +def _csv(header, rows): + lines = [','.join(header)] + for r in rows: + lines.append(','.join([_csv_cell(c) for c in r])) + return '\n'.join(lines) + '\n' + + +def emit_nodes_csv(model): + """label, x, y, z, unit, how many members meet here.""" + rows = [[n['label'], '{0:.6f}'.format(n['x']), '{0:.6f}'.format(n['y']), + '{0:.6f}'.format(n['z']), model['length_unit'], + len(n['members']), ' '.join(n['members'])] + for n in model['nodes']] + return _csv(['label', 'x', 'y', 'z', 'unit', 'member_count', 'members'], + rows) + + +def emit_members_csv(model): + """One row per member, with the blanks LEFT BLANK. + + An empty `section` cell is the honest representation of a section this + could not read. It is never filled with a plausible-looking default, and + `unresolved` on the same row says the same thing in words so that a + reader skimming for problems finds them without comparing columns. + """ + rows = [] + for m in model['members']: + sec = m.get('section') or {} + rows.append([ + m['label'], m['i_node'], m['j_node'], + '{0:.6f}'.format(m['length_ft']), model['length_unit'], + sec.get('name'), sec.get('source'), + m.get('material'), + '', # end releases: never extracted + m.get('axis_source'), + m.get('category'), m.get('mark'), m.get('element_id'), + m.get('desc'), m.get('token'), + '; '.join(m.get('unresolved') or []), + ]) + return _csv(['label', 'i_node', 'j_node', 'length', 'unit', + 'section', 'section_source', 'material', 'end_releases', + 'axis_source', 'revit_category', 'mark', 'revit_element_id', + 'identity_description', 'marker_matched', 'unresolved'], + rows) + + +DRAFT_HEADER = """\ +! ===================================================================== +! DRAFT -- NOT A VERIFIED RISA-3D INPUT FILE. DO NOT LOAD AND TRUST. +! ===================================================================== +! +! Written by WillTools lateral extraction from a Revit model. +! +! The section keywords, the record counts, the semicolon terminators +! and the quoted labels below follow RISA's published Appendix D. The +! FIELD ORDER inside each record does NOT: Appendix D names those fields +! only by letter and the table mapping letters to meanings could not be +! obtained. The numbers below are in the right sections and are very +! likely in the wrong columns. +! +! This file is named .r3d.DRAFT.txt so that it cannot be opened into RISA +! by accident. Renaming it to .r3d is a decision, not a convenience. +! +! WHAT IS NOT IN THIS FILE, AT ALL: +! * end releases -- not extractable from these Revit families +! * boundary conditions / supports -- none. This model is unrestrained. +! * loads, load combinations, diaphragms, mass, seismic parameters +! * plates, walls as shells (walls appear as line members only) +! * member offsets, K-node orientation, unbraced lengths, design rules +! +! Use the .json and the two .csv files beside this one. They are complete +! statements of what was extracted; this file is not. +! +! Length unit: {unit} (Revit internal decimal feet, project coordinates) +! Node weld tolerance: {tol} ft +! --------------------------------------------------------------------- +""" + + +def emit_risa_draft(model): + """RISA-3D-shaped text, clearly and repeatedly labelled as a draft.""" + L = [] + L.append(DRAFT_HEADER.format(unit=model['length_unit'], + tol=model['node_tol_ft'])) + L.append('[RISA-3D_INPUT_DATA]') + L.append('') + L.append('[.PROJECT_DESCRIPTION]') + L.append('DRAFT lateral extraction -- field order unverified') + L.append('Model: {0}'.format(model.get('model_title') or '(unnamed)')) + L.append('[.END_PROJECT_DESCRIPTION]') + L.append('') + # [UNITS] is deliberately NOT emitted as data. Its field encoding is the + # part of the format least established, and a units line in the wrong + # order is the most dangerous single line the file could contain -- it + # rescales everything silently. A comment says what the numbers are in. + L.append('! [UNITS] omitted on purpose -- the encoding of this line is') + L.append('! not established, and a wrong units line rescales the whole') + L.append('! model without any visible sign. All lengths below are FEET.') + L.append('') + + L.append('[NODES] <{0}>'.format(len(model['nodes']))) + for n in model['nodes']: + L.append(' "{0}" {1:.6f} {2:.6f} {3:.6f} ;' + .format(n['label'], n['x'], n['y'], n['z'])) + L.append('[END_NODES]') + L.append('') + + L.append('[MEMBERS] <{0}>'.format(len(model['members']))) + for m in model['members']: + sec = m.get('section') or {} + shape = sec.get('name') or UNRESOLVED_SHAPE + mat = m.get('material') or UNRESOLVED_MATERIAL + L.append(' "{0}" "" "{1}" "{2}" "{3}" "{4}" ; ! {5}' + .format(m['label'][:20], shape, mat, + m['i_node'], m['j_node'], + 'unresolved: ' + ', '.join(m['unresolved']) + if m['unresolved'] else 'complete')) + L.append('[END_MEMBERS]') + L.append('') + + L.append('! [BOUNDARY_CONDITIONS] intentionally absent -- no supports') + L.append('! were extracted. Nothing here is restrained.') + L.append('') + L.append('! ---- what was skipped, and why -----------------------------') + if model['skipped']: + for s in model['skipped']: + L.append('! skipped element {0} ({1}, mark {2}): {3}' + .format(s.get('element_id'), s.get('category'), + s.get('mark'), s.get('reason'))) + else: + L.append('! nothing was skipped.') + L.append('') + for note in model['notes']: + L.append('! NOTE: ' + note) + return '\n'.join(L) + '\n' + + +def file_set(model, stem): + """[(filename, text)] -- everything this tool writes, in one place. + + The RISA file is last and its name carries the warning, so that a + directory listing sorted any way still shows the complete outputs first. + """ + return [ + (stem + '-lateral.json', emit_json(model)), + (stem + '-lateral-nodes.csv', emit_nodes_csv(model)), + (stem + '-lateral-members.csv', emit_members_csv(model)), + (stem + '-lateral.r3d.DRAFT.txt', emit_risa_draft(model)), + ] diff --git a/WillTools.extension/lib/vibe/liftexport.py b/WillTools.extension/lib/vibe/liftexport.py new file mode 100644 index 0000000..4d2a367 --- /dev/null +++ b/WillTools.extension/lib/vibe/liftexport.py @@ -0,0 +1,3435 @@ +# -*- coding: utf-8 -*- +"""One assembly, turned into the job file panellift reads. + +Everything Revit-shaped stops here. The calculation runs on CPython with a +test suite that needs no model and no licence, so the boundary between the two +is a JSON file -- and this module is the whole of the Revit side of it. + +Three things are decided here rather than in the calculation, because all +three are facts about the model and none of them is mechanics: + + which end is up the RULE is public knowledge, not this module's: it + comes from the assembly origin (catalogue.yaml, + `orientation.up_end`, resolved by + `knowledge.up_end_rule`). This module reads that rule and + says in the job file which basis answered -- and while + the origin half is unverified, it falls back to global + elevation and names the fallback rather than passing it + off as the rule. Either way x is flipped so that x = 0 is + the bottom of the wall as erected, and every rule about + "the top row" then means what it says. + + where an insert acts the box centre for a face lifter, but the panel end + for an anchor at the top edge -- its modelled box centre + sits inboard by however long the bar welded to it is. + + which insert is what from the plant's lifter_groups, matched on family + name. Nothing here knows a part number. + +Written for IronPython 2.7: no f-strings, no pathlib, no dataclasses. +""" +# pylint: disable=import-error,invalid-name,broad-except +import io +import os + +from vibe import knowledge + +# vibe.ticketqc imports the Revit API at module scope, so it is imported +# inside the functions that need it. That leaves the pure parts of this module +# -- the grouping rules, the stage defaults, the interpreter search -- +# importable and testable on an ordinary CPython with no Revit anywhere. + +SCHEMA = 'panellift/1' + + +# ------------------------------------------------------------------ geometry + +def _axes(lo, hi): + """(length, width, thickness) as indices into a local xyz triple.""" + span = [hi[i] - lo[i] for i in (0, 1, 2)] + thickness = min(range(3), key=lambda i: span[i]) + rest = [i for i in (0, 1, 2) if i != thickness] + rest.sort(key=lambda i: -span[i]) + return rest[0], rest[1], thickness + + +MIN_VERTICAL_SHARE = 0.7 # cos(45 deg) is 0.71; anything less is ambiguous + + +FALLBACK_UP_END_BASIS = 'global elevation' +RULED_UP_END_BASIS = 'assembly origin' + +# The only attribute names `orientation.up_end.origin_up` may name. The value +# is data and is looked up with getattr precisely so a correction is an edit +# to the catalogue rather than a release -- but data reaching getattr on a +# native object is how Revit gets handed something it cannot survive (see +# CLAUDE.md, 2026-08-18), so the set it may reach is closed here. +ORIGIN_BASIS_NAMES = ('BasisX', 'BasisY', 'BasisZ') + + +def _local_shares(inverse, world_vector, axes): + """A world direction expressed in the panel's own (length, width, thickness).""" + L, W, T = axes + local = inverse.OfVector(world_vector) + components = (local.X, local.Y, local.Z) + names = {L: 'length', W: 'width', T: 'thickness'} + best = max((L, W, T), key=lambda a: abs(components[a])) + return components, names, best + + +def erected_orientation(asm, panel, frame, axes, know=None, spans=None): + """Which way up the piece stands, and on which of its own axes. + + Returns (axis, up_is_positive, shares) where axis is 'length', 'width' or + 'thickness', and shares is how much of each panel direction points up + plus, always, `basis` and `basis_why` -- WHERE the answer came from and on + whose authority. That pair is not decoration: this function used to be the + only place in the codebase that knew which end of a piece was up, and it + knew it as an unstated implementation detail no report mentioned. + + WHAT THE OWNER RULED, 2026-08-30. Which end is up comes from the ASSEMBLY + ORIGIN -- he calls it TOT -- and the origin carries an up end. That is + PUBLIC knowledge, so it lives in `knowledge/catalogue.yaml` and is + resolved by `knowledge.up_end_rule()` and `up_end_from_origin()`; this + function is a reader of that rule, not its author. + + HOW THE ORIGIN SAYS IT, measured on 282 assemblies: `GetTransform()` + returns a `Transform` whose `BasisY` is the erected up direction. The + catalogue names the attribute, so correcting it is an edit to a file. + + WHEN THE ORIGIN CANNOT ANSWER -- an origin that does not stand the piece + up, a near-square piece, a panel that is not horizontal -- the origin rule + DECLINES, and this function then reads which end is up the way it always + has: WORLD UP expressed in the panel's own axes, i.e. which end of the + piece is higher in the model. + + THE FALLBACK IS NAMED, EVERY TIME. `basis` says which of the two answered + and `basis_why` says why, and both ride out in the job file's `source` + block on every run. Falling back is not the problem; falling back + silently is, and the two mechanisms are not interchangeable. Global + elevation answers this for a wall panel erected on end and is meaningless + for a piece erected long-ways, where both ends are at the same height and + the winner is whatever the model's rounding happens to be. An origin has a + direction either way -- which is the reason the ruling was made. + + A wall panel is erected on end, so its LENGTH runs up the building and the + top of the piece is an end of it. A horizontal panel -- a spandrel, a + piece that sits long-ways over an opening -- is the same shape erected the + other way round: its WIDTH runs up, and the top is a long EDGE. + + Neither being convincing is a real answer too: a piece that stands none of + its axes up has no erected orientation in it, and guessing one puts the + erection anchors on the wrong edge with nothing in the output to suggest + it. + + `spans` is (length_ft, width_ft) -- the piece's two in-plane dimensions, + which the origin rule needs to refuse a near-square piece. Measured off + the panel's own concrete rather than off the assembly's member boxes, + which is what the bridge holder unioned; the two are close but not the + same reading, and `_axes` has already sorted them long/short. + """ + from Autodesk.Revit.DB import XYZ + L, W, T = axes + know = know or {} + + origin = None + try: + origin = asm.GetTransform() + except Exception: + origin = None + + source, why = knowledge.up_end_rule(know) + basis, up_world = FALLBACK_UP_END_BASIS, XYZ(0, 0, 1) + origin_up_name = None + + # ---- the ruled reading, and the three ways it declines ------------- + if source and origin is not None: + rule = knowledge.get(know, 'orientation.up_end') or {} + origin_up_name = str(rule.get('origin_up') or '') + if origin_up_name not in ORIGIN_BASIS_NAMES: + why = ('orientation.up_end.origin_up is {0!r}, which is not one ' + 'of {1}. Nothing is fetched off the origin by a name this ' + 'code cannot vouch for.' + .format(rule.get('origin_up'), + ', '.join(ORIGIN_BASIS_NAMES))) + else: + candidate = getattr(origin, origin_up_name) + _c, _n, best = _local_shares(frame[0], candidate, axes) + long_ft, short_ft = (spans or (None, None)) + ok, ruled_why = knowledge.up_end_from_origin( + know, (candidate.X, candidate.Y, candidate.Z), _n[best], + long_ft, short_ft, label=_asm_label(asm)) + why = ruled_why + if ok: + basis, up_world = RULED_UP_END_BASIS, candidate + elif source and origin is None: + why = ('This assembly has no origin transform, so the origin rule ' + 'has nothing to read.') + + if basis == FALLBACK_UP_END_BASIS: + why = (why + ' Which end is up was therefore read from GLOBAL ' + 'ELEVATION instead -- the end of the piece that is ' + 'higher in the model. That is a fallback, not the rule.') + + if origin is None: + return None, None, {'error': 'the assembly has no origin transform', + 'basis': basis, 'basis_why': why} + + components, names, best_axis = _local_shares(frame[0], up_world, axes) + shares = {'length': round(components[L], 4), + 'width': round(components[W], 4), + 'thickness': round(components[T], 4), + 'origin_z_world': round(origin.BasisZ.Z, 4), + 'basis': basis, + 'basis_why': why} + if origin_up_name: + shares['origin_up'] = origin_up_name + + value = components[best_axis] + if abs(value) < MIN_VERTICAL_SHARE: + return None, None, shares + return names[best_axis], value > 0.0, shares + + +def _asm_label(asm): + """The assembly's name, for a sentence that has to name a piece.""" + try: + return asm.Name or None + except Exception: + return None + + +MIN_SOLID_FT3 = 0.01 # below this a "solid" is a modelling artefact + + +def panel_solids(panel): + """Every solid the panel body draws, largest first. Revit-side. + + One geometry read, flattened out of the instance geometry exactly the way + `concrete_solid` has always done it -- this IS that walk, kept whole and + made to return the list rather than the maximum of it. + """ + from Autodesk.Revit.DB import Options, ViewDetailLevel, Solid, \ + GeometryInstance + options = Options() + options.DetailLevel = ViewDetailLevel.Fine + + found = [] + stack = [panel.get_Geometry(options)] + while stack: + geometry = stack.pop() + if geometry is None: + continue + for item in geometry: + if isinstance(item, Solid): + try: + volume = item.Volume + except Exception: + continue + if volume > MIN_SOLID_FT3: + found.append(item) + elif isinstance(item, GeometryInstance): + try: + stack.append(item.GetInstanceGeometry()) + except Exception: + pass + found.sort(key=lambda s: -s.Volume) + return found + + +def concrete_solid(panel, solids=None): + """The panel's largest solid -- its SHAPE, and no longer its weight. + + This is what the openings are read off (`_openings`) and what the piece's + extents are solved from, and for both of those the largest solid is the + right object: each is a question about one prism. + + IT IS NOT THE WEIGHT, and it used to be. A 16 in. insulated body draws + more than one solid -- concrete wythes with an insulation layer between + them -- and the largest of those is one wythe. Weighing that alone put + every insulated piece in the job at roughly 65% of its printed weight, + which failed the handling weight gate, so the whole handling analysis + computed NOTHING on 27 pieces. Measured 2026-08-31 on WP115: printed + 18,573 lb, the model's own weight parameter 18,376 lb, largest solid + 11,983 lb -- the two independent statements about the piece agreed with + each other to 1% and the solid sum was the outlier. + + The weight now comes from `concrete_volume`, which sums the solids + instead of picking one, and says which ones it summed. Both take + `solids` so one walk of the geometry answers both questions. + """ + solids = panel_solids(panel) if solids is None else solids + return solids[0] if solids else None + + +# ------------------------------------------------- what the solid sum is OF +# +# 50 pcf is not a plant fact and it is not a guess: it is the gap between the +# two things a precast sandwich panel is made of. Structural concrete is +# 145-150 pcf normalweight and 110+ pcf even lightweight; the rigid +# insulation between the wythes is under 5. Nothing a wall panel is cast from +# lands between them, so a threshold anywhere in the middle separates them +# without knowing a single product name. It is reachable as a knowledge key +# so a plant casting something exotic can move it, and it has a literal +# default here because the alternative to a default is a silent zero. +DEFAULT_CONCRETE_MIN_PCF = 50.0 + + +def _material_facts(doc, material_id): + """One material as {'id', 'name', 'density_pcf'} -- None where unread. + + `density_pcf` None means "this material publishes no density", which is + NOT "this material weighs nothing". Every hop is guarded because a + material carrying no structural asset is ordinary, not an error. + """ + out = {'id': None, 'name': None, 'density_pcf': None} + if material_id is None: + return out + # .IntegerValue is deprecated for .Value in Revit 2024+; read it the way + # the rest of this extension does rather than assuming either. + try: + out['id'] = str(material_id.IntegerValue) + except Exception: + try: + out['id'] = str(material_id.Value) + except Exception: + out['id'] = None + if out['id'] in (None, '-1'): + out['id'] = None + return out + try: + material = doc.GetElement(material_id) + except Exception: + material = None + if material is None: + return out + try: + out['name'] = str(material.Name) + except Exception: + pass + # Convert rather than assume what Revit stores a density in, and import + # the unit BY NAME so a wrong one is a traceback instead of a number 16x + # out -- CLAUDE.md, never guess an API enum. + try: + from Autodesk.Revit.DB import UnitUtils, UnitTypeId + asset = doc.GetElement(material.StructuralAssetId).GetStructuralAsset() + out['density_pcf'] = UnitUtils.ConvertFromInternalUnits( + asset.Density, UnitTypeId.PoundsMassPerCubicFoot) + except Exception: + out['density_pcf'] = None + return out + + +def _solid_material(doc, solid, cache=None): + """What a solid is made of, read off the material on its faces. + + A solid's faces normally all carry one material; where they do not, the + one on the most faces is taken and `mixed` says so -- a body whose faces + disagree is something to report, not something to average. + + `cache` memoises the material read across every face of every solid of + one body. A sandwich body is hundreds of faces and each read is three + element lookups; this is the same shape as the other memos here and it + cannot change an answer, because a hit returns what the lookup would. + """ + cache = {} if cache is None else cache + counts, facts = {}, {} + try: + faces = list(solid.Faces) + except Exception: + faces = [] + for face in faces: + try: + mid = face.MaterialElementId + except Exception: + continue + try: + key = str(mid.IntegerValue) + except Exception: + try: + key = str(mid.Value) + except Exception: + key = None + if key not in cache: + cache[key] = _material_facts(doc, mid) + seen = cache[key] + key = seen['id'] + counts[key] = counts.get(key, 0) + 1 + facts[key] = seen + if not counts: + return {'id': None, 'name': None, 'density_pcf': None, + 'mixed': False} + best = max(sorted(counts, key=lambda k: k or ''), + key=lambda k: counts[k]) + out = dict(facts[best]) + out['mixed'] = len(counts) > 1 + return out + + +def solid_entries(doc, panel, solids=None, frame=None, axis=None): + """Every solid the body draws, with what it is made of. Revit-side. + + Returns [{'volume_ft3', 'material_id', 'material_name', 'density_pcf', + 'mixed_material', 'lo_in', 'hi_in'}], largest first. Which of them are + concrete is `choose_concrete`'s question, and that one is pure. + + `solids` is the already-walked list where the caller has one. The walk + is `panel.get_Geometry` at Fine on an untouched piece -- 30-60 s of the + minute Ticket QC spends per ticket -- so a caller that wants both the + shape solid and the weight pays for it ONCE. + + `frame` and `axis` are what turn a LIST OF VOLUMES into a LAYUP. Volumes + alone say a sandwich exists; they cannot say which solid is the near + wythe, how thick each one is, or how much insulation is between them -- + and without those there is no section to compute, only a refusal. So + where the caller has the panel frame, each solid's own local extents are + measured too. `lo_in` / `hi_in` are (length, width, thickness) triples in + INCHES in the panel's local axes, or None where the read failed; None is + "not measured", never zero. + """ + from vibe import ticketqc + out, cache = [], {} + for ix, solid in enumerate(panel_solids(panel) if solids is None + else solids): + material = _solid_material(doc, solid, cache) + lo_in = hi_in = None + if frame is not None: + try: + box = ticketqc.extents_of_solid(solid, frame) + except Exception: + box = None + if box is not None: + lo_in = [v * 12.0 for v in box[0]] + hi_in = [v * 12.0 for v in box[1]] + out.append({'volume_ft3': solid.Volume, + 'material_id': material['id'], + 'material_name': material['name'], + 'density_pcf': material['density_pcf'], + 'mixed_material': material['mixed'], + 'lo_in': lo_in, + 'hi_in': hi_in, + # This solid's position in the body's solid list, so the + # concrete DECISION (`choose_concrete`, pure) can be mapped + # back to the Solid OBJECTS the reconstruction has to + # rebuild -- equal wythes make equal entry dicts, and only + # the index tells them apart. Internal; never serialised. + '_ix': ix}) + return out + + +# --------------------------------------------------------------- the layup +# +# WHY THE THICKNESS AXIS NEEDS ITS OWN ORIGIN HERE, and it is not pedantry. +# +# `panel.thickness_in` and every insert's `z_in` are measured off +# `ticketqc.concrete_extents`, which takes the LARGEST solid. On a solid panel +# that is the piece. On an insulated one it is ONE WYTHE, so the other wythe +# can sit at a negative z under that origin and the "thickness" is a wythe's +# thickness rather than the envelope's. Whether that is what it comes out as +# on a real insulated piece is UNMEASURED (docs/lift-check.md, "Open"), and +# nothing here guesses: the layup is measured from the lowest face of ANY +# solid the body draws -- the envelope -- and both the origin and the offset +# between the two are written into the job file so the difference is +# arithmetic a reader can do rather than a discrepancy they have to find. +# +# panellift refuses an insulated piece outright, so no number is riding on +# this today. It is written down so that when the refusal is lifted, the +# question is already answered in the file rather than in somebody's head. + +# Which stages "which end is up" actually decides. Stripping and +# transportation are analysed FLAT and are invariant under mirroring x -- +# reverse the piece end for end and the beam problem is its own mirror image, +# so the same peak moment comes out at the mirrored station. Erection is not: +# the erected axis decides which row `exclude_top_rows` drops and which edge +# the anchors sit on. +UP_END_AFFECTS = ('erection -- which row of face lifters the crane cannot ' + 'reach, and which edge the anchors are on') + + +def _solid_parts(concrete, axis): + """`source.solid_parts`, each solid with its place through the thickness. + + Ordered included-then-excluded, as it has been since 2026-08-31, so + anything already reading the list is unaffected; what is new on each + entry is z0_in / z1_in, which are null where the extents could not be + read. Null is "not measured" and is never read as zero downstream. + """ + T = axis[2] if axis else None + + def part(entry, counted): + lo_in, hi_in = entry.get('lo_in'), entry.get('hi_in') + z0 = z1 = None + if T is not None and lo_in is not None and hi_in is not None: + z0, z1 = lo_in[T], hi_in[T] + return {'volume_ft3': round(entry.get('volume_ft3') or 0.0, 4), + 'material': entry.get('material_name'), + 'density_pcf': entry.get('density_pcf'), + 'z0_in': None if z0 is None else round(z0, 4), + 'z1_in': None if z1 is None else round(z1, 4), + 'counted': counted} + + parts = ([part(e, True) for e in concrete['included']] + + [part(e, False) for e in concrete['excluded']]) + # Rebase onto the envelope's own form face, so the layup and the + # per-solid list agree with each other. `concrete_extents`' origin is the + # LARGEST solid's, under which the other wythe of a sandwich sits at a + # negative z; see Z_ORIGIN_NOTE. + floors = [p['z0_in'] for p in parts if p['z0_in'] is not None] + if floors and len(floors) == len(parts): + floor = min(floors) + for p in parts: + p['z0_in'] = round(p['z0_in'] - floor, 4) + p['z1_in'] = round(p['z1_in'] - floor, 4) + return parts + + +def _connectors(know): + """What ties the wythes together -- every field null, with its reason. + + NOTHING HERE IS MEASURED YET and the block is written anyway, because + rule 3 of the file contract is that a gather which could not read + something writes an explicit null AND a reason. A missing `connectors` + key would read downstream as "this piece has no connectors", which on an + insulated panel is the one thing it certainly is not. + + `declared_action` is a PLANT DECLARATION and it is carried as evidence, + not as a ruling. The plant may describe its pins as non-composite; that + is strong evidence about design intent and it is not a statement about + behaviour, because partial composite action exists physically whatever + the label says. It is written into the file so the report can quote it + and stays out of the mechanics. + """ + return { + 'count': None, + 'spacing_in': None, + 'per_row': None, + 'family': None, + 'capacity_shear_lb': None, + 'capacity_tension_lb': None, + 'stiffness_lb_per_in': None, + 'declared_action': knowledge.get( + know, 'handling.connectors.declared_action'), + 'why': ('connector recognition is not wired: nothing here reads the ' + 'connector instances off the piece, and no layer publishes a ' + 'capacity or a slip stiffness for them. Every field above is ' + 'null because it was not read, which is not the same as zero ' + 'and is not a piece with no connectors in it. What would ' + 'unblock each: a recognition rule for the connector family, ' + 'a capacity PER FAILURE MODE (interface shear, and ' + 'normal/tension) in lb, and a slip stiffness k in lb/in.'), + } + + +Z_ORIGIN_NOTE = ( + 'z is measured up from the lowest face of any solid the body draws (the ' + 'envelope form face). WHICH of the two faces of the piece is the form ' + 'face is NOT established -- the local thickness axis is taken as it ' + 'comes, the same convention the strand and insert z_in already use -- so ' + 'a layup that is symmetric top to bottom is the same either way and one ' + 'that is not may be the mirror of what was cast.') + + +def layup_of(entries, axis, know=None): + """The piece through its thickness, as ordered layers. Pure. + + `entries` is `solid_entries` output and `axis` is the (L, W, T) triple + `_axes` returned. Returns a dict with 'layers', 'envelope_thickness_in', + 'z_origin_offset_in', 'measured' and 'why' -- ALWAYS all of them, and + 'measured' False with a written reason wherever the positions could not + be read. A missing key would read downstream as "nothing to check". + + Pure so it is testable on host CPython with no model and no licence, the + same reason `choose_concrete` is. + """ + out = {'layers': [], 'envelope_thickness_in': None, + 'z_origin_offset_in': None, 'measured': False, 'why': '', + 'z_origin': Z_ORIGIN_NOTE} + entries = [e for e in (entries or []) if (e.get('volume_ft3') or 0.0) > 0] + if not entries: + out['why'] = 'the panel body draws no solid, so there is no layup' + return out + if axis is None: + out['why'] = ('the panel frame was not solved, so no solid could be ' + 'placed through the thickness') + return out + + T = axis[2] + placed = [e for e in entries + if e.get('lo_in') is not None and e.get('hi_in') is not None] + if len(placed) != len(entries): + out['why'] = ( + '{0} of the {1} solids this body draws could not be measured ' + 'through the thickness, so the layup is incomplete -- and an ' + 'incomplete layup is not a thinner panel, it is a panel nobody ' + 'measured'.format(len(entries) - len(placed), len(entries))) + return out + + floor = min([e['lo_in'][T] for e in entries]) + ceiling = max([e['hi_in'][T] for e in entries]) + layers = [] + for entry in sorted(entries, key=lambda e: e['lo_in'][T]): + layers.append({'z0_in': round(entry['lo_in'][T] - floor, 4), + 'z1_in': round(entry['hi_in'][T] - floor, 4), + 'material': entry.get('material_name'), + 'density_pcf': entry.get('density_pcf'), + 'volume_ft3': round(entry.get('volume_ft3') or 0.0, 4)}) + out['layers'] = layers + out['envelope_thickness_in'] = round(ceiling - floor, 4) + out['measured'] = True + out['why'] = ('{0} solid(s) placed through the thickness over an envelope ' + 'of {1:.3f} in'.format(len(layers), + out['envelope_thickness_in'])) + return out + + +def _material_label(entry): + name = entry.get('material_name') + if name: + return name + if entry.get('material_id'): + return 'material {0}'.format(entry['material_id']) + return 'no material' + + +def _volume_of(entries): + return sum([(e.get('volume_ft3') or 0.0) for e in entries]) + + +def _sum_sentence(included, excluded, min_pcf): + """What the solid sum included, and what it deliberately left out.""" + def parts(entries): + return ', '.join(['{0:.2f} ft3 of {1}'.format( + e.get('volume_ft3') or 0.0, _material_label(e)) + for e in entries]) + + said = ('summed {0} of the {1} solids the body draws as concrete: {2}' + .format(len(included), len(included) + len(excluded), + parts(included))) + if not excluded: + return said + '. Nothing was left out.' + left = ('. Left out {0:.2f} ft3 -- {1} -- as not concrete' + .format(_volume_of(excluded), parts(excluded))) + if min_pcf is not None: + left += ' (under {0:.0f} pcf)'.format(min_pcf) + return said + left + '.' + + +def choose_concrete(entries, know=None): + """Which of a body's solids are its concrete, and what the sum included. + + PURE -- plain dicts in, plain dict out -- so the decision this repair + turns on is testable on host CPython with no Revit anywhere. + `solid_entries` is the Revit half that feeds it. + + Returns, always with every key present: + + volume_ft3 the concrete, in cubic feet, or None + basis 'one material' | 'material density' | 'material name' + | None + why one sentence. Where volume_ft3 is None it says why not; + where it is a number it says WHAT WENT INTO IT, because + "both wythes, the insulation left out" is a different + claim from a bare number and 2.2 has to be able to make + the difference. + included / excluded the entries on each side + + None is "could not tell". It is never 0.0, and it is never a smaller + answer offered as the whole one -- picking the largest solid and calling + it the piece is precisely what this replaced. + """ + entries = [e for e in (entries or []) + if (e.get('volume_ft3') or 0.0) > 0.0] + out = {'volume_ft3': None, 'basis': None, 'why': '', + 'included': [], 'excluded': []} + if not entries: + out['why'] = ('the panel body draws no solid, so there is no ' + 'concrete to weigh') + return out + + ids = set([e.get('material_id') for e in entries]) + + # ONE material -- which covers a body drawing one solid, and covers a + # body whose solids carry no material at all. Nothing distinguishes + # them, so they are the same stuff and every one of them counts. This is + # the case every solid 8 in. panel in the reference job is in, and it is + # the case the old "largest solid" answer got wrong the moment a body + # drew two. + if len(ids) == 1: + out['included'] = list(entries) + out['volume_ft3'] = _volume_of(entries) + out['basis'] = 'one material' + if len(entries) == 1: + out['why'] = ( + 'the body draws one solid, {0:.2f} ft3 of {1}, and all of it ' + 'is counted'.format(out['volume_ft3'], + _material_label(entries[0]))) + elif entries[0].get('material_id') is None: + out['why'] = ( + '{0} solids totalling {1:.2f} ft3, ALL counted as concrete: ' + 'not one of them carries a readable material, so nothing ' + 'here can tell them apart. If any of them is insulation this ' + 'weight is too heavy by its volume.' + .format(len(entries), out['volume_ft3'])) + else: + out['why'] = ( + '{0} solids totalling {1:.2f} ft3, all of them {2}, and all ' + 'of them counted'.format(len(entries), out['volume_ft3'], + _material_label(entries[0]))) + return out + + # SEVERAL materials. Density tells concrete from insulation without + # anybody having to know a product name. + min_pcf = DEFAULT_CONCRETE_MIN_PCF + if know is not None: + min_pcf = knowledge.get(know, 'materials.concrete_min_pcf', + DEFAULT_CONCRETE_MIN_PCF) + if all([e.get('density_pcf') is not None for e in entries]): + heavy = [e for e in entries if e['density_pcf'] >= min_pcf] + light = [e for e in entries if e['density_pcf'] < min_pcf] + if not heavy: + out['why'] = ( + 'the body draws {0} solids and every one of them is under ' + '{1:.0f} pcf ({2}) -- none of them is concrete, so there is ' + 'nothing here to weigh as concrete' + .format(len(entries), min_pcf, + ', '.join(sorted(set( + ['{0} at {1:.0f} pcf'.format(_material_label(e), + e['density_pcf']) + for e in entries]))))) + return out + out['included'], out['excluded'] = heavy, light + out['volume_ft3'] = _volume_of(heavy) + out['basis'] = 'material density' + out['why'] = _sum_sentence(heavy, light, min_pcf) + return out + + # No density to read. A plant may name its concrete instead; the key + # ships absent, and absent leaves the question unanswered rather than + # answering it "everything". + names = [] + if know is not None: + names = [str(tok).upper() for tok in + (knowledge.get(know, 'materials.concrete_names', []) or []) + if str(tok).strip()] + if names: + unreadable = [e for e in entries if not e.get('material_name')] + if unreadable: + out['why'] = ( + 'materials.concrete_names is declared, but {0} of the {1} ' + 'solids this body draws carry no readable material name, so ' + 'whether they are concrete is unknown -- and a sum that ' + 'quietly leaves them out is a piece missing a wythe' + .format(len(unreadable), len(entries))) + return out + def named_concrete(entry): + up = entry['material_name'].upper() + return any([tok in up for tok in names]) + + # Split by the test, not by membership: two solids of the same + # material are equal dicts, and `in` would put both on whichever + # side the first one landed. + heavy = [e for e in entries if named_concrete(e)] + light = [e for e in entries if not named_concrete(e)] + if not heavy: + out['why'] = ( + 'no solid this body draws is made of anything ' + 'materials.concrete_names lists ({0}); what it is made of is ' + '{1}'.format(', '.join(names), + ', '.join(sorted(set([_material_label(e) + for e in entries]))))) + return out + out['included'], out['excluded'] = heavy, light + out['volume_ft3'] = _volume_of(heavy) + out['basis'] = 'material name' + out['why'] = _sum_sentence(heavy, light, None) + return out + + out['why'] = ( + 'the body draws {0} solids of {1} different materials ({2}) and ' + 'nothing can say which of them is concrete: no material on them ' + 'publishes a density, and no layer sets materials.concrete_names. ' + 'Summing them all would weigh the insulation as concrete; taking the ' + 'largest would drop a wythe, which is the defect this replaced.' + .format(len(entries), len(ids), + ', '.join(sorted(set(['{0} {1:.2f} ft3'.format( + _material_label(e), e.get('volume_ft3') or 0.0) + for e in entries]))))) + return out + + +def concrete_volume(doc, panel, know=None, solids=None, frame=None, + axis=None): + """How much concrete this panel body is, and what that number included. + + The weight half of what `concrete_solid` used to answer alone. Returns + `choose_concrete`'s dict, which carries the sentence as well as the + number -- the number must never travel without it -- plus 'entries', the + whole per-solid breakdown, and 'layup' where the frame was given. + + Pass `solids` when the geometry has already been walked; see + `solid_entries`. Pass `frame` and `axis` to get the layup with it: the + same walk answers the weight and the section, and walking twice would + double the most expensive read in the button. + """ + entries = solid_entries(doc, panel, solids, frame, axis) + out = choose_concrete(entries, know) + out['entries'] = entries + out['layup'] = layup_of(entries, axis, know) + return out + + +def _openings(solid, frame, lo, hi, axes, tol=0.02): + """Voids through the panel, as [x0, x1, y0, y1] in feet, in local axes. + + Taken from the faces of the concrete solid whose normal lies in the plane + of the panel: the sides of a hole. Each hole contributes several such + faces, so they are clustered back into rectangles -- one face on its own + is a line, and four lines are not four blockouts. + + The hard part is telling a hole's side from the OUTSIDE of the piece, + which is a side face too. The test is not "does it reach the edge": + a door running down to the bottom of a panel has jambs that reach the + edge, and dropping them left the head on its own, which clusters into a + line and reads as no opening at all. That is how a piece with a large + opening came back weighing what a solid one would. + + The real distinction is shape. An outer face of a rectangular panel lies + flat IN one of the four bounding planes, so it is degenerate along one + in-plane direction and sits at that direction's extreme. A jamb at some + x strictly inside the piece is not that, however far it runs in y. + """ + L, W, T = axes + if solid is None: + return [] + + inv = frame[0] + length_ft = (hi[L] - lo[L]) / 12.0 + width_ft = (hi[W] - lo[W]) / 12.0 + rects = [] + for face in solid.Faces: + try: + normal = inv.OfVector(face.ComputeNormal(face.GetBoundingBox().Min)) + except Exception: + continue + if abs((normal.X, normal.Y, normal.Z)[T]) > 0.2: + continue # a top or bottom face, not a side + box = None + for loop in face.EdgeLoops: + for edge in loop: + curve = edge.AsCurve() + for end in (curve.GetEndPoint(0), curve.GetEndPoint(1)): + q = inv.OfPoint(end) + v = (q.X * 12.0, q.Y * 12.0, q.Z * 12.0) + box = [list(v), list(v)] if box is None else \ + [[min(box[0][a], v[a]) for a in (0, 1, 2)], + [max(box[1][a], v[a]) for a in (0, 1, 2)]] + if box is None: + continue + xs = sorted([(box[0][L] - lo[L]) / 12.0, (box[1][L] - lo[L]) / 12.0]) + ys = sorted([(box[0][W] - lo[W]) / 12.0, (box[1][W] - lo[W]) / 12.0]) + + flat_in_x = (xs[1] - xs[0]) < tol + flat_in_y = (ys[1] - ys[0]) < tol + at_end = flat_in_x and (xs[0] < tol or xs[1] > length_ft - tol) + at_side = flat_in_y and (ys[0] < tol or ys[1] > width_ft - tol) + if at_end or at_side: + continue # the outside of the piece + if flat_in_x and flat_in_y: + continue # a sliver, not a face of anything + rects.append([xs[0], xs[1], ys[0], ys[1]]) + + return _cluster(rects) + + +def extra_cast_ft3(doc, asm, know): + """Cast concrete inside the assembly that is not the panel's own solid. + + The panel rectangle is not always the whole cast piece. A slab-seat + ledge is modelled as its own family inside the assembly, it is real + concrete, and leaving it out understates every weight downstream: + WP371's ledge carried 8.99 ft3 = 1,349 lb, 7.5% of the piece, which is + how the weight gate started refusing panels whose model was right. + + Which families count is plant knowledge (`families.cast_include`); with + no list the answer is 0.0, not a guess. + """ + return sum([(m['volume_ft3'] or 0.0) + for m in extra_cast_members(doc, asm, know)]) + + +def extra_cast_members(doc, asm, know): + """The members `extra_cast_ft3` sums, one entry each. + + Split out 2026-08-30 for checklist 2.4, which asks a question the total + cannot answer: are the cast extras the ticket DRAWS the same set that + went into the weight? A number cannot be compared against a set, and a + second implementation of the matching rule would drift from the one that + actually fed the weight -- so this is now the single rule and + `extra_cast_ft3` is its sum. + + Returns [{'family', 'mark', 'id', 'volume_ft3'}]. volume_ft3 is None + when the member has no readable Volume: that member added NOTHING to + the weight, which is a finding for 2.4 and must not read as 0.0. + """ + out = [] + include = [str(tok).upper() for tok in + (knowledge.get(know, 'families.cast_include', []) or [])] + if not include or asm is None: + return out + # Solid-zone members are counted by `solid_zone_ft3` on their own concrete; + # they must not ALSO be swept in here, or a plant that named its solid-zone + # family in `families.cast_include` (a substring match -- "SOLID" would do + # it) would weigh that concrete twice. One mechanism owns them, and it is + # not this one. + zone_family = _solid_zone_family(know) + for mid in asm.GetMemberIds(): + member = doc.GetElement(mid) + try: + family = str(member.Symbol.Family.Name) + except Exception: + continue + if zone_family and family.strip().upper() == zone_family: + continue + if not any(tok and tok in family.upper() for tok in include): + continue + volume = None + for p in member.Parameters: + try: + if p.Definition.Name == 'Volume' and p.HasValue: + volume = p.AsDouble() + break + except Exception: + pass + try: + mark = _member_mark(member) + except Exception: + mark = None + # .IntegerValue is deprecated for .Value in Revit 2024+; read it the + # way the rest of this extension does rather than assuming either. + try: + eid = str(member.Id.IntegerValue) + except Exception: + try: + eid = str(member.Id.Value) + except Exception: + eid = None + out.append({'family': family, 'mark': mark, 'id': eid, + 'volume_ft3': volume}) + return out + + +def solid_zone_ft3(doc, asm, know): + """Cast concrete of the assembly's solid-zone members, or 0.0. + + The thickened concrete zones of an insulated (sandwich) panel are modelled + as their OWN family members inside the assembly -- separate Generic Models, + not geometry of the panel's structural-framing element -- so neither the + solid sum nor the reconstruction, both of which walk only the panel body, + ever sees them. On an insulated sandwich that is real cast concrete silently + missing from every weight (WP113: 9.63 ft3, ~7.8% of the piece). + + Which family they are is plant knowledge, read from + `insulation.solid_zone.family`; with no such key (a plant that casts no + solid zones) the answer is 0.0, not a guess, and the weight is unchanged. + Only their CONCRETE is counted -- `_member_concrete_ft3` runs the same + density discipline `concrete_volume` uses, so an all-insulation member + contributes nothing. + """ + return sum([(m['volume_ft3'] or 0.0) + for m in solid_zone_members(doc, asm, know)]) + + +def _solid_zone_family(know): + """The declared solid-zone family in UPPER case, or None. + + One reader for the family name so `solid_zone_members` (which counts them) + and `extra_cast_members` (which must NOT, or a plant that also lists the + family in `families.cast_include` would count them twice) agree on exactly + which members belong to which mechanism. Absent key -> None -> nothing is + a solid zone, and the weight is unchanged. + """ + family = knowledge.get(know, 'insulation.solid_zone.family') + family = str(family).strip() if family is not None else '' + return family.upper() or None + + +def solid_zone_members(doc, asm, know): + """The solid-zone members `solid_zone_ft3` sums, one entry each. + + Returns [{'family', 'mark', 'id', 'volume_ft3'}], `volume_ft3` the member's + CONCRETE only and None where the member draws no concrete solid at all -- + a member that added nothing to the weight, which is a fact to keep and not + a zero to invent, the same rule `extra_cast_members` follows. + + The walk is `asm.GetMemberIds()` filtered to the declared family. Steel + (embeds, lifters, rebar) and everything else is a DIFFERENT family and is + never reached, so the concrete/steel split here is the family filter; the + density split WITHIN a matched member is `_member_concrete_ft3`'s. + """ + out = [] + want = _solid_zone_family(know) + if not want or asm is None: + return out + for mid in asm.GetMemberIds(): + member = doc.GetElement(mid) + try: + fam = str(member.Symbol.Family.Name) + except Exception: + continue + if fam.strip().upper() != want: + continue + try: + mark = _member_mark(member) + except Exception: + mark = None + # .IntegerValue is deprecated for .Value in Revit 2024+; read it the + # way the rest of this extension does rather than assuming either. + try: + eid = str(member.Id.IntegerValue) + except Exception: + try: + eid = str(member.Id.Value) + except Exception: + eid = None + out.append({'family': fam, 'mark': mark, 'id': eid, + 'volume_ft3': _member_concrete_ft3(doc, member, know)}) + return out + + +def _member_concrete_ft3(doc, member, know): + """One member's concrete volume in ft3, or None if it draws no concrete. + + The same density discipline the panel weight uses -- `choose_concrete` over + the member's own solids -- so a member cast entirely of insulation, or one + that draws no solid at all, returns None rather than a volume. Split out so + a host test can feed it entries through a stubbed `solid_entries` and + exercise the real decision with no Revit anywhere. + """ + return choose_concrete(solid_entries(doc, member), know).get('volume_ft3') + + +def _pick_concrete_solids(solids, concrete): + """The Solid OBJECTS `choose_concrete` counted as concrete, body order. + + `concrete['included']` is a subset of the entries `solid_entries` built, + each stamped with `_ix` = its position in `solids` before the choice ran. + Pure: given the list and the decision it returns the solids, and it is the + seam that makes the reconstruction rebuild EVERY concrete wythe rather than + only the largest. + """ + picked = [] + for e in (concrete.get('included') or []): + ix = e.get('_ix') + if ix is not None and 0 <= ix < len(solids): + picked.append(solids[ix]) + return picked + + +def _wythe_gross_ft3(lo_in, hi_in, hole_rects): + """One concrete solid's gross length x width x thickness less its openings. + + PURE. `lo_in`/`hi_in` are the solid's own local (x, y, z) extents in + inches; `hole_rects` is `_openings`' [[x0, x1, y0, y1]] in feet from `lo`. + The thickness axis is the smallest span, exactly as `_axes` picks it, so a + thin wythe is measured across its OWN thickness and not the envelope's. + This is the per-wythe unit the reconstruction sums. + """ + L, W, T = _axes(lo_in, hi_in) + thickness_ft = (hi_in[T] - lo_in[T]) / 12.0 + gross_ft3 = ((hi_in[L] - lo_in[L]) / 12.0) * \ + ((hi_in[W] - lo_in[W]) / 12.0) * thickness_ft + holes_ft3 = sum([(r[1] - r[0]) * (r[3] - r[2]) * thickness_ft + for r in hole_rects]) + return gross_ft3 - holes_ft3 + + +def _reconstructed_ft3(concrete_solids, frame, know=None): + """Sum of every concrete wythe's gross-extents-minus-openings, or None. + + THE WYTHE-AWARE RECONSTRUCTION. The leg's purpose is unchanged -- it is the + self-check that catches a blockout read wrong, gross-from-extents against + the actual solid -- but it now rebuilds EACH concrete solid the body draws + and sums them, where it used to rebuild only the single largest and so + silently dropped every other wythe of a sandwich (WP113: 68.0 ft3 rebuilt + against 102.2 ft3 measured, a 33% gap that refused the piece). + + Returns `(total_ft3, reason)`. `total_ft3` is None whenever `reason` is + set, and never both are None-and-set together. + + `reason` is None on the ordinary path (a wythe's extents could not be + read is still the OLD failure -- `(None, None)` -- so a caller that has + not been updated to read the reason still sees "could not be rebuilt"). + + `reason` is SET only when BOTH of two things are true of one wythe -- + neither alone is enough, and the split matters: + + 1. `_openings` came back with a DEGENERATE rectangle: zero-width in the + length direction or the width direction (`_degenerate_rects`). A + real through-plan hole's several side faces (jambs, head, sill) + always cluster into a rectangle with positive extent in BOTH plan + directions -- that is what let WP100's own real corner notch measure + correctly at 0.139 ft3. A face with no partner to close a rectangle + against is a face `_openings` cannot explain as a hole at all, and + that is the specific, diagnosed shape of a thickness-direction step + or taper (WP100, 2026-09-08, `_scratch/wp100_weight.md`): its backup + wythe steps down in thickness over its last ~0.5 ft, leaving a face + that spans the wythe's FULL width at one length -- a face whose + normal has no thickness-axis component, so `_openings` sees it as a + hole side, but with nothing at any other length to pair against, it + clusters to a zero-width rectangle and silently deducts 0.0 ft3. + 2. That deduction shortfall actually matters: gross-less-known-openings + exceeds the wythe's own `Volume` by more than + `handling.weight_tolerance`. + + Condition 1 alone is not enough to stand the leg down -- a stray + degenerate rect that costs nothing is not worth reporting. Condition 2 + alone is NOT enough either, and this is the reason both are checked: + the classic missed-hole failure this leg exists to catch (a jamb + dropped as the panel's own outside edge) also makes gross exceed the + actual volume, but it leaves NO trace in `rects` at all -- nothing + degenerate, nothing bounded, the hole is simply invisible to + `_openings`. That case must keep reading as a wrong reconstruction + (WARN, "suspect the tool first") rather than being waved through as + "not applicable"; only a wythe that shows the specific unpaired-face + signature stands down. + + The caller stands the RECONSTRUCTION down as not applicable rather than + reporting a confidently wrong number; it does not stand down the + measured leg, which never used this function at all. + """ + from vibe import ticketqc + tolerance = knowledge.get(know, 'handling.weight_tolerance', 0.02) or 0.02 + total = 0.0 + for ix, csolid in enumerate(concrete_solids): + extents = ticketqc.extents_of_solid(csolid, frame) + if extents is None: + return None, None + lo = [v * 12.0 for v in extents[0]] + hi = [v * 12.0 for v in extents[1]] + L, W, T = _axes(lo, hi) + rects = _openings(csolid, frame, lo, hi, (L, W, T)) + gross = _wythe_gross_ft3(lo, hi, rects) + degenerate = _degenerate_rects(rects) + if degenerate: + try: + actual = csolid.Volume + except Exception: + actual = None + if actual is not None and actual > 0: + excess = gross - actual + if (excess / actual) > tolerance: + return None, ( + 'wythe {0} of {1} (its own solid measures {2:,.2f} ' + 'ft3) does not reconcile with the reconstruction, ' + 'which reads {3:,.2f} ft3 for the same wythe ' + '(length x width x thickness less the {4} plan-view ' + 'opening(s) found) -- {5:,.2f} ft3 unaccounted for, ' + 'and {6} face(s) on this wythe never closed into a ' + 'plan-view opening at all: a stepped or tapered ' + 'wythe, which the reconstruction assumes is a ' + 'constant thickness'.format( + ix + 1, len(concrete_solids), actual, gross, + len(rects), excess, len(degenerate))) + total += gross + return total, None + + +def _degenerate_rects(rects, tol=0.02): + """Which of `_openings`' rectangles never closed into a real 2D hole. + + PURE. A genuine through-plan opening's side faces (jambs, head, sill) + cluster into a rectangle with positive extent in BOTH plan directions -- + `_cluster` merges whatever touches, and two perpendicular jamb faces + meeting at an interior corner do touch. A face with no partner anywhere + near it -- the signature of a thickness-direction step or taper, which + `_openings` has no representation for at all -- has nothing to cluster + against and stays zero-width in one direction. `_wythe_gross_ft3` + already silently deducts 0.0 ft3 for a rect like that; this is what + lets `_reconstructed_ft3` notice it happened instead of only feeling + its effect as an unexplained gap against the solid's own Volume. + """ + return [r for r in rects if (r[1] - r[0]) <= tol or (r[3] - r[2]) <= tol] + + +def _member_mark(member): + """The instance's Mark, when it has one. Best-effort naming only: 2.4 + says WHAT and WHERE, and an element id alone is a poor 'where'.""" + try: + from Autodesk.Revit.DB import BuiltInParameter + p = member.get_Parameter(BuiltInParameter.ALL_MODEL_MARK) + if p is not None and p.HasValue: + value = p.AsString() + return value.strip() if value else None + except Exception: + pass + return None + + +def weight_volumes(doc, asm, panel, know): + """The two GEOMETRY-derived weights of checklist 2.2, as volumes. + + Returns a dict with 'solid_ft3' (the modelled concrete as it was + actually drawn: every concrete solid the body draws, plus the cast + extras, plus any solid-zone members), 'reconstructed_ft3' (each concrete + wythe's own length x width x thickness less its blockouts, summed, plus + the same extras and zones -- the piece as the drawing describes it), + 'extra_cast_ft3', 'solid_zone_ft3', 'solid_basis', 'solid_note', and + 'reason'. + + 'reason' is a sentence and it is the point of the function: when a + number could not be had, the caller has to be able to SAY why rather + than quietly compare the two it still has. Both volumes are None + whenever reason is set. + + 'reconstruction_reason' is the narrower version of the same discipline: + it is set, with 'reconstructed_ft3' left None and 'reason' left None, + when the MEASURED leg is fine but a wythe fails its own sanity check in + `_reconstructed_ft3` (a stepped or tapered wythe the plan-projection + method cannot represent) -- the reconstruction alone stands down, not + the whole check, because 'solid_ft3' never depended on it. + + 'solid_note' is the other half of the same discipline and is set + whenever solid_ft3 is a number: it says WHICH SOLIDS went into it -- + "both wythes, the insulation left out at 2 pcf" is a different claim + from a bare 18,500 lb. See `choose_concrete`. + + Both legs are wythe-aware. A 16 in. sandwich body draws several concrete + solids; the reconstruction used to rebuild only the largest (one wythe) + and so came out at ~65% of the piece, refusing every insulated piece at + the weight gate on a number the gate was right to reject. It now rebuilds + every concrete solid and sums them, which is `reconstructed_ft3`'s parity + with the measured `solid_ft3` -- and both include the solid-zone concrete + the body draws OUTSIDE the panel element (`solid_zone_ft3`). + + Same definitions as `weight_check` above, deliberately -- this is the + ticket-report path asking the handling gate's own question -- but with + no erected orientation solved, because which way the piece stands up + does not change what it weighs. + """ + from vibe import ticketqc + + def stood_down(reason, extras=None, zone=None): + return {'solid_ft3': None, 'reconstructed_ft3': None, + 'extra_cast_ft3': extras, 'solid_zone_ft3': zone, + 'solid_basis': None, 'solid_note': None, 'reason': reason, + 'reconstruction_reason': None} + + if panel is None: + return stood_down('this assembly has no structural framing member, ' + 'so there is no concrete solid to weigh') + solids = panel_solids(panel) + if not solids: + return stood_down("the panel's concrete solid could not be read " + 'out of its geometry') + # ONE geometry walk, both questions off it: `choose_concrete` decides which + # solids are the concrete (the measured leg) and `_pick_concrete_solids` + # hands those same solids back so the reconstruction rebuilds every one. + concrete = concrete_volume(doc, panel, know, solids) + extras_ft3 = extra_cast_ft3(doc, asm, know) + # Concrete the body draws OUTSIDE the panel element -- the thickened + # solid-zone members of an insulated sandwich. 0.0 where no plant names + # the family, so a piece without them is unchanged. Counted here on BOTH + # legs so they stay in parity. + zone_ft3 = solid_zone_ft3(doc, asm, know) + if concrete['volume_ft3'] is None: + # A body whose concrete cannot be identified has no measured weight + # AND no meaningful reconstruction -- the extents the reconstruction + # rebuilds from are one solid's, and on a body of several that is one + # part of the piece. Standing both down with the sentence is the + # honest answer; reporting the half we still have would be 2.2 + # grading a piece on a number nobody can say the meaning of. + return stood_down(concrete['why'], extras_ft3, zone_ft3) + frame = ticketqc._panel_frame(panel) + concrete_solids = _pick_concrete_solids(solids, concrete) + reconstructed, recon_reason = _reconstructed_ft3( + concrete_solids, frame, know) + if not concrete_solids or (reconstructed is None and + recon_reason is None): + return stood_down("the panel's own extents could not be solved, so " + 'length x width x depth could not be rebuilt', + extras_ft3, zone_ft3) + if reconstructed is None: + # The wythe geometry read fine, but at least one wythe failed its + # own sanity check (see _reconstructed_ft3) -- a stepped or tapered + # wythe the plan-projection method cannot represent. The MEASURED + # leg never went through that method at all, so it is reported as + # usual; only the reconstruction stands down, named and why. + return { + 'solid_ft3': concrete['volume_ft3'] + extras_ft3 + zone_ft3, + 'reconstructed_ft3': None, + 'extra_cast_ft3': extras_ft3, + 'solid_zone_ft3': zone_ft3, + 'solid_basis': concrete['basis'], + 'solid_note': concrete['why'], + 'reason': None, + 'reconstruction_reason': recon_reason, + } + return { + 'solid_ft3': concrete['volume_ft3'] + extras_ft3 + zone_ft3, + 'reconstructed_ft3': reconstructed + extras_ft3 + zone_ft3, + 'extra_cast_ft3': extras_ft3, + 'solid_zone_ft3': zone_ft3, + 'solid_basis': concrete['basis'], + 'solid_note': concrete['why'], + 'reason': None, + 'reconstruction_reason': None, + } + + +def opening_removed_ft3(solid, frame, rect, axes, lo, hi, errors=None): + """Concrete actually REMOVED inside one opening's footprint, in cubic feet. + + Builds the rect's column -- its footprint extruded through the full panel + thickness in the piece's own frame -- and intersects it with the concrete + solid (an in-memory boolean; nothing is written to the model). Removed = + column volume - (solid ∩ column) volume. This measures a sloped, stepped or + far-face recess that a single centre-line probe reads as solid concrete. + `rect` is (x0, x1, y0, y1) in FEET from lo; `lo`/`hi` in INCHES; `axes` is + (L, W, T) as `_axes` returns. A degenerate rect (zero width or zero + length) returns 0.0 -- no footprint, nothing removed, and a clean number + rather than an unexplained None. None when the geometry could not be + built; pass `errors=[]` and each failure appends ' -> '. + """ + x0, x1, y0, y1 = rect + if (x1 - x0) <= 1e-6 or (y1 - y0) <= 1e-6: + # Zero-area footprint: no concrete inside it, so nothing is removed. + # A degenerate rect is not a geometry failure -- 0.0, not an + # unexplained None. + return 0.0 + try: + from Autodesk.Revit.DB import (XYZ, Line, CurveLoop, + GeometryCreationUtilities, + BooleanOperationsUtils, + BooleanOperationsType) + from System.Collections.Generic import List + + L, W, T = axes + fwd = frame[0].Inverse + pts = [] + for (px, py) in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)): + a = [0.0, 0.0, 0.0] + a[L] = lo[L] / 12.0 + px + a[W] = lo[W] / 12.0 + py + a[T] = lo[T] / 12.0 - 0.01 + pts.append(fwd.OfPoint(XYZ(a[0], a[1], a[2]))) + p0, p1, p2, p3 = pts + loop = CurveLoop() + loop.Append(Line.CreateBound(p0, p1)) + loop.Append(Line.CreateBound(p1, p2)) + loop.Append(Line.CreateBound(p2, p3)) + loop.Append(Line.CreateBound(p3, p0)) + d = [0.0, 0.0, 0.0] + d[T] = 1.0 + direction = fwd.OfVector(XYZ(d[0], d[1], d[2])) + distance = (hi[T] - lo[T]) / 12.0 + 0.02 + column = GeometryCreationUtilities.CreateExtrusionGeometry( + List[CurveLoop]([loop]), direction, distance) + inter = BooleanOperationsUtils.ExecuteBooleanOperation( + solid, column, BooleanOperationsType.Intersect) + removed = column.Volume - (inter.Volume if inter is not None else 0.0) + return round(max(removed, 0.0), 4) + except Exception as exc: + if errors is not None: + errors.append('({0}, {1}, {2}, {3}) -> {4}'.format( + round(x0, 4), round(x1, 4), round(y0, 4), round(y1, 4), + exc)) + return None + + +def section_profile(solids, frame, axes, lo, hi, n=7): + """The piece's thickness profile, MEASURED: a line along the piece's own + THICKNESS axis at each of an (n-1) x (n-1) grid of interior points, + intersected with every solid of the body; the most common non-empty + profile wins. A line through a window hits nothing and does not vote. + + Why not the solids' extents: an outer wythe drawn with edge returns + spans the whole thickness in its bounding box, so a sandwich reads as + one layer that way (measured 2026-09-03 on two insulated pieces whose + layup said 0-11 / 0-7 in; the line probe says 0-3 and 7-11). + + lo, hi -- extents in INCHES in the piece's frame (union over solids). + Returns (profile, hits, samples): profile = [(z0_in, z1_in, solid_index)] + sorted by z0, inches measured from lo[T]; hits = how many sample lines + voted for it; samples = lines shot. profile is [] when nothing hit. + """ + from Autodesk.Revit.DB import XYZ, Line, SolidCurveIntersectionOptions + L, W, T = axes + inv = frame[0] + fwd = inv.Inverse + counts = {} + samples = 0 + for i in range(1, n): + for j in range(1, n): + a = [0.0, 0.0, 0.0] + b = [0.0, 0.0, 0.0] + a[L] = b[L] = (lo[L] + (hi[L] - lo[L]) * i / float(n)) / 12.0 + a[W] = b[W] = (lo[W] + (hi[W] - lo[W]) * j / float(n)) / 12.0 + a[T] = lo[T] / 12.0 - 0.5 # start below the piece + b[T] = hi[T] / 12.0 + 0.5 # end above it + try: + line = Line.CreateBound(fwd.OfPoint(XYZ(a[0], a[1], a[2])), + fwd.OfPoint(XYZ(b[0], b[1], b[2]))) + except Exception: + continue + samples += 1 + ivs = [] + for si, s in enumerate(solids): + try: + res = s.IntersectWithCurve( + line, SolidCurveIntersectionOptions()) + for k in range(res.SegmentCount): + seg = res.GetCurveSegment(k) + z0 = inv.OfPoint(seg.GetEndPoint(0))[T] * 12.0 - lo[T] + z1 = inv.OfPoint(seg.GetEndPoint(1))[T] * 12.0 - lo[T] + ivs.append((round(min(z0, z1), 2), + round(max(z0, z1), 2), si)) + except Exception: + continue + if not ivs: + continue + ivs.sort() + key = tuple(ivs) + counts[key] = counts.get(key, 0) + 1 + if not counts: + return [], 0, samples + best = max(counts.items(), key=lambda kv: kv[1]) + return list(best[0]), best[1], samples + + +def section_from_profile(profile, min_gap_in=0.5): + """Solid or Sandwich, from a thickness profile (pure; host-testable). + + Intervals that touch or overlap (gap under min_gap_in) merge into one + layer -- one pour drawn as several solids, a ledge, a thickened zone. + The largest gap of min_gap_in or more between merged layers is the + insulation: two concrete masses half an inch apart are not one pour. + Nearest the envelope floor (the smaller z) is the TOP wythe in the form. + + Returns {} (Solid, or could not tell -- the caller's default stands, + never a fabricated section) or {'section_type': 'Sandwich', + 'wythe_top_in', 'insulation_in', 'wythe_bottom_in'} in inches. + """ + layers = [] + try: + ordered = sorted(profile or []) + except Exception: # CPython 3 refuses to order mixed types + return {} + for iv in ordered: + try: + z0, z1 = float(iv[0]), float(iv[1]) + except Exception: + return {} + if z1 - z0 <= 0.0: + continue + if layers and z0 - layers[-1][1] < min_gap_in: + layers[-1][1] = max(layers[-1][1], z1) + else: + layers.append([z0, z1]) + if len(layers) < 2: + return {} + gaps = [layers[i + 1][0] - layers[i][1] for i in range(len(layers) - 1)] + largest = max(gaps) + if largest < min_gap_in: + return {} + split = gaps.index(largest) + top = sum(z1 - z0 for z0, z1 in layers[:split + 1]) + bottom = sum(z1 - z0 for z0, z1 in layers[split + 1:]) + if top <= 0.0 or bottom <= 0.0: + return {} + return {'section_type': 'Sandwich', + 'wythe_top_in': round(top, 3), + 'insulation_in': round(largest, 2), + 'wythe_bottom_in': round(bottom, 3)} + + +def opening_penetration(panel, frame, solid, rects, axes, lo, hi): + """Is each opening through the thickness, or a blind recess? + + The dunnage judgement is torque, not bearing: a support across a + through-thickness void is the case that matters, and a shallow recess + is not. Through means 'the concrete does not exist where the opening + is' -- answered per opening by shooting a line along the thickness + axis through the opening's centre and intersecting it with the solid. + + rects -- [(x0, x1, y0, y1)] in FEET in the piece's frame (as produced + by _openings). + lo, hi -- concrete extents in INCHES (the (lo, hi) used by _openings). + + Returns a list aligned with rects: + {'through': bool, 'concrete_in': inches of concrete along the + centreline, 'x0': , 'x1': , 'y0': , 'y1': } + + Measured on the detached model: WP199/WP213/WP200 OPENING 1 are + through (0.0 in of concrete), WP210's two openings and every 8-inch + corner pocket are blind recesses (3.5 in of concrete in an 8 in + panel) -- exactly the batch the plant knowledge asks for. + """ + from Autodesk.Revit.DB import (XYZ, Line, SolidCurveIntersectionOptions) + + L, W, T = axes + inv = frame[0] + fwd = inv.Inverse + out = [] + for (x0, x1, y0, y1) in rects: + cx = (x0 + x1) / 2.0 # feet along length, from lo[L] + cy = (y0 + y1) / 2.0 # feet across width, from lo[W] + a = [0.0, 0.0, 0.0] + b = [0.0, 0.0, 0.0] + a[L] = lo[L] / 12.0 + cx + b[L] = a[L] + a[W] = lo[W] / 12.0 + cy + b[W] = a[W] + a[T] = lo[T] / 12.0 - 0.5 # start below the panel + b[T] = hi[T] / 12.0 + 0.5 # end above it + line = Line.CreateBound(fwd.OfPoint(XYZ(a[0], a[1], a[2])), + fwd.OfPoint(XYZ(b[0], b[1], b[2]))) + concrete_in = 0.0 + try: + res = solid.IntersectWithCurve( + line, SolidCurveIntersectionOptions()) + for k in range(res.SegmentCount): + concrete_in += res.GetCurveSegment(k).Length * 12.0 + except Exception: + pass + removed_errors = [] + removed = opening_removed_ft3(solid, frame, (x0, x1, y0, y1), + axes, lo, hi, removed_errors) + out.append({ + 'through': concrete_in < 0.05, + 'concrete_in': round(concrete_in, 2), + 'removed_ft3': removed, + 'removed_err': removed_errors[0] if removed_errors else None, + 'x0': round(x0, 3), 'x1': round(x1, 3), + 'y0': round(y0, 3), 'y1': round(y1, 3), + }) + return out + + +def split_openings(rects, penetration, to_x, to_y, lo, axes, thickness_in): + """The openings, split into through voids and blind recesses. + + Until 2026-09-01 every rectangle `_openings` returned went into the job + file's `blockouts`, and `panellift` reads a blockout as a void through the + thickness: it takes the width out of the section, CUTS every strand that + passes over it, and restarts development at its edges. A blind recess does + none of that -- the concrete and the strand are both still there. The test + for which is which has existed here since `opening_penetration` landed and + simply was not wired into the export, so an 8-inch corner pocket with 3.5 + in of concrete behind it arrived on the far side as a full-depth hole. + That is what produced the last genuine-looking capacity FAIL on the + reference sweep, on a piece whose model has no opening in it at all. + + rects -- [[x0, x1, y0, y1]] in FEET from `lo`, as `_openings` + returns them. + penetration -- `opening_penetration`'s list, ALIGNED with rects. Short or + empty is allowed: the solve can fail on its own. + to_x, to_y -- the export's own converters into erected job coordinates. + lo -- concrete extents in INCHES. + axes -- (L, W, T). + thickness_in -- the piece's thickness, or None. + + Returns (blockouts, recesses, unjudged): + + blockouts the through ones, in the shape the job file has always had. + recesses the blind ones -- same rect fields, plus `concrete_in` (what + is left along the centreline) and `depth_in` where the + thickness is known. + unjudged how many rectangles had no penetration answer. Those stay in + `blockouts`, because "could not tell" is not "blind": the + through reading is the one that takes section away, it is + what this file emitted before, and standing it down on a + failed solve would quietly make a piece pass. The count is + returned so the caller can SAY so rather than only do it. + """ + L, W = axes[0], axes[1] + blockouts = [] + recesses = [] + unjudged = 0 + for i, rect in enumerate(rects): + x0, x1 = sorted([to_x(lo[L] + rect[0] * 12.0), + to_x(lo[L] + rect[1] * 12.0)]) + y0, y1 = sorted([to_y(lo[W] + rect[2] * 12.0), + to_y(lo[W] + rect[3] * 12.0)]) + entry = {'x0': round(x0, 4), 'y0': round(y0, 4), + 'length': round(x1 - x0, 4), + 'width': round(y1 - y0, 4)} + pen = penetration[i] if i < len(penetration) else None + through = pen.get('through') if pen else None + if through is None: + unjudged += 1 + if through is False: + concrete_in = pen.get('concrete_in') + entry['name'] = 'RECESS {0}'.format(len(recesses) + 1) + entry['concrete_in'] = concrete_in + entry['depth_in'] = ( + None if (concrete_in is None or thickness_in is None) + else round(thickness_in - concrete_in, 3)) + entry['removed_ft3'] = pen.get('removed_ft3') if pen else None + entry['removed_err'] = pen.get('removed_err') if pen else None + recesses.append(entry) + else: + entry['name'] = 'OPENING {0}'.format(len(blockouts) + 1) + entry['removed_ft3'] = pen.get('removed_ft3') if pen else None + entry['removed_err'] = pen.get('removed_err') if pen else None + blockouts.append(entry) + return blockouts, recesses, unjudged + + +def _cluster(rects, tol=0.02): + """Merge rectangles that touch into one rectangle each.""" + groups = [] + for rect in rects: + joined = None + for group in list(groups): + if _touches(rect, group, tol): + if joined is None: + _absorb(group, rect) + joined = group + else: # this rect bridges two groups + _absorb(joined, group) + groups.remove(group) + if joined is None: + groups.append(list(rect)) + return groups + + +def _touches(a, b, tol): + return (a[0] <= b[1] + tol and b[0] <= a[1] + tol and + a[2] <= b[3] + tol and b[2] <= a[3] + tol) + + +def _absorb(group, rect): + group[0] = min(group[0], rect[0]) + group[1] = max(group[1], rect[1]) + group[2] = min(group[2], rect[2]) + group[3] = max(group[3], rect[3]) + + +# ------------------------------------------------------------------- lifters + +def strand_area_in2(know, dia_in): + """Section area for a nominal strand diameter, or None if unlisted.""" + table = knowledge.get(know, 'strands.area_in2', {}) or {} + for key, area in table.items(): + try: + if abs(float(key) - float(dia_in)) < 1e-6: + return float(area) + except (TypeError, ValueError): + continue + return None + + +def _fast_box(know, batch): + """Whether an insert's centre comes off its bounding box or its geometry. + + Two keys, not one, because the two run modes want opposite answers: a + batch runs unattended and should be right, one piece at a time is + somebody waiting. See handling.fast_lifter_box in knowledge.DEFAULTS. + """ + key = ('handling.fast_lifter_box_batch' if batch + else 'handling.fast_lifter_box') + return bool(knowledge.get(know, key, not batch)) + + +def strand_count_expectation(qty, columns_in, width_ft, max_width_ft): + """How many strands THIS piece should carry, given what the design + summary says. Pure; host-tested. + + A design summary is drawn at the design's MAXIMUM width and its strand + count is for that width; a narrower piece of the same design is a + cut-down piece and carries only the strands that fall inside it + (owner, 2026-09-03: "FSA 摘要写了最大宽度 13'-8". 这玩意儿只有 11 ft"). So: + + * a strand layout (`columns_in`, positions across the piece from the + datum) -- the expected count is the columns inside the piece's + width, and that is compared exactly; + * no layout, and the piece is narrower than the design's maximum + width by more than an inch -- the summary's count does not apply; + the model's strands are taken as they are, and the sentence says + why; + * otherwise the summary's count applies as it is. + + Returns (expected_count or None, note or None). + """ + from vibe import ticketqc + try: + qty = int(qty) if qty is not None else None + except Exception: + qty = None + if qty is None: + return None, None + cols = [c for c in (columns_in or []) if isinstance(c, (int, float))] + if cols and width_ft: + inside = [c for c in cols if c <= width_ft * 12.0 + 0.5] + return len(inside), None + if width_ft and max_width_ft and width_ft < max_width_ft - 1.0 / 12.0: + return None, ( + 'the design summary\'s {0} strands are for its maximum width of ' + '{1}; this piece is {2} wide -- a cut-down piece -- so its ' + 'modelled strands are taken as they are'.format( + qty, ticketqc.fmt_feet(max_width_ft), + ticketqc.fmt_feet(width_ft))) + return qty, None + + +def read_prestress(doc, asm, panel, frame, know, to_y, lo, W, T, design, + fast=True, width_ft=None): + """The strands on this piece, with the pull the design put in them. + + Two sources, on purpose, because neither one alone is the truth: + + WHERE they are comes from the model. It is this piece, and a blockout + cuts the strands it actually passes over -- which strands those are is + a fact about geometry and nothing else. + + HOW HARD they were pulled comes from the design summary, where it is + written as an I.T. figure per strand. Nothing in the model carries it. + + Returns (block, problems). `block` is None when the piece has no strands, + which is a real answer. It is also None when the piece HAS strands and + the pull could not be established -- but then problems is non-empty, and + the caller refuses rather than checking a prestressed panel as though it + were reinforced. That mistake is worth 350 psi in the direction that + fails a piece which is fine. + """ + from vibe import designspec, ticketqc + problems = [] + boxes = ticketqc.member_boxes(doc, asm, panel, frame) + found = [] + for entry in boxes: + if not knowledge.is_strand(know, entry['family']): + continue + if not fast: + ticketqc.refine_box(entry, frame) + centre = [(entry['lo'][i] + entry['hi'][i]) / 2.0 for i in (0, 1, 2)] + found.append({ + 'mark': str(entry['id']), + 'family': entry['family'], + 'y': round(to_y(centre[W]), 4), + # Up from the form face. The form face is the low end of the + # thickness axis: a panel is cast face down on the bed. + 'z_in': round(centre[T] - lo[T], 3), + }) + if not found: + return None, problems + + spec = designspec.load_spec(doc, design) if design else None + groups = designspec.get_path(spec or {}, 'reinforcing.strand_groups', + []) or [] + pulled = [g for g in groups if g.get('it_lb')] + if not design: + # The caller did not say which design this piece belongs to, so the + # summary could not even be looked up. That is a different failure + # from 'the summary exists but has no tension': this one is fixed by + # passing design=, not by running Extract Design -- which is what the + # old message sent the user to do, and it changed nothing. + problems.append( + 'no design number was given for this piece, so its prestress ' + 'could not be looked up.\n\nPass the design (design=...) -- the ' + 'summary lookup needs it to find the strand tension.') + return None, problems + if not pulled: + problems.append( + '{0} strands are modelled on this piece but the design summary ' + 'for {1} gave no initial tension, so there is no prestress to ' + 'credit.\n\nRun Extract Design on {1} first. Checking a ' + 'pretensioned panel with the strands left out overstates every ' + 'handling stress by the whole of P/A -- on one real piece that ' + 'was 350 psi against a limit of 444, and it turned a panel that ' + 'passes into one that fails.' + .format(len(found), design or 'this design')) + return None, problems + if len(pulled) > 1: + problems.append( + 'the design summary for {0} lists {1} strand groups pulled to ' + 'different forces ({2}). Which modelled strand belongs to which ' + 'group is not in the model, and guessing would put the wrong ' + 'force on the wrong line.' + .format(design, len(pulled), + ', '.join('{0:,.0f} lb'.format(float(g['it_lb'])) + for g in pulled))) + return None, problems + + group = pulled[0] + dia = group.get('dia_in') + area = strand_area_in2(know, dia) if dia else None + if area is None: + problems.append( + 'no section area known for a {0}" strand; add it to ' + 'strands.area_in2.'.format(dia)) + return None, problems + expected, note = strand_count_expectation( + group.get('qty'), + designspec.get_path(spec or {}, 'reinforcing.strand_layout.columns_in', + []) or [], + width_ft, + designspec.get_path(spec or {}, 'envelope.width.max_ft', None)) + if note: + problems.append(note) + elif expected is not None and expected != len(found): + problems.append( + 'the design summary for {0} calls for {1} strands and {2} are ' + 'modelled on this piece'.format(design, expected, len(found))) + + for strand in found: + strand['area_in2'] = area + strand['initial_pull_lb'] = float(group['it_lb']) + found.sort(key=lambda s: (s['z_in'], s['y'])) + return { + 'loss_pct': knowledge.get(know, 'handling.prestress_loss_pct', 8.0), + 'diameter_in': float(dia), + 'transfer_length_in': knowledge.get(know, + 'handling.transfer_length_in'), + 'strands': found, + }, problems + + +def _group_of(know, family): + """Which lifter group a family belongs to, longest match wins.""" + groups = knowledge.get(know, 'handling.lifter_groups', {}) or {} + best, best_len = None, -1 + upper = (family or '').upper() + for name, spec in groups.items(): + for pattern in (spec or {}).get('families', []) or []: + token = str(pattern).upper() + if token and token in upper and len(token) > best_len: + best, best_len = name, len(token) + return best + + +def _group_spec(know, group_name): + return (knowledge.get(know, 'handling.lifter_groups', {}) or {}) \ + .get(group_name) or {} + + +def snap_to_edge(where, vertical_axis, x_ft, y_ft, length_ft, width_ft): + """Move an insert onto the edge of the piece it really acts at. + + An anchor cast into an edge acts AT that edge, but its modelled box centre + sits inboard by however long the bar welded to it is -- seven inches on + one real piece, which is not a rounding error on an erection pick. + + Which edge depends on how the piece stands up: + + vertical panel the top is an END. Snap x, leave y. + horizontal panel the top is a long EDGE. Snap y, leave x -- the + anchors are spread ALONG the length there, and + where each one sits along it is a real choice. + + Returns (x, y, fixed_x, fixed_y). + """ + if where not in ('top', 'bottom'): + return x_ft, y_ft, False, False + at_max = (where == 'top') + if vertical_axis == 'width': + return x_ft, (width_ft if at_max else 0.0), False, True + return (length_ft if at_max else 0.0), y_ft, True, False + + +# ------------------------------------------------------- the cardinal sin +# +# `families.lifter_include` matching ZERO families on a panel is this +# project's own named cardinal sin (CLAUDE.md, error-pattern record) and it +# has bitten twice already: the shipped defaults matching no real family, and +# P78 on insulated panels, where the part the owner named was in no knowledge +# layer at all. Both times the check did not refuse -- it degraded, quietly, +# into a report with nothing to say no. `export()` must never do that a +# third time: a panel with zero recognised lifters is NOT JUDGED, not a job +# file with `panel.lifters: []` masquerading as a clean read. +# +# `no_lifters_message` is pure -- plain values in, one string out -- so the +# three-state wording is tested without Revit. `_lifter_include_provenance` +# is the Revit-shaped half that gathers those values; it is not tested here, +# the same as every other doc-shaped helper in this module (see +# tests/test_liftexport.py's own docstring). + +def _lifter_include_provenance(doc, know): + """Which layer, if any, sets families.lifter_include -- and the plant + file that is the right place for a plant to make that declaration. + + Returns (include, declared_by, plant_path): + + include the resolved list this run actually matched against + (knowledge.get(know, 'families.lifter_include', [])). + declared_by layer labels ('catalogue', 'plant', 'job', 'config') whose + OWN file names the key, in merge order -- never + 'defaults', because the code's built-in fallback is not a + plant declaring anything. Empty means no real layer names + it. None means the layers could not be re-read to tell + (the refusal still fires; only the provenance sentence is + poorer). + plant_path the plant knowledge file this job resolves to, whether or + not that file exists yet, so the refusal can name where + the declaration belongs. + """ + include = knowledge.get(know, 'families.lifter_include', []) or [] + declared_by, plant_path = [], None + try: + from vibe import configdump + for label, path, exists, data in configdump.layer_values( + doc, include_config=True): + if label == 'plant': + plant_path = path + if label == 'defaults' or not exists: + continue + node, found = data, True + for part in ('families', 'lifter_include'): + if not isinstance(node, dict) or part not in node: + found = False + break + node = node[part] + if found: + declared_by.append(label) + except Exception: + declared_by = None + return include, declared_by, plant_path + + +NO_LIFTERS_HEADLINE = ( + 'no modelled family matched families.lifter_include -- the plant layer ' + 'maps {0}; nothing was lifted-checked.') + + +def no_lifters_message(include, declared_by, assembly_families, plant_path): + """Why zero lifters were recognised on this piece, in one of three ways. + + Pure -- `export()`'s job is to gather the four arguments off the model + and the knowledge layers; this turns them into the sentence the refusal + shows. `declared_by` is what tells the three states apart: + + None provenance could not be re-checked. Refuse anyway, hedged. + [] UNSET. Nobody has customised the key for this plant, so + the run is on the tool's own guess at somebody else's + naming -- the expected shape for a brand-new plant. + ['plant', ...] SET. A real layer names the key and it still matched + nothing here -- a mapping gone stale, the same shape as + the shipped-defaults and P78 incidents. + """ + include = include or [] + headline = NO_LIFTERS_HEADLINE.format( + 'none' if not include else ', '.join(include)) + + if declared_by is None: + state = ('Which layer sets families.lifter_include could not be ' + 're-checked, so this cannot say whether that is a new ' + "plant's default or a mapping gone stale -- only that " + 'nothing modelled matched it.') + elif not declared_by: + state = ( + 'families.lifter_include is UNSET: no plant, job or project ' + 'config layer names it, so this run matched against the ' + "tool's own built-in default ({0}). That is the expected shape " + 'for a brand-new plant -- and it is also why nothing was found: ' + "the default tokens are a guess at somebody else's naming, not " + 'this one.'.format(', '.join(include) if include else 'none')) + else: + state = ( + 'families.lifter_include IS SET ({0}, from the {1} layer) and ' + 'it still matches no family modelled on this piece -- the ' + "plant's mapping has gone stale, the same shape as the " + 'shipped-defaults and P78 incidents this codebase has already ' + 'hit twice.'.format( + ', '.join(include) if include else '(empty list)', + ' + '.join(declared_by))) + + seen = sorted(set(f for f in (assembly_families or []) if f)) + modelled = ('The families actually modelled on this assembly: {0}.' + .format(', '.join(seen)) if seen else + 'No other family names were read off this assembly either.') + + where = ('Declare it in {0}.'.format(plant_path) if plant_path else + "This job's plant could not be resolved to a knowledge file " + 'path, so there is nowhere to point at.') + + return '{0}\n\n{1}\n\n{2}\n\n{3}'.format(headline, state, modelled, where) + + +def resolve_concrete_strength(summary_value, param_value, label): + """(value, conflict_problem_or_None) for one concrete strength. + + Two sources feed f'c / f'ci into a job file: + + summary_value the drawing's OWN printed value, read off the + design summary view (`designspec.find_summary_views` + + `ticketqc.parse_design_summary`) -- the design's + own statement of its concrete strength. + param_value the MODEL parameter, read through the same + `sheet.fc` / `sheet.fci` role + (`ticketqc._as_number_or_none(ticket['tkt_fc'])`) + Ticket QC already reads for its own comparison row. + + Precedence, and why: the summary is the design's own printed value, so + it is the most authoritative source for a job whose drawing carries one, + and wins outright when present. The parameter is the fallback -- the + ONLY source on a job whose drawing carries no summary at all (the sample_job + model, where the owner mapped `sheet.fc` -> `FINAL_STRENGTH` on the + model instead). When both exist and agree (within 1 psi, since these + are always whole numbers in practice), either would do and the summary + is returned. `export()` never invents a strength: a source that is + simply absent contributes nothing here, same as before this fallback + existed. + + When both exist and DISAGREE, this mirrors `weight_check`'s "three + weights" rule for the same shape of problem -- a printed value and a + modelled value that describe the same physical fact are not averaged + and neither is silently preferred; the disagreement is reported, naming + both numbers, and nothing is calculated on a guess. So the value comes + back None with a `conflict_problem` string for `export()`'s `problems` + list -- a NOT-JUDGED conflict, not a wrong pick, exactly the same shape + `weight_check` reports as `False, message, numbers` for a stated-vs-solid + weight mismatch, adapted here to `export()`'s "return the job anyway, + with a problem" convention rather than a hard stop, since a bad fc is + caught downstream by every check that needs it and this is one field, + not the whole geometry read. + """ + if summary_value is not None and param_value is not None: + if abs(summary_value - param_value) < 1.0: + return summary_value, None + return None, ( + "{0} disagrees between the drawing summary ({1:.0f} psi) and " + 'the model parameter ({2:.0f} psi); neither was picked -- this ' + 'is a conflict to resolve, not a choice for the tool to make, ' + 'and the stages that need {0} cannot be checked until it ' + 'is.'.format(label, summary_value, param_value)) + if summary_value is not None: + return summary_value, None + if param_value is not None: + return param_value, None + return None, None + + +# ------------------------------------------------------------------ the read + +def export(doc, asm, know, sheets=None, design=None, batch=False): + """Everything panellift needs about one assembly, as a plain dict. + + Returns (job, problems). problems is a list of strings; a job with + problems is still returned so the caller can show what is missing rather + than only that something is. + """ + from Autodesk.Revit.DB import ElementId + from vibe import ticketqc + problems = [] + + ticket = ticketqc.read_ticket(doc, asm, sheets or {}, know=know) + if not ticket.get('panel_id'): + return None, ['no structural framing member in this assembly, so ' + 'there is no panel to check'] + + panel = doc.GetElement(ElementId(ticket['panel_id'])) + frame = ticketqc._panel_frame(panel) + extents = ticketqc.concrete_extents(panel, frame) + if frame is None or extents is None: + return None, ['could not solve the panel geometry'] + + lo = [v * 12.0 for v in extents[0]] + hi = [v * 12.0 for v in extents[1]] + L, W, T = _axes(lo, hi) + length_ft = (hi[L] - lo[L]) / 12.0 + width_ft = (hi[W] - lo[W]) / 12.0 + thickness_in = hi[T] - lo[T] + + vertical_axis, up_is_max, shares = erected_orientation( + asm, panel, frame, (L, W, T), know, (length_ft, width_ft)) + if vertical_axis is None: + return None, [ + 'Cannot tell which way this piece stands up. None of its axes ' + 'points convincingly up: length {0}, width {1}, thickness {2} ' + '(1.00 would be straight up).\n\n' + 'Guessing puts the erection anchors on the wrong edge with ' + 'nothing in the output to show it.\n\n' + 'Read from {3}: {4}'.format( + shares.get('length'), shares.get('width'), + shares.get('thickness'), + shares.get('basis'), shares.get('basis_why'))] + flat_piece = False + if vertical_axis == 'thickness': + # A piece that lies FLAT in service -- a slab, a plank, a landing. + # Nothing about handling changes: in the form every piece lies on + # its face, and a flat piece is lifted the way a wall panel is + # stripped. Only the up-end convention loses its meaning (neither + # end ends up lower), so x and y keep the model's order -- the same + # rule a horizontal panel already follows below -- and the attitude + # rides out in `vertical_share` so the report can say so. This used + # to be a refusal (owner, 2026-09-03: "没道理 panellift 能跑 + # horizontal wall 跑不了 slab"). + shares['attitude'] = 'flat' + problems.append( + 'this piece lies flat in service (its thickness points up): x ' + 'and y keep the model\'s order, every stage is a flat lift, and ' + 'a stage that hangs the piece on edge finds no lifters') + up_is_max = True + flat_piece = True + + # x always runs along the length with 0 at the end that ends up lower -- + # for a horizontal panel neither end is lower, so the sign is taken from + # the width direction instead and x simply keeps the model's order. + if vertical_axis == 'length': + x_ascends = up_is_max + y_ascends = True + else: + x_ascends = True + y_ascends = up_is_max + + def to_x(value_in): + along = (value_in - lo[L]) / 12.0 + return along if x_ascends else length_ft - along + + def to_y(value_in): + across = (value_in - lo[W]) / 12.0 + return across if y_ascends else width_ft - across + + # -- openings ---------------------------------------------------------- + # The largest solid for the SHAPE (the openings are its side faces), and + # `concrete_volume` for the weight -- they stopped being the same object + # the day an insulated body turned out to draw more than one solid. One + # geometry walk answers both; walking twice would double the most + # expensive read in the button. + solids = panel_solids(panel) + solid = concrete_solid(panel, solids) + concrete = concrete_volume(doc, panel, know, solids, frame, (L, W, T)) + layup = concrete.get('layup') or {} + rects = _openings(solid, frame, lo, hi, (L, W, T)) + # A rectangle in the side of the solid is not automatically a hole through + # it. `opening_penetration` shoots a line along the thickness axis and + # measures what concrete is left, off the SAME solid that is already in + # hand -- no second geometry walk. Only the through ones may go out as + # blockouts: panellift cuts every strand a blockout crosses and restarts + # development at its edges, and doing that for a corner pocket with 3.5 in + # of concrete behind it is how a solid piece came back failing. + try: + pen = opening_penetration(panel, frame, solid, rects, (L, W, T), + lo, hi) + except Exception: + pen = [] + blockouts, recesses, unjudged = split_openings( + rects, pen, to_x, to_y, lo, (L, W, T), thickness_in) + if unjudged: + problems.append( + 'the through-thickness test could not be solved for {0} of the {1} ' + 'opening(s) read off this piece; those are carried as ' + 'through-thickness blockouts, which takes section and strand away ' + 'that may really be there'.format(unjudged, len(rects))) + + fast = _fast_box(know, batch) + + # -- extra cast concrete ------------------------------------------------ + extras_ft3 = extra_cast_ft3(doc, asm, know) + # Concrete the body draws OUTSIDE the panel element: the thickened + # solid-zone members of an insulated sandwich, real cast concrete that neither + # the panel-body solid sum nor the reconstruction can see. 0.0 where no + # plant names the family, so a piece without them is untouched. + zone_ft3 = solid_zone_ft3(doc, asm, know) + # THE RECONSTRUCTION LEG, MADE WYTHE-AWARE. `weight_check` rebuilds the + # reconstructed weight from `panel.thickness_in`, which is the LARGEST + # solid's extent (`concrete_extents`) and therefore ONE WYTHE on a + # sandwich -- so on a multi-solid body that recomputation drops every other + # wythe and refuses the piece. Where the body draws more than one concrete + # solid, the gate is handed a reconstruction summed over EVERY wythe here + # instead; a single-solid body has no wythe to drop and keeps the + # dimension-based recomputation (which also carries the recess-depth + # handling `weight_check` does). None where a wythe's extents could not be + # read -- the gate then falls back and refuses rather than passing short. + concrete_solids = _pick_concrete_solids(solids, concrete) + reconstructed_ft3 = None + reconstruction_reason = None + if len(concrete_solids) > 1: + wythe_recon, reconstruction_reason = _reconstructed_ft3( + concrete_solids, frame, know) + if wythe_recon is not None: + reconstructed_ft3 = round(wythe_recon + extras_ft3 + zone_ft3, 4) + + # -- concrete strengths ------------------------------------------------ + # Two sources: the drawing's own printed summary (jobs whose drawing + # carries one) and the sheet.fc/sheet.fci MODEL parameter (the sample_job + # model, whose drawing carries no summary at all) -- see + # `resolve_concrete_strength`'s docstring for the precedence and the + # disagreement rule. The parameter read reuses + # `ticket['tkt_fc']` / `['tkt_fci']`, already gathered by the + # `ticketqc.read_ticket()` call at the top of this function through the + # SAME `_sheet_param(sheets, know, 'sheet.fc')` Ticket QC's own f'c row + # reads -- not a parallel read of the parameter. + fc_summary = fci_summary = None + if design: + from vibe import designspec + views, _packet = designspec.find_summary_views(doc, design, know) + if views: + read = ticketqc.parse_design_summary(doc, views[0]) + fc_summary, fci_summary = read.get('fc'), read.get('fci') + fc_param = ticketqc._as_number_or_none(ticket.get('tkt_fc')) + fci_param = ticketqc._as_number_or_none(ticket.get('tkt_fci')) + fc, fc_conflict = resolve_concrete_strength(fc_summary, fc_param, "f'c") + fci, fci_conflict = resolve_concrete_strength(fci_summary, fci_param, + "f'ci") + if fc_conflict: + problems.append(fc_conflict) + if fci_conflict: + problems.append(fci_conflict) + if fc is None and not fc_conflict: + problems.append("no f'c found for this design; the stages that need " + 'it cannot be checked') + + # -- lifters ----------------------------------------------------------- + boxes = ticketqc.member_boxes(doc, asm, panel, frame) + # Every OTHER member's family name on this assembly -- the only place a + # tension bar welded to an embed can be corroborated at all, since Revit + # carries no link from the embed instance to the bar (see + # `knowledge._tension_bar_corroborated`). Built once, off the SAME walk + # that finds the lifters, rather than a second collector pass. + assembly_families = [b['family'] for b in boxes] + lifters = [] + for entry in boxes: + family = entry['family'] + if not knowledge.is_lifter(know, family): + continue + if not fast: + ticketqc.refine_box(entry, frame) + centre = [(entry['lo'][i] + entry['hi'][i]) / 2.0 for i in (0, 1, 2)] + group = _group_of(know, family) + acts_at = _group_spec(know, group).get('at', 'measured') + x, y, fixed_x, fixed_y = snap_to_edge( + acts_at, vertical_axis, to_x(centre[L]), to_y(centre[W]), + length_ft, width_ft) + # Ratings go out UNDERATED, with the strength they were derived at. + # One insert is checked at several concrete strengths in one report -- + # release for stripping, 28 days for the rest -- so a single + # pre-derated number would be wrong for all but one of them. The + # derating is panellift's, where it is tested. + # + # capacity_lb / shear_capacity_lb are baked into the job file at + # THIS export, and panellift never re-reads the catalogue -- so the + # basis/why strings ride along here too, or the origin of a number + # (a direct override, a bare catalogue read, or the tension-bar gate + # picking a column) is gone the moment this file is written, exactly + # the gap that made the dropped P76 override take a git-history dig + # to explain. + tension_lb, tension_basis, tension_why = knowledge.lifter_capacity( + know, family, assembly_families=assembly_families) + shear_lb, shear_basis, shear_why = knowledge.lifter_capacity( + know, family, kind='shear', assembly_families=assembly_families) + lifters.append({ + 'mark': str(entry['id']), + 'family': family, + 'group': group, + 'x': round(x, 4), + 'y': round(y, 4), + 'modelled_x': round(to_x(centre[L]), 4), + 'modelled_y': round(to_y(centre[W]), 4), + 'pinned': fixed_x, + 'pinned_y': fixed_y, + 'on_edge': acts_at in ('top', 'bottom'), + 'capacity_lb': tension_lb, + 'capacity_basis': tension_basis, + 'capacity_why': tension_why, + 'shear_capacity_lb': shear_lb, + 'shear_capacity_basis': shear_basis, + 'shear_capacity_why': shear_why, + 'basis_fc_psi': knowledge.lifter_basis_fc(know, family), + 'min_edge_distance_in': ( + knowledge.lifter_edge_distance(know, family) or + knowledge.get(know, 'lifters.min_edge_distance_in')), + # PER PART, and absent rather than defaulted. These are the + # factors that turn an ultimate rating into a working load, and + # they were a single job-wide pair -- 4.0 / 2.67 -- until + # 2026-08-31. The 2.67 had no source and ALP catalog p53 + # (02/2024) publishes 4:1 in shear for the parts it reached, so + # a factor now comes from the entry of the part that was tested + # or from nowhere; `panellift` reports an insert with none as + # unjudged rather than substituting a number. + 'tension_fos': knowledge.lifter_safety_factor( + know, family, kind='tension')[0], + 'shear_fos': knowledge.lifter_safety_factor( + know, family, kind='shear')[0], + }) + lifters.sort(key=lambda l: (l['x'], l['y'])) + + # A hard stop, not a warning -- the cardinal sin (see the block above + # `_lifter_include_provenance`). Checked before the strand read below + # because there is nothing left worth computing on a piece nobody + # lifted-checked, and because this is the same class of refusal as the + # strand one just below it: a fact needed to make the check MEAN + # anything is missing, not merely vaguer. + if not lifters: + include, declared_by, plant_path = _lifter_include_provenance( + doc, know) + return None, [no_lifters_message(include, declared_by, + assembly_families, plant_path)] + + # -- strands ----------------------------------------------------------- + # A hard stop, not a warning. Every other gap here makes the answer + # vaguer; this one makes it wrong by 350 psi in the direction that fails + # a piece which is fine, and it does it silently. + pre, strand_problems = read_prestress(doc, asm, panel, frame, know, + to_y, lo, W, T, design, fast, + width_ft=width_ft) + if strand_problems and pre is None: + return None, strand_problems + problems.extend(strand_problems) + + ungrouped = sorted(set([l['family'] for l in lifters + if l['group'] is None])) + if ungrouped: + problems.append( + 'no handling.lifter_groups entry for {0}; these inserts cannot be ' + 'assigned to a stage'.format(', '.join(ungrouped))) + + job = { + 'schema': SCHEMA, + 'panel': { + 'mark': ticket['mark'], + 'design_number': ticket.get('design_number'), + 'length_ft': round(length_ft, 4), + 'width_ft': round(width_ft, 4), + 'thickness_in': round(thickness_in, 3), + 'unit_weight_pcf': ticket.get('pcf') or 150.0, + 'weight_lb': ticket.get('weight_lb'), + 'fc_psi': fc, + 'fci_psi': fci, + 'finish': knowledge.get(know, 'handling.finish', 'smooth'), + 'product_type': knowledge.get(know, 'handling.product_type', + 'flat'), + 'blockouts': blockouts, + # The blind ones, kept apart from the through ones on purpose. + # Absent means "nothing said" and an empty list means "looked, + # found none" -- the same rule every other additive key in this + # file follows, and the reason the schema does not bump. + 'recesses': recesses, + 'extra_cast_ft3': round(extras_ft3, 4), + 'lifters': [{k: v for k, v in l.items() + if k not in ('group', 'modelled_x', 'modelled_y')} + for l in lifters], + 'prestress': pre, + # The plant's reference-tool convention for the in-plane + # prestress term (handling.ignore_lateral_prestress). + 'ignore_lateral_prestress': bool(knowledge.get( + handling_view(know, flat_piece), + 'handling.ignore_lateral_prestress', False)), + }, + 'rigging': { + 'mode': knowledge.get(know, 'handling.rig_mode', 'rolling'), + 'sling_angle_deg': knowledge.get(know, 'handling.sling_angle_deg', + 60.0), + 'group_tolerance_ft': knowledge.get( + know, 'handling.group_tolerance_ft', 1.0), + # The JOB-WIDE fallback pair, and normally null -- the real + # factors ride on each lifter above, because a safety factor is + # a property of the part that was tested. This wrote 4.0 / 2.67 + # until 2026-08-31 and those defaults are gone: 2.67 had no + # published source and the manufacturer's page for the parts it + # reached (ALP catalog p53, 02/2024) heads its capacity table + # "Capacity in Concrete 4:1 SWL" over BOTH load columns. The key + # is still written so a plant that has its OWN tested factor for + # hardware the catalogue does not cover can state one and have + # it read; with nothing set, an insert with no factor of its own + # is reported unjudged instead of quietly given one. + 'tension_fos': knowledge.get( + know, 'catalogue.safety_factors.tension_fos'), + 'shear_fos': knowledge.get( + know, 'catalogue.safety_factors.shear_fos'), + }, + 'stages': [], + 'source': { + 'model': os.path.basename(doc.PathName or ''), + 'assembly_id': ticket['asm_id'], + 'panel_id': ticket['panel_id'], + 'design': design, + 'erected_vertical_axis': vertical_axis, + 'erected_up_is_axis_max': bool(up_is_max), + 'vertical_share': shares, + # WHERE "which end is up" CAME FROM, on every run. The ruled + # answer is the assembly origin (see erected_orientation and + # knowledge.up_end_rule); until the origin half of that rule is + # verified against a live model this is global elevation, and a + # report that cannot say which of the two it used cannot be + # argued with. Quiet but on the record -- the same treatment + # `_plant_id` gets when no plant is named. + 'up_end_basis': shares.get('basis'), + 'up_end_basis_why': shares.get('basis_why'), + # Total cast concrete: every concrete solid the body draws, plus + # the extras, plus the solid-zone members. None when which solids + # are the concrete could not be established -- reporting a fraction + # of the piece would hand the weight gate a number that is a + # fraction of the piece and let it call the model wrong. It did + # exactly that on every insulated panel until 2026-08-31. + 'solid_ft3': (round(concrete['volume_ft3'] + extras_ft3 + zone_ft3, + 4) + if concrete['volume_ft3'] is not None else None), + 'panel_solid_ft3': (round(concrete['volume_ft3'], 4) + if concrete['volume_ft3'] is not None + else None), + 'extra_cast_ft3': round(extras_ft3, 4), + # The thickened solid-zone concrete cast OUTSIDE the panel element + # (separate Generic Model members). Included in solid_ft3 and in + # reconstructed_ft3 above; carried on its own so the weight gate + # (and a reader) can add it to the dimension-based reconstruction + # of a single-solid piece too. 0.0 where no plant names the family. + 'solid_zone_ft3': round(zone_ft3, 4), + # THE WYTHE-SUMMED RECONSTRUCTION, present only where the body draws + # more than one concrete solid -- the case `weight_check`'s + # `panel.thickness_in` recomputation gets wrong by a whole wythe. + # Absent (None) on a single-solid piece, which keeps the gate's own + # dimension-and-recess reconstruction. Already includes the extras + # and the solid zones, so the gate uses it as-is. + 'reconstructed_ft3': reconstructed_ft3, + # Set only when the wythe sum above stood itself down (a + # stepped or tapered wythe `_reconstructed_ft3` cannot + # represent) -- see that function's docstring. None on every + # other path, including a single-solid piece, which never + # calls it at all. + 'reconstruction_reason': reconstruction_reason, + # WHAT THE SOLID SUM INCLUDED, on every run, whether it worked + # or not. A weight travelling without this is a bare number + # nobody can argue with -- the same treatment `up_end_basis` + # gets above, and for the same reason. + 'solid_basis': concrete['basis'], + 'solid_note': concrete['why'], + # AND WHERE EACH OF THEM SITS THROUGH THE THICKNESS. The volumes + # alone were enough to say "this body draws more than one solid", + # which is enough to REFUSE an insulated piece -- and not enough + # to compute its section. z0_in/z1_in are what turn a list of + # volumes into a layup: which solid is the near wythe, how thick + # each one is, and how much insulation is between them. They are + # null where the read failed, never zero, and `panellift.sandwich` + # reads null as "the layup exists and nobody measured it" rather + # than as a layer of no thickness. + 'solid_parts': _solid_parts(concrete, (L, W, T)), + # The layup as an ordered list, which is the same facts arranged + # the way the section wants them. Written whether or not it could + # be measured, with `measured` and `why` on it -- absence is not + # a value, and a missing key downstream reads as nothing to check. + 'layup': layup, + # The envelope, measured over EVERY solid the body draws. Not the + # same number as `panel.thickness_in`, which comes off the + # largest solid alone and is therefore one wythe on a sandwich. + # Both are written so the difference is arithmetic rather than a + # discrepancy somebody has to notice. + 'envelope_thickness_in': layup.get('envelope_thickness_in'), + 'section_thickness_basis': ( + "panel.thickness_in is the LARGEST solid's extent " + '(ticketqc.concrete_extents), which is the whole piece on a ' + 'solid panel and ONE WYTHE on an insulated one. ' + 'source.envelope_thickness_in is measured over every solid ' + 'the body draws. On a body drawing one solid they are the ' + 'same number.'), + # What is holding the wythes together, and it is empty today. + # Every value null with a reason beside it, because a missing key + # reads downstream as "nothing to check" and the connectors are + # the thing an insulated panel is actually checked on. + 'connectors': _connectors(know), + # Which stages the up-end answer decides, so a report that had to + # fall back can say what the fallback touched instead of leaving + # the reader to work it out. See panellift.report.up_end_ + # disclosure for why the fallback is allowed to answer at all. + 'up_end_affects': UP_END_AFFECTS, + }, + } + # Carried outside 'panel' so the job file stays exactly what panellift + # reads; the button needs the grouping to work out the default picks. + job['_groups'] = dict((l['mark'], l['group']) for l in lifters) + if job['panel'].get('ignore_lateral_prestress'): + problems.append( + 'the in-plane (horizontal) prestress eccentricity is left out of ' + 'the fibre stresses, per handling.ignore_lateral_prestress -- the ' + 'plant runs its reference tool with horizontal strand ' + 'eccentricity ignored') + return job, problems + + +# -------------------------------------------------------------------- stages + +def rows_of(lifters, tolerance=0.4, along='x'): + """Inserts clustered into rows along the piece, bottom first. + + `along` is the direction the piece spans in the stage being set up: 'x' + flat on the bed, 'y' for a horizontal panel stood up on its long edge. + Inserts share a row when they hang from the same point of the rigging, + and which coordinate decides that changes with the piece's orientation -- + the anchors along the top edge of a horizontal panel are one support when + it is standing and several when it is flat. + + Returns a list of dicts: x, marks, families, group. + """ + rows = [] + for lifter in sorted(lifters, key=lambda l: l[along]): + value = lifter[along] + if rows and abs(value - rows[-1]['x']) <= tolerance: + row = rows[-1] + else: + rows.append({'x': value, 'xs': [], 'marks': [], + 'families': set(), 'groups': set(), 'counts': {}}) + row = rows[-1] + row['marks'].append(lifter['mark']) + row['families'].add(lifter['family']) + row['counts'][lifter['family']] = \ + row['counts'].get(lifter['family'], 0) + 1 + # A SET, not the first member's group. An edge anchor and a pair of + # face lifters can sit at the same station -- on one real piece they + # were an inch apart -- and stamping the row with whichever sorted + # first threw the other two out of every stage that wanted them. + row['groups'].add(lifter.get('group')) + row['xs'].append(value) + row['x'] = sum(row['xs']) / float(len(row['xs'])) + return rows + + + +def _dunnage_mode(know): + """The plant's dunnage rule, or None when nobody has stated one.""" + dunnage = knowledge.get(know, 'handling.dunnage') or {} + mode = dunnage.get('mode') + if mode in ('lifter_rows', 'fraction'): + return str(mode), dunnage + return None, dunnage + + +def dunnage_supports(know, length_ft, rows): + """Support lines for the transportation stage, per the plant's rule. + + Returns (supports, problems). supports is a list of x positions in feet, + or None when the plant has not configured dunnage -- in which case the + stage keeps the old lift approximation and the report labels it. + """ + mode, dunnage = _dunnage_mode(know) + if mode is None: + return None, [] + if mode == 'lifter_rows': + xs = [r['x'] for r in (rows or [])] + if len(xs) < 2: + return None, [ + 'handling.dunnage.mode is lifter_rows but there are fewer ' + 'than two stripping lifter rows to sit the piece on'] + return [round(x, 4) for x in xs], [] + # fraction -- two dunnage lines, inset that much of the span each end. + fraction = dunnage.get('fraction') + if fraction is None: + return None, [ + 'handling.dunnage.mode is fraction but ' + 'handling.dunnage.fraction is not set'] + try: + fraction = float(fraction) + except (TypeError, ValueError): + return None, [ + 'handling.dunnage.fraction is not a number: {0!r}' + .format(dunnage.get('fraction'))] + if not 0.0 < fraction < 0.5: + return None, [ + 'handling.dunnage.fraction must be between 0 and 0.5, got ' + '{0}'.format(fraction)] + return [round(fraction * length_ft, 4), + round((1.0 - fraction) * length_ft, 4)], [] + +def handling_view(know, flat): + """The `handling` block a piece actually runs under. + + A plant may run a piece that lies FLAT in service (a slab, a plank) under + different limits from its wall panels -- the reference tool differs, and + so do the transfer limits and the stage strengths. That difference lives + in the plant file as `handling.when_flat`, a block overlaid key by key + onto `handling` for a flat piece and ignored otherwise. Returns `know` + itself when nothing applies, a deep copy with the overlay when it does; + the caller's tree is never mutated. + """ + if not flat: + return know + overlay = knowledge.get(know, 'handling.when_flat') + if not isinstance(overlay, dict) or not overlay: + return know + view = knowledge._deep_copy(know) + handling = view.setdefault('handling', {}) + for key, value in overlay.items(): + if key == 'when_flat': + continue + handling[key] = knowledge._deep_copy(value) + return view + +def default_stages(job, know): + """The stages from knowledge, each with the picks the rules imply. + + Returns a list of dicts ready to become job['stages'], the row structure + the caller shows the user, and any dunnage configuration problems found. + The user gets the last word: these are defaults, not decisions. + """ + groups = job.get('_groups') or {} + lifters = [] + for lifter in job['panel']['lifters']: + item = dict(lifter) + item['group'] = groups.get(lifter['mark']) + lifters.append(item) + + tolerance = job['rigging'].get('group_tolerance_ft', 1.0) + # WHICH WAY THE PIECE SPANS ONCE IT IS STANDING, and the `or 'length'` + # that used to be here was the same defect as an undisclosed fallback one + # step further on: a piece whose erected axis nobody established was + # quietly assumed to span its length, which is right for a wall panel and + # puts the erection anchors on the wrong edge of a horizontal one. It + # cannot be silent. `export()` refuses outright when no axis is + # convincing, so this only fires on a hand-written job file -- and a + # hand-written file is exactly where nobody would notice. + erected = (job.get('source') or {}).get('erected_vertical_axis') + erected_assumed = not erected + if erected_assumed: + erected = 'length' + # A piece that lies FLAT in service (a slab, a plank): its thickness + # points up, so there is no erection hang and no edge to hang it by. + flat = (erected == 'thickness' or + ((job.get('source') or {}).get('vertical_share') or {}) + .get('attitude') == 'flat') + # The plant's flat-piece overlay (handling.when_flat), if it has one. + know = handling_view(know, flat) + all_rows = rows_of(lifters, tolerance, along='x') + + fc = job['panel'].get('fc_psi') + fci = job['panel'].get('fci_psi') + # The tensile allowable, as a multiple of sqrt(f'c) in psi. This is the + # single largest disagreement between this tool and a commercial check of + # the same piece -- 5.0 against 7.5 is a factor of 1.5 on every stress + # verdict -- and the owner would not settle it for everybody, 2026-08-30: + # "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自 + # 己填写。I can see it both ways." So it is a field in the Project Config + # window's lifting & handling module, resolved through the layers here + # and written onto every stage below, where panellift reads it. + limit = knowledge.get(know, 'handling.tension_limit_sqrt_fc', 5.0) + # The compression allowable's sibling field, resolved and written the + # exact same way: "0.6保持可调" (2026-09-01) is the ruling that made this + # one adjustable per job too, in the same Project Config module. + limit_comp = knowledge.get(know, 'handling.compression_limit_ratio', 0.6) + length_ft = job['panel'].get('length_ft') or 0.0 + problems = [] + stripping_rows = None + + out = [] + for spec in knowledge.get(know, 'handling.stages', []) or []: + wanted = spec.get('groups') or [] + # Which direction the piece spans in this stage. + # + # Flat on the bed or the trailer it spans its LONG direction, always, + # whatever it will end up being: that is how it lies, and that is how + # the face lifters are rowed across it. + # + # Erected, gravity runs along whatever the assembly origin stands up, + # and the piece spans THAT. For a wall panel it is the long direction + # again and nothing changes. For a horizontal panel it is the width, + # and the rows have to be taken again in that direction. + uses_erected = bool(spec.get('erected') or 'edge_top' in wanted) + span = spec.get('span') or (erected if uses_erected else 'length') + # And if the erected axis was assumed rather than read, this is the + # stage it was assumed FOR, so it says so on that stage rather than + # in a log nobody opens. + assumed_here = (erected_assumed and uses_erected + and not spec.get('span')) + if assumed_here: + problems.append( + 'stage "{0}" spans whatever the piece stands up, and nothing ' + 'in this job file says which axis that is ' + '(source.erected_vertical_axis is absent). It was taken as ' + 'the LENGTH, which is right for a wall panel and puts the ' + 'anchors on the wrong edge of a horizontal one.' + .format(spec.get('name') or 'stage')) + # Inserts are chosen by GROUP first and rowed afterwards. Rowing first + # and then filtering asks a row which group it is, and a row that + # holds two answers can only give one of them: an anchor and a pair of + # face lifters at the same station cost the face lifters their place + # in every stage that wanted them. Six of eight got picked, on a real + # piece, and the missing pair is a whole support line. + picked = [l for l in lifters if l.get('group') in wanted] + rows = rows_of(picked, tolerance, + along='y' if span == 'width' else 'x') + # 'exclude_top_rows' drops rows from the top of the piece, and only + # rows that are PURELY the movable group -- the point of the rule is a + # crane that cannot reach the top face lifters, not one that cannot + # reach the anchors it is hooking on to, and a row holding both still + # holds an anchor. Rows are ordered along whatever the piece spans in + # this stage, so 'top' follows the piece round with it. + # + # The N slots counted off are the top N ROWS OF THE PIECE, not the + # top N of whatever is left after filtering to pure rows first: a + # narrow piece can put the anchor row within group_tolerance_ft of + # the face row above it, so the two merge into one mixed row that + # sits at the actual top. Filtering to "purely one group" BEFORE + # ranking throws that merged row out of contention entirely and + # then drops the next pure row down instead -- on one real piece + # that was the row nearest the OTHER end, mistaken for "the top + # face row" only because it was the sole survivor of the filter, + # which cost the pick its second support line and only that line. + drop = spec.get('exclude_top_rows') or 0 + dropped_marks = 0 + if drop and wanted: + ordered = sorted(rows, key=lambda r: -r['x']) + for row in ordered[:drop]: + if row['groups'] == set([wanted[0]]): + rows.remove(row) + dropped_marks += len(row['marks']) + strength = fci if spec.get('strength') == 'fci' else fc + stage = { + 'name': spec.get('name') or 'stage', + 'span': span, + 'multiplier': spec.get('multiplier'), + # Not defaulted to the stage's name: a plant may call a stage + # anything, and guessing its Table 8.3.1 row from what it is + # called would put a load factor on the piece that nobody chose. + # Missing both is refused downstream, loudly. + 'pci_case': spec.get('pci_case'), + # None means 'hang it the way the job hangs it'. panellift + # inherits job rigging.mode when this is absent, and refuses + # anything that is neither mode nor blank. + 'rig_mode': spec.get('rig_mode'), + 'fc_psi': strength, + 'tension_limit_sqrt_fc': (spec['tension_limit_sqrt_fc'] + if spec.get('tension_limit_sqrt_fc') + is not None else limit), + # WHO chose that limit, carried through so the report can print + # it. A stage spec that states its own beats the job-wide field + # in Project Config, and the engineer who typed a number into + # that field and got a different one on the report has to be + # able to see why on the page rather than by reading YAML. + 'tension_limit_source': ( + 'stage' if spec.get('tension_limit_sqrt_fc') is not None + else 'job'), + # The compression allowable's sibling of the two keys above -- + # same per-stage override, same provenance word, same reason. + 'compression_limit_ratio': ( + spec['compression_limit_ratio'] + if spec.get('compression_limit_ratio') is not None + else limit_comp), + 'compression_limit_source': ( + 'stage' if spec.get('compression_limit_ratio') is not None + else 'job'), + 'lifters': [m for r in rows for m in r['marks']], + '_rows': rows, + '_available': len(picked), + '_dropped_marks': dropped_marks, + } + + # A stage the plant switched off. It stays in the list, carrying the + # flag, so panellift can print "not performed" beside the stages that + # ran -- which is the entire reason for having a flag instead of + # deleting the stage from handling.stages, where its absence would be + # indistinguishable from a stage that passed. + # + # The key is written ONLY when it is false. Every plant configured + # today has no `enabled` in its stage specs, absent means enabled at + # both ends, and writing `"enabled": true` on all of them would churn + # every job file on disk to say nothing. + if not spec.get('enabled', True): + stage['enabled'] = False + if spec.get('disabled_reason'): + stage['disabled_reason'] = str(spec['disabled_reason']) + elif flat and uses_erected: + # A flat piece is never hung on edge: its "erection" is another + # flat lift, which the face-lifter stages already cover. Switched + # off with the reason on the stage rather than solved with the + # thickness as a span (owner, 2026-09-03: 检查到是 slab 以后只跑 + # stripping / transportation, 不跑 erection). + stage['enabled'] = False + stage['span'] = 'length' + stage['disabled_reason'] = ( + 'the piece lies flat in service (its thickness points up), ' + 'so there is no erection hang to check; stripping and ' + 'transportation cover its handling') + + # Transportation is the piece sitting on dunnage, not hanging from + # the inserts. The plant layer says where the dunnage goes; a stage + # that becomes supports has no pick to confirm in the model. + if spec.get('pci_case') == 'transportation': + supports, dunnage_problems = dunnage_supports( + know, length_ft, stripping_rows or rows) + problems.extend(dunnage_problems) + if supports is not None: + stage['supports'] = supports + # The key goes, rather than going empty. panellift refuses a + # stage that names both a support line and an insert, and an + # empty list is still a list -- which made every job file + # written with dunnage configured unreadable by the thing + # that reads job files. + stage.pop('lifters', None) + stage['_rows'] = [] + stage['_available'] = 0 + stage['_dropped_marks'] = 0 + + out.append(stage) + if spec.get('pci_case') == 'stripping' and span == 'length': + stripping_rows = rows + + return out, all_rows, problems + + +def stage_audit(stages): + """Every insert a stage was entitled to, against what the defaults gave it. + + Run on the DEFAULTS, before anybody has chosen anything: after that, + fewer inserts is the engineer's decision and not a fault. + + A stage quietly losing an insert it should have had is invisible in the + result. The report is complete, the diagrams are drawn, and the piece is + analysed on a support line that is not there -- which is how a panel came + back at 184% of its stripping limit on three rows when it has four. + + Advisory, not a veto. The button now has the user click every insert on + the piece, so a default that is off costs clicks rather than an answer; + what this says is that the highlighted defaults are not to be trusted on + this piece, which is worth knowing before you glance at them and hit + Finish. + """ + problems = [] + for stage in stages: + # A stage that is switched off is not going to be analysed on any + # inserts, so how many it was entitled to is not a finding about + # anything. Saying it anyway teaches the reader to skim the audit. + if stage.get('enabled', True) is False: + continue + available = stage.get('_available', 0) + expected = available - stage.get('_dropped_marks', 0) + got = len(stage.get('lifters') or []) + if got != expected: + problems.append( + '{0}: its insert groups hold {1}, {2} were set aside on ' + 'purpose, and {3} came through. {4} unaccounted for.' + .format(stage['name'], available, + stage.get('_dropped_marks', 0), got, + abs(expected - got))) + return problems + + +# ---------------------------------------------------------- the weight gate + +def _opening_deduction_ft3(entry, thickness_in, through): + """Cubic feet one opening takes out of the reconstruction. The MEASURED + `removed_ft3` wins when it is a number; otherwise the geometric fallback: + full thickness for a through blockout, `depth_in` for a blind recess + (0 when the depth was never solved -- an unmeasured pocket is left in, + so the gate reads high and refuses rather than blessing a guess).""" + r = entry.get('removed_ft3') + if isinstance(r, (int, float)): + return float(r) + depth = thickness_in if through else (entry.get('depth_in') or 0.0) + return entry['length'] * entry['width'] * depth / 12.0 + + +def weight_check(job, know=None): + """Does the geometry we read weigh what the model says it does? + + Returns (ok, message, numbers). ok False means STOP -- do not calculate, + do not write a report. + + Three weights have to agree, and which pair disagrees says what is wrong: + + stated the panel's own weight parameter + solid every concrete solid the body draws x unit weight + (plus cast extras and solid-zone members) + reconstructed (each concrete wythe's length x width x thickness less + its blockouts, summed, + cast extras + solid-zone + members) x unit weight. On a single-solid piece the sum + is one term and comes off panel.thickness_in; on a + sandwich the exporter hands over source.reconstructed_ft3 + so the reconstruction is not one wythe of several. + + `solid` says which solids that was -- `source.solid_note`, written by + `choose_concrete` -- and this report prints it beside the number. Until + 2026-08-31 it was the LARGEST solid rather than the sum of them, so an + insulated body was weighed one wythe at a time and every insulated piece + stopped here at about 65% of its printed weight. The gate was right; the + number it was handed was not. + + stated vs solid is about the model: a weight parameter that has not been + recalculated, or a unit weight that is not what the piece is cast from. + It is also the free net under a units bug in `stated` itself: measured + live 2026-09-02, WPA545E's stored weight parameter read 560,537.22 via + AsDouble() against 38,409 lb via AsValueString() (the number the + engineer actually sees) -- a 14.6x units artifact. `weight_lb` is fed + from the AsValueString path (`ticketqc.rstr`), so that number should + never reach here, but if it ever did, this tolerance check refuses + rather than calculates on it: 560,537 lb implied against a 256.06 ft3 + solid is ~2189 pcf, nowhere near real concrete. + + solid vs reconstructed is about US. Those two describe the same concrete; + they differ only when the blockouts were read wrong, and reading them + wrong is silent. An opening that got missed makes the piece heavier than + it is, which is conservative for stress and NOT conservative for anything + else -- and a section modulus computed as if a door were not there is + simply the wrong number, in the direction that says a panel is fine. + + That happened: a door running down to the bottom edge of a panel had its + jambs discarded as the outside of the piece, and the panel came back + weighing what a solid one would. + """ + panel = job['panel'] + pcf = panel.get('unit_weight_pcf') or 150.0 + tolerance = 0.02 + if know is not None: + tolerance = knowledge.get(know, 'handling.weight_tolerance', 0.02) + + gross_ft3 = (panel['length_ft'] * panel['width_ft'] * + panel['thickness_in'] / 12.0) + holes_ft3 = sum(_opening_deduction_ft3(b, panel['thickness_in'], True) + for b in panel.get('blockouts') or []) + # A blind recess is concrete that is not there either, and it stopped + # being in `blockouts` on 2026-09-01 -- so without this the reconstruction + # would start calling every pocketed piece heavier than its own solid. + # The MEASURED `removed_ft3` wins when the in-memory boolean solved it; + # `depth_in` is the fallback for a recess never measured (a sloped, + # stepped or far-face pocket reads 0 through the centre-line probe). + # `depth_in` absent means the depth was never solved, and an unmeasured + # pocket is left in rather than guessed at -- the gate then reads high, + # which is a refusal to explain and not a piece quietly blessed. + holes_ft3 += sum(_opening_deduction_ft3(r, panel['thickness_in'], False) + for r in panel.get('recesses') or []) + extras_ft3 = panel.get('extra_cast_ft3') or 0.0 + source = job.get('source') or {} + # Solid-zone concrete cast OUTSIDE the panel element (insulated sandwich + # thickened zones). Already inside source.solid_ft3, and added to the + # dimension-based reconstruction below so a single-solid piece that has + # zones still reconciles. 0.0 where no plant names the family. + zone_ft3 = source.get('solid_zone_ft3') or 0.0 + solid_ft3 = source.get('solid_ft3') + # THE RECONSTRUCTION LEG. Prefer the wythe-summed volume the exporter + # measured over EVERY concrete solid: it is written only on a multi-solid + # (sandwich) body, which is exactly the body `panel.thickness_in` -- one + # wythe of a sandwich -- gets wrong, and it already carries the extras and + # the solid zones. On a single-solid piece the key is absent and the + # dimension-and-recess reconstruction stands (with the solid zones added), + # which keeps the recess-depth handling the wythe sum does not do. The leg + # is still gross-from-the-drawing against the actual solid, so it still + # catches a blockout read wrong -- it just no longer drops a wythe. + recon_ft3 = source.get('reconstructed_ft3') + wythe_summed = recon_ft3 is not None + if not wythe_summed: + recon_ft3 = gross_ft3 - holes_ft3 + extras_ft3 + zone_ft3 + # The provenance rides with the number. `solid_note` is what went into + # the sum; where there is no sum, the same sentence is why there is not. + solid_note = source.get('solid_note') if solid_ft3 else None + solid_why = source.get('solid_note') if not solid_ft3 else None + + numbers = { + 'stated_lb': panel.get('weight_lb'), + 'gross_lb': gross_ft3 * pcf, + 'blockout_lb': holes_ft3 * pcf, + 'extra_cast_lb': extras_ft3 * pcf, + 'solid_zone_lb': zone_ft3 * pcf, + 'reconstructed_lb': recon_ft3 * pcf, + 'solid_lb': solid_ft3 * pcf if solid_ft3 else None, + 'solid_basis': source.get('solid_basis'), + 'solid_note': source.get('solid_note'), + 'blockouts': len(panel.get('blockouts') or []), + 'recesses': len(panel.get('recesses') or []), + 'tolerance': tolerance, + } + + def off(a, b): + return abs(a - b) / b if b else None + + lines = [ + 'Weight does not reconcile, so nothing was calculated.', + '', + ' panel {0:.2f} x {1:.2f} ft x {2:.2f} in at {3:.0f} pcf' + .format(panel['length_ft'], panel['width_ft'], + panel['thickness_in'], pcf), + ' solid, gross {0:,.0f} lb'.format( + numbers['gross_lb']), + ' {0} blockout(s) + {1} recess(es) -{2:,.0f} lb'.format( + numbers['blockouts'], numbers['recesses'], + numbers['blockout_lb']), + ] + if extras_ft3: + lines.append( + ' cast extras (ledge etc.) +{0:,.0f} lb'.format( + numbers['extra_cast_lb'])) + if zone_ft3: + lines.append( + ' solid-zone members +{0:,.0f} lb'.format( + numbers['solid_zone_lb'])) + if wythe_summed: + # The breakdown above is the LARGEST solid's dimensions -- one wythe of + # a sandwich -- and does not sum to the reconstruction on a multi-solid + # body, so the reconstruction says how it was really built rather than + # letting the reader add up lines that cannot reach it. + lines.append( + ' = what we think it is {0:,.0f} lb ' + '(summed over every concrete wythe; the breakdown above is the ' + 'largest wythe alone)'.format(numbers['reconstructed_lb'])) + else: + lines.append( + ' = what we think it is {0:,.0f} lb'.format( + numbers['reconstructed_lb'])) + if numbers['solid_lb']: + lines.append(' its actual concrete solid {0:,.0f} lb' + .format(numbers['solid_lb'])) + if solid_note: + # What that number is OF, never the number on its own. A solid + # sum that does not say which solids it summed is exactly the + # claim nobody could check while an insulated body was being + # weighed one wythe at a time. + lines.append(' -- {0}'.format(solid_note)) + elif solid_why: + lines.append(' its actual concrete solid not established -- ' + '{0}'.format(solid_why)) + lines.append(' the model\'s weight parameter {0}'.format( + '{0:,.0f} lb'.format(numbers['stated_lb']) + if numbers['stated_lb'] else 'not set')) + lines.append('') + + if numbers['solid_lb']: + error = off(numbers['reconstructed_lb'], numbers['solid_lb']) + if error is not None and error > tolerance: + missed = numbers['reconstructed_lb'] - numbers['solid_lb'] + lines.append( + 'The blockouts were read wrong: what we reconstructed is ' + '{0:+.1%} off the piece\'s own solid, which is {1:,.0f} lb ' + 'of concrete {2}.'.format( + error if missed > 0 else -error, abs(missed), + 'we did not take out' if missed > 0 + else 'we took out twice')) + lines.append('') + lines.append( + 'Openings are read off the faces of the concrete solid. A ' + 'void modelled as a separate family, a recess that is not ' + 'full depth, or a non-rectangular opening will all land here.') + return False, '\n'.join(lines), numbers + else: + # NO MEASURED LEG, SO NO GATE. `solid` is the only one of the three + # read off the piece's own geometry rather than off its dimensions, + # and solid-vs-reconstructed is the comparison that catches a + # blockout read wrong -- which is the whole reason this is a refusal + # and not a warning. Passing on the two remaining legs would be a + # piece cleared by a check that never ran, and they can agree + # perfectly while the openings are misread, because both of them are + # built out of the same dimensions. + lines.append( + 'The piece\'s own concrete was never weighed, so the one ' + 'comparison that catches a blockout read wrong -- what we ' + 'reconstruct against what is actually modelled -- did not run. ' + 'The other two weights are both built from the same dimensions ' + 'and can agree while the openings are wrong.') + return False, '\n'.join(lines), numbers + + if numbers['stated_lb']: + reference = numbers['solid_lb'] or numbers['reconstructed_lb'] + error = off(reference, numbers['stated_lb']) + if error is not None and error > tolerance: + lines.append( + 'The geometry and the model\'s own weight parameter disagree ' + 'by {0:+.1%}. Either the parameter has not been recalculated, ' + 'or the unit weight is not what this piece is cast from.' + .format(error)) + return False, '\n'.join(lines), numbers + else: + lines.append('The panel has no weight parameter to check against.') + return False, '\n'.join(lines), numbers + + return True, '', numbers + + +def strip_private(job): + """The job as panellift reads it: no keys of ours left in it.""" + clean = dict((k, v) for k, v in job.items() if not k.startswith('_')) + clean['stages'] = [dict((k, v) for k, v in s.items() + if not k.startswith('_')) + for s in job.get('stages', [])] + return clean + + +# ------------------------------------------------------- running the maths +# +# The calculation is CPython. Revit's engine is IronPython 2.7 and cannot run +# it -- dataclasses and typing syntax alone rule that out -- and porting it +# would mean giving up the test suite that is the only reason to believe the +# numbers. So it runs as a subprocess, the way vibe.aiextract already runs the +# Claude CLI. + +# Windows inside Revit; the bare names let setup_machine.py --check and the +# host suite ask the same chooser on a Linux/macOS checkout. +_PYTHON_NAMES = (('python.exe', 'python3.exe') if os.name == 'nt' + else ('python3', 'python')) + + +def _python_dirs(): + local = os.environ.get('LOCALAPPDATA', '') + dirs = [] + base = os.path.join(local, 'Programs', 'Python') + if os.path.isdir(base): + try: + for name in sorted(os.listdir(base), reverse=True): + dirs.append(os.path.join(base, name)) + except Exception: + pass + for major in ('313', '312', '311', '310', '39'): + dirs.append('C:\\Python' + major) + dirs.append(os.path.join(os.environ.get('ProgramFiles', ''), + 'Python' + major)) + return dirs + + +# What a candidate has to prove before it is chosen. The snippet runs INSIDE +# the candidate: it reports its own version and which of the modules named on +# its argv it cannot import, on one stdout line, and exits non-zero when +# anything is missing. Gitea #16: `find_python()` used to take the first +# python.exe that EXISTED, and `_python_dirs()` lists the newest install +# folder first -- so a freshly installed Python313 with no packages won over +# the Python311 that had them, Panel Analysis' server died at `import yaml` +# before it printed its URL, and every mark went NOT RUN. Existing is not +# working; a candidate is chosen only after it has run this and passed. +_PROBE_SNIPPET = ( + "import sys\n" + "missing = []\n" + "for m in sys.argv[1:]:\n" + " try:\n" + " __import__(m)\n" + " except Exception:\n" + " missing.append(m)\n" + "sys.stdout.write('PY %d.%d.%d MISSING %s' % (\n" + " sys.version_info[0], sys.version_info[1], sys.version_info[2],\n" + " ','.join(missing)))\n" + "sys.exit(1 if missing else 0)\n" +) +MIN_PYTHON = (3, 9) +PROBE_TIMEOUT_MS = 15000 + +# Interpreters that passed a probe this session, keyed by (exe, needs). Only +# SUCCESSES are cached: a failure is re-probed every call, so `pip install` +# in another window fixes the next button press without a Revit restart. +# Panel Analysis asks once per mark, so an uncached success would cost a +# process start per mark for nothing. +_PROBE_OK = {} + + +def _run_probe(exe, needs): + """(exit_code, stdout) of the probe snippet under `exe`, or + (None, reason) when it could not be run at all. .NET inside Revit, + subprocess on the host -- the same call either way. One pipe only + (stdout), read to the end before the wait, so the two-pipe deadlock + `run()` warns about cannot happen here.""" + args = ['-c', _PROBE_SNIPPET] + list(needs) + try: + from System.Diagnostics import Process, ProcessStartInfo + except ImportError: + Process = None + if Process is not None: + info = ProcessStartInfo() + info.FileName = exe + info.Arguments = ' '.join(_win_quote(a) for a in args) + info.UseShellExecute = False + info.CreateNoWindow = True + info.RedirectStandardOutput = True + try: + process = Process.Start(info) + except Exception as exc: + return None, 'could not be started: {0}'.format(exc) + out = process.StandardOutput.ReadToEnd() + if not process.WaitForExit(PROBE_TIMEOUT_MS): + try: + process.Kill() + except Exception: + pass + return None, 'did not answer in {0} s'.format( + PROBE_TIMEOUT_MS // 1000) + return process.ExitCode, out + import subprocess + try: + proc = subprocess.Popen([exe] + args, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + out, _ = proc.communicate() + except Exception as exc: + return None, 'could not be started: {0}'.format(exc) + if not isinstance(out, str): + out = out.decode('utf-8', 'replace') + return proc.returncode, out + + +def _win_quote(arg): + """One argument for a Windows command line, the way CommandLineToArgvW + undoes it: wrapped in double quotes, a double quote inside escaped with a + backslash, and a run of backslashes doubled only where it sits before a + quote (or the closing one) -- elsewhere a backslash is literal. The + snippet is newlines and single quotes; module names are identifiers.""" + out = '"' + backslashes = 0 + for ch in arg: + if ch == '\\': + backslashes += 1 + continue + if ch == '"': + out += '\\' * (backslashes * 2 + 1) + '"' + else: + out += '\\' * backslashes + ch + backslashes = 0 + out += '\\' * (backslashes * 2) + '"' + return out + + +def _parse_probe(out): + """(version_tuple_or_None, missing_list) from the snippet's one line.""" + text = (out or '').strip() + if not text.startswith('PY '): + return None, [] + try: + rest = text[3:] + ver_text, marker, missing_text = rest.partition(' MISSING') + if not marker: + return None, [] + version = tuple(int(x) for x in ver_text.split('.')) + except Exception: + return None, [] + missing = [m for m in missing_text.strip().split(',') if m] + return version, missing + + +def probe_python(exe, needs=()): + """(ok, verdict) for one interpreter: does it run, is it MIN_PYTHON or + newer, and can it import every module in `needs`. The verdict is one + sentence naming what failed, because 'this python was rejected' with no + reason is exactly the silence the caller has to print past.""" + needs = tuple(needs) + key = (exe, needs) + if key in _PROBE_OK: + return True, _PROBE_OK[key] + code, out = _run_probe(exe, needs) + if code is None: + return False, out + version, missing = _parse_probe(out) + if version is None: + return False, ('did not answer the probe (exit {0}); not a CPython, ' + 'or one that cannot start'.format(code)) + ver_text = '.'.join(str(v) for v in version) + if version < MIN_PYTHON: + return False, 'Python {0}; {1}+ is required'.format( + ver_text, '.'.join(str(v) for v in MIN_PYTHON)) + if missing: + return False, 'Python {0} cannot import {1} (pip install {2})'.format( + ver_text, ', '.join(missing), ' '.join(_pip_name(m) for m in missing)) + verdict = 'Python {0}'.format(ver_text) + if needs: + verdict += ', imports ' + ', '.join(needs) + _PROBE_OK[key] = verdict + return True, verdict + + +_PIP_NAMES = {'yaml': 'pyyaml', 'fitz': 'pymupdf'} + + +def _pip_name(module): + return _PIP_NAMES.get(module, module) + + +def find_python(know=None, needs=()): + """CPython MIN_PYTHON+ that imports every module in `needs`, or None. + + `find_python_report` with the reasons thrown away. A caller that prints + a NOT RUN should use that one instead, so the message can name what was + tried and why each was turned down. + """ + return find_python_report(know, needs)[0] + + +def find_python_report(know=None, needs=(), probe=None): + """(exe_or_None, tried). `tried` is a list of (candidate, verdict), in + the order they were looked at; on success the last entry is the winner. + + A configured path wins -- `handling.python_exe` in the knowledge layers, + then WILLTOOLS_PYTHON -- and it is the ONLY candidate when it is set: an + interpreter somebody named by hand that is missing or fails the probe is + reported, never quietly swapped for whatever else is on the machine. The + run would use an interpreter nobody chose and look completely normal, + which is the same trap as a config pointer to a file that is gone. + + Then the usual install locations, then PATH, each probed by running it + (`probe_python`) and the first that PASSES wins -- not the first that + exists, which is what Gitea #16 was. The Microsoft Store aliases in + WindowsApps are skipped on purpose: they are zero-byte stubs that open + the Store instead of running anything, and a 'python' that exists and + does nothing is worse than one that is missing. + """ + probe = probe or probe_python + tried = [] + + stated = None + if know is not None: + stated = knowledge.get(know, 'handling.python_exe') + source = 'handling.python_exe' + if not stated: + stated = os.environ.get('WILLTOOLS_PYTHON') + source = 'WILLTOOLS_PYTHON' + if stated: + if not os.path.isfile(stated): + tried.append((stated, '{0} names a file that does not exist' + .format(source))) + return None, tried + ok, verdict = probe(stated, needs) + tried.append((stated, '{0}: {1}'.format(source, verdict))) + return (stated if ok else None), tried + + dirs = _python_dirs() + os.environ.get('PATH', '').split(os.pathsep) + seen = set() + for folder in dirs: + if not folder or 'WindowsApps' in folder: + continue + for name in _PYTHON_NAMES: + candidate = os.path.join(folder, name) + try: + if not (os.path.isfile(candidate) + and os.path.getsize(candidate)): + continue + except Exception: + continue + norm = os.path.normcase(os.path.abspath(candidate)) + if norm in seen: + continue + seen.add(norm) + ok, verdict = probe(candidate, needs) + tried.append((candidate, verdict)) + if ok: + return candidate, tried + return None, tried + + +def describe_python_search(tried, needs=()): + """The sentence a NOT RUN prints when no interpreter passed: every + candidate and why, and what would fix it.""" + if not tried: + return ('no CPython {0}+ was found in the usual install folders or ' + 'on PATH; install Python or set handling.python_exe in the ' + 'knowledge file'.format('.'.join(str(v) for v in MIN_PYTHON))) + lines = ['no CPython could be used; tried:'] + for exe, verdict in tried: + lines.append(' {0} -- {1}'.format(exe, verdict)) + lines.append('install the missing package into one of them, or set ' + 'handling.python_exe in the knowledge file to the one that ' + 'has it') + return '\n'.join(lines) + + +def panellift_src(): + """The panellift package directory that ships beside this extension.""" + here = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) # WillTools.extension + root = os.path.dirname(here) + return os.path.join(root, 'panellift', 'src') + + +def runner_script(python_exe, job_path, out_path, summary_path, log_path, + recommend=True): + """The one-line cmd wrapper, as text. + + Split out of `run()` so the command line can be checked without Revit. + `run()` itself imports System.Diagnostics and cannot be called off the + IronPython side at all, which meant the ONE string that decides what the + calculation is actually asked to do had no test on it. It has now: + `--no-recommend` has to be present when it was asked for and absent when + it was not, and a flag that silently never reaches the subprocess is a + setting that does nothing -- the exact trap CLAUDE.md names for the + project config file. + """ + return (u'@echo off\r\n' + u'chcp 65001 >nul\r\n' + u'set "PYTHONPATH={src}"\r\n' + u'set "PYTHONIOENCODING=utf-8"\r\n' + u'"{exe}" -m panellift "{job}" -o "{out}" --json{flags} ' + u'> "{summary}" 2> "{log}"\r\n' + u'exit /b %ERRORLEVEL%\r\n').format( + src=panellift_src(), exe=python_exe, job=job_path, + out=out_path, summary=summary_path, log=log_path, + flags=u'' if recommend else u' --no-recommend') + + +def run(python_exe, job_path, out_path, timeout_ms=900000, on_tick=None, + recommend=True): + """Run the calculation. Returns (exit_code, summary_or_None, stderr). + + A non-zero exit is not necessarily an error: the CLI exits 1 when a stage + fails its check, which is a result and not a crash. 2 means it could not + read the job at all. + + `recommend=False` adds `--no-recommend`, which switches the insert + POSITION SEARCH off. That search is 93% of the run -- 12.6 s of a 13.6 s + median piece -- and it is the only thing the flag changes: every stage is + still analysed, every check still runs and the verdict is the same. What + the report loses is the "then where?" half, and it says so where the + positions would have been rather than leaving a blank section, because a + section that quietly vanishes reads as a piece with nothing to recommend. + Default True, so a caller that has not thought about it gets the whole + answer. + + TWO PIPES ARE NOT USED, and that is deliberate. Reading one to the end and + then the other deadlocks the moment the child writes more to the second + than its buffer holds: the child blocks writing stderr, the parent blocks + reading stdout, and neither ever moves. Four kilobytes of traceback is + enough. Worse, the WaitForExit timeout after those reads is unreachable, + so the guard that was supposed to catch it could never fire. + + A one-line cmd wrapper redirects both streams to files instead. Nothing + can fill, nothing can deadlock, and an import error that never reaches + Python's own error handling still lands in the log. + + on_tick(elapsed_ms) is called about four times a second while waiting. The + caller uses it to keep a progress bar alive: this runs on Revit's UI + thread, and a five minute wait with a frozen window is not distinguishable + from a crash by the person looking at it. + """ + import json as _json + from System.Diagnostics import Process, ProcessStartInfo + + base = os.path.splitext(out_path)[0] + summary_path = base + '.summary.json' + log_path = base + '.log' + runner = base + '.run.cmd' + + with io.open(runner, 'w', encoding='utf-8') as fh: + fh.write(runner_script(python_exe, job_path, out_path, summary_path, + log_path, recommend=recommend)) + + info = ProcessStartInfo() + info.FileName = 'cmd.exe' + info.Arguments = '/c "{0}"'.format(runner) + info.UseShellExecute = False + info.CreateNoWindow = True + + process = Process.Start(info) + waited = 0 + while not process.WaitForExit(250): + waited += 250 + if on_tick is not None: + on_tick(waited) + if waited >= timeout_ms: + try: + process.Kill() + except Exception: + pass + return -1, None, ('the calculation did not finish in {0} minutes' + .format(timeout_ms // 60000)) + + def _read(path): + try: + with io.open(path, 'r', encoding='utf-8', errors='replace') as fh: + return fh.read() + except Exception: + return '' + + stdout, stderr = _read(summary_path), _read(log_path) + summary = None + if stdout.strip(): + try: + summary = _json.loads(stdout) + except Exception: + stderr = (stderr or '') + u'\n--- stdout ---\n' + stdout + for path in (runner, summary_path): + try: + os.remove(path) + except Exception: + pass + return process.ExitCode, summary, stderr + + +def open_file(path): + """Hand a finished file to whatever the machine opens it with. + + A report nobody looks at is not a report, and the gap between "written" + and "read" is one double-click that is easy not to take. Failing to open + it is not worth an error: the path is printed either way. + """ + from System.Diagnostics import Process, ProcessStartInfo + try: + info = ProcessStartInfo() + info.FileName = path + info.UseShellExecute = True # let the shell pick the browser + Process.Start(info) + return True + except Exception: + return False diff --git a/WillTools.extension/lib/vibe/pa_criteria.py b/WillTools.extension/lib/vibe/pa_criteria.py new file mode 100644 index 0000000..afb3de2 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_criteria.py @@ -0,0 +1,944 @@ +# -*- coding: utf-8 -*- +u"""The Design Criteria section of the Panel Analysis input form. + +WHY THIS FILE EXISTS: the owner rejected the old Panel Analysis form three +times running -- "太丑了" (too ugly), "你这么输入用户上哪儿能看懂" (input like +this, where would a user even understand it), "现在这个panel analysis做的也太丑 +了" (even now it is still too ugly) -- and then gave the fix in one sentence: +"全部给我做成普通人能看懂的样子", make every part of it something an ordinary +person can understand. The form is being rebuilt to look like Eriksson Wall's +own printed pages, split one Eriksson section per file so eight agents can +build it at once without stepping on each other. This file owns exactly one +of those sections: **Design Criteria**, which on Eriksson's own printout is +four blocks stacked on one page -- Project Information, Material Properties, +Load Combinations, Settings -- not four separate sections. `page()` below +returns all four, in that order, as one flat block list. + +`page()` NEVER RAISES. A form button that throws mid-render leaves the owner +looking at a stack trace instead of a form, which is worse than an ugly form +-- so every lookup here is defensive, and anything this module could not lay +out is named in the `problems` list it returns instead. + +THE SHAPE OF THE OUTPUT is the block vocabulary the caller's generic WPF +renderer understands (`heading`, `columns`, `field`, `kv`, `sentence`, +`table`, `lc_table`, `rule`, `space`) and nothing else -- an unknown `kind` renders as +nothing, so a typo here is a silently missing block, not an error. There is +no test for "the renderer draws this correctly" (there is no renderer to run +against on host CPython, and Revit is not running for this module to check +against) -- only for "every block this function returns is a well-formed +member of that vocabulary." + +WHERE THE FIELD TEXT COMES FROM. This file authors its own `label` / `symbol` +/ `hint` for every field, rather than importing them from +`vibe.panelanalysisform`. Two reasons, both load-bearing: + +1. Another agent is restructuring `panelanalysisform.FIELDS` while this file + is being written, so importing text from it ties this module's stability + to a moving target -- exactly what "the block list is stable" (tested + below) is supposed to rule out. +2. Roughly half of what Eriksson's own Design Criteria page prints has NO + key in `panelanalysisform.FIELDS` at all yet (project identity, concrete + elastic modulus, deflection-limit checkboxes, stress-limit checkboxes, + resistance-factor splits, development multipliers by strand type, ...). + Those need a label and a hint regardless of whether the backing field + exists, so authoring locally is the only option that covers both cases the + same way. + +`panelanalysisform` IS still consulted, but only for one yes/no fact per +field: does this key already exist in `FIELDS`. That answer decides EXISTS +vs. MISSING for the `problems` list, per the owner's instruction that a field +whose key does not exist yet degrades to an empty editor plus a named +problem, never to a vanished block. The import is wrapped in `try/except +Exception` (not just `ImportError`) because a sibling agent editing that file +right now can leave it syntactically broken for a moment, and this module +must keep rendering through that -- "could not judge whether the key exists" +degrades to "treat it as missing", the same discipline CLAUDE.md's +`same_face()` example applies to an unrecognised abbreviation. + +WHY THE 3-COLUMN CONCRETE GRID AND THE REBAR/MESH/STRAND BLOCK ARE BUILT WITH +`columns`, NOT `table`. Eriksson prints both as grids, and the block +vocabulary's `table` kind was the first thing tried -- but a `table` cell is +only ever a plain string or a `{'editor': 'check', ...}` tick box; there is no +way to put a live numeric/text editor inside one. Every value in those two +grids (f′ci, f′c, E′ci, E′c, unit weight, concrete type, fy, Es, fpu, Ep) is +something the user can actually type, so they are laid out as `columns` of +`field` blocks that mirror Eriksson's rows and column groupings instead. The +Load Combinations tables used `table` for exactly that reason, back when the +only editable thing per row was the Include tick. They no longer do: the owner +ruled (2026-09-03) that the ACI factors themselves are editable and that a user +can add a combination of their own, so they emit `lc_table` -- the same grid, +but every factor cell carries a live numeric editor and the block names its +factor columns so the renderer can build an added row. The DEFAULT factors are +public ACI 318 code text (not this job's data) and now live in +`panelanalysisform.LOAD_COMBO_ROWS`, beside the keys they are stored under, +rather than in this file. + +UPDATE 2026-09-04, RESISTANCE FACTORS IS THE ONE EXCEPTION TO ALL OF THAT. A +headless render of this page (WPA655) showed every phi editor squeezed to zero +width: that block was a `columns` of two sub-columns ("Developed" | +"Transferring"), each holding `field` blocks, nested inside `col_c` of the +Settings section's own three-way split -- so each phi row's `_two_col_row` (the +renderer's fixed-pixel `label* | symbol(66) | editor(138) | annot(150)` grid, +see `script.py`) needed >450px it never had once two levels of column-halving +were done to it. `_render_table`'s cell handling was generalised for `lc_table` +(any editor kind, not just a tick, keyed per cell) and that generalisation +reads for a plain `table` too -- its grid columns are ordinary (unconstrained) +`ColumnDefinition`s, not `_two_col_row`'s fixed pixels, so a cell's editor gets +real room regardless of how many columns the table has. Resistance Factors is +now a `table`: rows Bending/Compression/Shear, columns Developed/Transferring, +built by `_cell(F(key))` so the existing missing-field `problems` bookkeeping +in `_field()` still runs even though the field block itself is only used for +its `editor`/`key`/`choices`, never rendered directly -- which does mean these +five phi keys carry no Notes-panel hint any more (`_render_field` is what +files a field's hint into the notes; a bare table cell dict has no hook for +that). The Concrete / Rebar-Mesh-Strand grids above stay on `columns` +deliberately -- they are not being squeezed the way Resistance Factors was. + +PSI VS. KSI. `panelanalysisform.FIELDS` keeps concrete/rebar/strand strength +keys in **psi** (`fc_psi`, `fci_psi`, `rebar_fy_ksi` is the one already in +ksi, `ps_fpu_ksi` likewise, `strand_ep_ksi` likewise -- the module is not +perfectly consistent about it, and this file does not try to fix that, only +to read it correctly field by field). Eriksson prints everything in **ksi**. +Per the assignment: keep the psi keys as the source of truth and put the ksi +reading in the field's `symbol` or `hint` instead of introducing a second, +parallel set of ksi keys nobody would keep in sync. Concretely: `fc_psi` and +`fci_psi` keep their psi keys and their `hint` says "Eriksson prints this +divided by 1000, in ksi" so the two reconcile by eye against a printed sheet; +the keys that are already ksi (`rebar_fy_ksi`, `rebar_es_ksi`, `ps_fpu_ksi`, +`strand_ep_ksi`) need no such note. + +WHY EXTERIOR SOIL / INTERIOR SOIL ARE `sentence` BLOCKS, NOT `field` BLOCKS. +On Eriksson's own page these are not something the user types on THIS page at +all -- they are a computed caption ("Design contains no exterior soil loads") +that reads back whatever soil-load rows exist elsewhere in the model. Nothing +in this codebase collects soil-load rows anywhere yet, so there is no field +for this section to degrade to an "empty editor" for -- there is no editor to +have. Rendering it as a static `sentence` instead of inventing a text field +that would silently claim to collect something it cannot is the same +discipline as `knowledge.same_face()` returning `None` rather than `False`: +"cannot judge, and does not pretend to." One `problems` entry names this once +for both columns, so the gap is visible without repeating the same sentence +twice. + +THE R-VALUE / MINIMUM-REINFORCEMENT PAIRS mirror Eriksson's own pattern: a +checkbox that turns a calculation on, printed beside the sentence that +applies when it is off ("R-Value not being calculated"). Both checkbox keys +are new (`design_criteria.rvalue_calc_enabled`, +`design_criteria.min_reinf_checked` -- see the naming note below) and both +degrade the same way every other missing key does: an empty `check` editor +plus a named `problems` entry, with the off-state sentence always shown +underneath since "unchecked" is this page's honest default until the field +exists to say otherwise. + +NAMING STYLE FOR NEW KEYS. `panelanalysisform` already has a dotted +namespacing convention for one composite input (`stage..`, +see `stage_field_key`). New keys here that do not fit a single flat name +follow the same shape: `design_criteria.` for one-off Settings/Project +fields (e.g. `design_criteria.analysis_beta_d`), and +`load_combo...include` / +`load_combo..min_earth_factor` for the Load Combinations +grid, mirroring `stage_field_key`'s `..` shape exactly. + +IRONPYTHON 2.7: no f-strings, no walrus, no typing syntax; string formatting +is `'{0}'.format(...)`; every literal that carries the prime character or a +non-ASCII letter is a `u'...'` literal with the coding cookie above declaring +the file UTF-8, the same convention `panelanalysisform.py` uses throughout. +Imports NOTHING beyond the standard library and, defensively, +`vibe.panelanalysisform` for key-existence checks only -- no WPF, no Revit, +no `panelsketch`, so this loads and runs on plain CPython the way +`tests/conftest.py` expects. +""" + +# ------------------------------------------------------------- key existence + +def _existing_keys(): + """The set of keys `panelanalysisform.FIELDS` already declares, or None. + + None means "could not judge" -- the import failed, or the module's shape + changed out from under us mid-refactor -- and every caller below treats + None the same as "key not found": every field on this page degrades to + an empty editor plus a `problems` entry rather than silently assuming + the other agent's in-flight edit added the key. Broad `except Exception` + on purpose: a sibling agent saving a half-written `panelanalysisform.py` + can raise a SyntaxError, not just ImportError, and this page must keep + rendering through that. + """ + try: + from vibe import panelanalysisform as pf + return set(f['key'] for f in pf.FIELDS) + except Exception: + return None + + +# ------------------------------------------------------------------- fields +# +# One entry per field this page can lay out: key -> (label, symbol, hint, +# required, editor, choices). Authored locally -- see the module docstring's +# "WHERE THE FIELD TEXT COMES FROM" for why. `required` is True only for the +# two keys `panelanalysisform` itself already gates the Run button on +# (`fc_psi`, `unit_weight_pcf`); every other field here is optional, matching +# the rest of Design Criteria in that module. +_F = { + # --------------------------------------------------- Project Information + 'project_name': ( + 'Project name', '', + 'The name of the project this panel belongs to, for the record on ' + 'this printout. Leave blank if the project has no name yet.', + False, 'text', None), + 'client_name': ( + 'Client name', '', + "The client this panel's project is for. Leave blank if unknown.", + False, 'text', None), + 'project_description': ( + 'Project description', '', + 'A short free-text description of the project. Leave blank if ' + 'there is nothing to add.', + False, 'text', None), + 'project_location': ( + 'Project location', '', + 'Where the project is built -- city, state, or site name. Leave ' + 'blank if unknown.', + False, 'text', None), + 'engineer_initials': ( + 'Engineer of record', '', + 'Who is responsible for this design. Leave blank if not yet ' + 'assigned.', + False, 'text', None), + 'project_date': ( + 'Design date', '', + 'The date this design applies to. Leave blank to leave it unset.', + False, 'text', None), + 'job_number': ( + 'Job number', '', + "This job's internal tracking number, if it has one. Leave blank " + 'if none has been assigned yet.', + False, 'text', None), + 'design_spec': ( + 'Governing design code', '', + 'Which code edition this design is checked against. Leave blank ' + 'to leave it unstated.', + False, 'choice', ('ACI 318-19', 'ACI 318-14', 'ACI 318-11')), + # ------------------------------------------- Material Properties/Concrete + 'fci_psi': ( + 'Concrete strength at release', u"f′ci (psi)", + u"How strong the concrete must be when the strand is cut or the " + u"panel is stripped from the bed -- usually lower than the 28-day " + u"strength. Eriksson prints this divided by 1000, in ksi. Leave " + u"blank if this panel is not prestressed.", + False, 'num', None), + 'fc_psi': ( + 'Concrete strength at 28 days', u"f′c (psi)", + u"The concrete's design strength once fully cured; every stress " + u"limit on this page is a multiple of this number. Eriksson prints " + u"this divided by 1000, in ksi. Required -- the solve cannot run " + u"without it.", + True, 'num', None), + # Both moduli are DERIVED (`panelanalysisform.DERIVED_FIELDS`, ACI 318 + # 19.2.2, Ec = 33 wc^1.5 sqrt(f'c)) and the page keeps them live as + # f'c / f'ci / unit weight are edited until the user types into one + # (issue #22, item 1). The old text here said a rule was still to be + # agreed; it had been in the field model since 2026-09-03. The solve + # still does not read either -- `panellift` computes its own -- and the + # hint says so, so the page mutes them honestly. + 'conc_eci_psi': ( + 'Concrete elastic modulus at release', u"E′ci (psi)", + u"How stiff the concrete is at release strength. Computed by ACI " + u"318 from f′ci and the unit weight (Ec = 33 wc^1.5 sqrt(f′ci)) and " + u"kept up to date as you edit those; type a value to override it. " + u"Not yet used by the solve.", + False, 'num', None), + 'conc_ec_psi': ( + 'Concrete elastic modulus, in-place', u"E′c (psi)", + u"How stiff the concrete is once fully cured. Computed by ACI 318 " + u"from f′c and the unit weight (Ec = 33 wc^1.5 sqrt(f′c)) and kept " + u"up to date as you edit those; type a value to override it. Not " + u"yet used by the solve.", + False, 'num', None), + 'unit_weight_pcf': ( + 'Concrete unit weight', 'pcf', + "How much the concrete weighs per cubic foot; the panel's own " + 'weight is computed from this. Required.', + True, 'num', None), + 'concrete_type': ( + 'Concrete type', '', + 'What kind of concrete this panel is cast from (normal weight, ' + 'lightweight, ...). Reference only -- nothing computes from it ' + 'yet.', + False, 'choice', ('Normalweight', 'Lightweight', 'Sand-Lightweight')), + # ----------------------------------------------- Material Properties/Bar + 'rebar_fy_ksi': ( + 'Rebar yield strength', 'fy (ksi)', + 'The stress at which ordinary (non-prestressed) reinforcing bar ' + 'starts to yield. Shown for the record; not yet used by the ' + 'solve.', + False, 'num', None), + 'rebar_es_ksi': ( + 'Rebar elastic modulus', 'Es (ksi)', + 'How stiff ordinary reinforcing steel is. Shown for the record; ' + 'not yet used by the solve.', + False, 'num', None), + 'ps_fpu_ksi': ( + u'Strand ultimate strength', u'fₚᵤ (ksi)', + 'The breaking strength of the prestressing strand. Leave blank if ' + 'this panel is not prestressed.', + False, 'num', None), + 'strand_ep_ksi': ( + 'Strand elastic modulus', 'Ep (ksi)', + 'How stiff the prestressing strand is. Shown for the record; not ' + 'yet used by the solve.', + False, 'num', None), + # ----------------------------------------------- Material Properties/Mesh + 'mesh_fy_ksi': ( + 'Mesh yield strength', 'fy (ksi)', + 'The yield strength of the welded wire mesh. Defaults to the ' + 'common 80 ksi. Shown for the record; not yet used by the solve.', + False, 'num', None), + 'mesh_es_ksi': ( + 'Mesh elastic modulus', 'Es (ksi)', + 'How stiff the welded wire mesh is. Defaults to 29000 ksi. Shown ' + 'for the record; not yet used by the solve.', + False, 'num', None), + # -------------------------------------------------------- Load combos + 'design_criteria.min_earth_factor_strength': ( + 'Minimum earth factor, strength combinations', '', + 'The smallest lateral-earth-pressure factor applied across the ' + 'strength combinations below. Leave blank to use none.', + False, 'num', None), + 'design_criteria.min_earth_factor_service': ( + 'Minimum earth factor, service combinations', '', + 'The smallest lateral-earth-pressure factor applied across the ' + 'service combinations below. Leave blank to use none.', + False, 'num', None), + # --------------------------------------------------- Settings/Deflection + 'deflection_limit': ( + 'Typical deflection limit', 'L / n', + 'How much the panel is allowed to bow or sag, as a fraction of its ' + 'span, e.g. "150" for L/150. Read into the in-place service check; ' + 'blank leaves that check NOT JUDGED.', + False, 'text', None), + 'design_criteria.deflection_add_bow': ( + 'Add an initial bow to reported deflections?', '', + 'Whether the panel is assumed to already be slightly bowed before ' + 'any load is applied, and that starting bow is added to every ' + 'reported deflection. Leave unchecked to assume none.', + False, 'check', None), + 'design_criteria.deflection_additional_bow_in': ( + 'Additional bow assumed', 'in', + 'How much starting bow to add, if the box above is checked. Leave ' + 'blank for none.', + False, 'num', None), + 'design_criteria.deflection_check_cantilever': ( + 'Check deflections on cantilevers?', '', + 'Whether cantilevered portions of the panel get their own ' + 'deflection check, separate from the typical limit above. Leave ' + 'unchecked to skip it.', + False, 'check', None), + 'design_criteria.deflection_cantilever_limit': ( + 'Cantilever deflection limit', 'L / n', + 'The separate deflection limit for cantilevered portions, if the ' + 'box above is checked.', + False, 'choice', ('L/75', 'L/120', 'L/150')), + # ---------------------------------------------- Settings/Structural Anal. + 'second_order': ( + '2nd-order effects considered?', '', + 'Whether P-delta / second-order bending is considered for this ' + 'panel. Not yet used by the solve.', + False, 'text', None), + 'cracking_assumption': ( + 'Cracked or uncracked section assumed', '', + 'Whether the stress check assumes the concrete section is cracked ' + 'or uncracked. Read into the in-place service check as the ' + 'cracking class.', + False, 'choice', ('Cracked', 'Uncracked')), + 'design_criteria.analysis_beta_d': ( + u'Sway condition for 2nd-order analysis', u'βd', + 'Whether the 2nd-order analysis above treats this panel as braced ' + '(non-sway) or unbraced (sway). Leave blank if not applicable.', + False, 'choice', ('Non-Sway', 'Sway')), + 'design_criteria.cracking_method': ( + 'Cracking method', '', + 'Which published method decides whether the section is treated as ' + 'cracked. Leave blank if not applicable.', + False, 'choice', ('Mast Method', 'Uncracked', 'Cracked')), + 'design_criteria.cracking_limit_sqrt_fc': ( + u'Cracking stress limit', u"n·√f′c", + u"The stress, as a multiple of √f′c, at which the " + u"section is assumed to crack. Leave blank to use the built-in " + u"default.", + False, 'num', None), + 'design_criteria.rvalue_calc_enabled': ( + 'Calculate an R-value for this panel?', '', + 'Whether an insulation R-value is calculated for this panel. Leave ' + 'unchecked if it is not being calculated -- that is the default.', + False, 'check', None), + 'design_criteria.min_reinf_checked': ( + 'Check minimum reinforcement?', '', + 'Whether the ACI minimum-steel requirement is checked for this ' + 'panel. Leave unchecked if it is not being checked -- that is the ' + 'default.', + False, 'check', None), + # -------------------------------------------------- Settings/Stress opts + 'design_criteria.stress_failure_if_cracked': ( + 'Fail the panel if it cracks?', '', + 'Whether a cracked section is treated as an outright failure ' + 'rather than just a flag. Leave unchecked to only flag it.', + False, 'check', None), + 'design_criteria.stress_ignore_lateral_ecc': ( + 'Ignore lateral strand eccentricity?', '', + 'Whether side-to-side strand placement is ignored when computing ' + 'stress. Leave unchecked to include it.', + False, 'check', None), + 'design_criteria.stress_use_principal_axis': ( + 'Use the principal axis for stress?', '', + 'Whether stress is computed about the section\'s principal axis ' + 'rather than its geometric one. Leave unchecked to use the ' + 'geometric axis.', + False, 'check', None), + # Eriksson prints these as three rows under one "In-Place Stress Limits" + # heading -- Tension (uncracked), Tension (transition / modulus of + # rupture), Compression -- and the review on 2026-09-04 (WPA655) found + # this page missing the first of the three (`inplace_tension_sqrt` is a + # real `panelanalysisform.FIELDS` key, just never surfaced here) and + # labelling the other two by what they compute rather than by Eriksson's + # own row names. All three keep the naming scheme now. + 'inplace_tension_sqrt': ( + u'Tension, uncracked', u"n·√f′c", + u"The concrete's tensile stress limit once erected, while the " + u"section is still assumed uncracked, as a multiple of √f′c. " + u"Leave blank to use the built-in default.", + False, 'num', None), + 'design_criteria.inplace_modulus_rupture_sqrt_fc': ( + u'Tension, transition (modulus of rupture)', u"n·√f′c", + u"The concrete's tensile cracking stress once erected, as a " + u"multiple of √f′c. Leave blank to use the built-in " + u"default.", + False, 'num', None), + 'inplace_comp_ratio': ( + u'Compression', u"n·f′c", + u"The compression limit for the panel once it is standing in " + u"place, as a fraction of f′c. Not yet used by the solve.", + False, 'num', None), + 'handling_tension_sqrt': ( + 'Tension allowed while handling', u"n·√f′ci", + u"How much the concrete may be pulled apart during stripping / " + u"handling, as a multiple of √f′ci. Blank uses the " + u"built-in default.", + False, 'num', None), + 'handling_comp_ratio': ( + 'Compression allowed while handling', u"n·f′ci", + u"How much the concrete may be squeezed during handling, as a " + u"fraction of f′ci. Blank uses the built-in default.", + False, 'num', None), + 'design_criteria.flexure_method': ( + 'Flexure calculation method', '', + 'Which method computes bending capacity. Leave blank to use the ' + 'built-in default.', + False, 'choice', ('Whitney Stress Block', 'Strain Compatibility')), + 'design_criteria.flexure_ignored_end_distance_in': ( + 'Ignored end distance', 'in', + 'A length at each end of the panel excluded from the flexure ' + 'check (strand not yet developed there). Leave blank for none.', + False, 'num', None), + 'design_criteria.flexure_cracking_check': ( + 'Flexural cracking check', '', + 'Which cracking check applies to the flexure calculation, if any. ' + 'Leave blank for none.', + False, 'choice', ('None', '1.2 Mcr', '1.33 Mu')), + # ------------------------------------------------ Settings/Resist. Fact. + 'phi_flexure': ( + 'Bending strength reduction factor, developed', u'φ, tension', + "The ACI safety factor applied to the panel's bending strength " + 'once the strand is fully developed. Blank uses the built-in ' + 'default (0.90).', + False, 'num', None), + 'design_criteria.phi_compression': ( + 'Compression strength reduction factor, developed', + u'φ, compression', + "The ACI safety factor applied to the panel's compression " + 'strength once the strand is fully developed. Leave blank to use ' + 'the built-in default.', + False, 'num', None), + 'phi_shear': ( + 'Shear strength reduction factor, developed', u'φ, shear', + "The ACI safety factor applied to the panel's shear strength once " + 'the strand is fully developed. Blank uses the built-in default ' + '(0.75).', + False, 'num', None), + 'design_criteria.phi_tension_transfer': ( + 'Bending strength reduction factor, transferring', + u'φ, tension', + 'The same safety factor as above, but while the strand is still ' + 'transferring its force into the concrete. Leave blank if not ' + 'applicable.', + False, 'num', None), + 'design_criteria.phi_shear_transfer': ( + 'Shear strength reduction factor, transferring', u'φ, shear', + 'The same safety factor as above, but while the strand is still ' + 'transferring its force into the concrete. Leave blank if not ' + 'applicable.', + False, 'num', None), + 'dev_mult': ( + 'Strand development multiplier, normal', '', + 'A multiplier on the strand development length for ordinary ' + '(non-debonded, uncut) strand. Blank uses the built-in default ' + '(1.0).', + False, 'num', None), + 'design_criteria.dev_mult_debonded': ( + 'Strand development multiplier, debonded', '', + 'The same multiplier, for strand that is deliberately debonded ' + 'over part of its length. Leave blank to use the built-in ' + 'default.', + False, 'num', None), + 'design_criteria.dev_mult_cut': ( + 'Strand development multiplier, cut', '', + 'The same multiplier, for strand that is field-cut rather than ' + 'released normally. Leave blank to use the built-in default.', + False, 'num', None), + 'design_criteria.dev_mult_mild': ( + 'Development multiplier, mild reinforcement', '', + 'The same idea applied to ordinary (non-prestressed) reinforcing ' + 'bar. Leave blank to use the built-in default.', + False, 'num', None), + 'design_criteria.shear_treat_as_column': ( + 'Treat this panel as a column for shear?', '', + 'Whether the shear check uses column rules rather than wall ' + 'rules. Leave unchecked to use wall rules.', + False, 'check', None), + 'design_criteria.axial_ignore_tensile_failures': ( + 'Ignore axial tensile-strength failures?', '', + 'Whether the axial check is allowed to pass even when the ' + "concrete's tensile strength alone is exceeded. Leave unchecked " + 'to flag them.', + False, 'check', None), + 'design_criteria.axial_ignore_buckling_failures': ( + 'Ignore buckling failures?', '', + 'Whether the axial check is allowed to pass even when the panel ' + 'would buckle. Leave unchecked to flag them.', + False, 'check', None), +} + +# Prestress-loss group: shown as a sentence when every one of these is blank +# (see `_group_or_sentence`), matching Eriksson's own "nothing entered here" +# style for Exterior/Interior Soil, applied to the one other Design Criteria +# group that is entirely optional and entirely empty on a fresh panel. +_PRESTRESS_LOSS_FIELDS = ( + 'ps_loss_method', 'ps_loss_humidity_pct', + 'ps_loss_release_hours', 'ps_loss_erection_days', +) +_F['ps_loss_method'] = ( + 'Prestress loss method', '', + 'Which published method (PCI, AASHTO, ...) the assumed loss below ' + 'comes from. Reference only.', + False, 'text', None) +_F['ps_loss_humidity_pct'] = ( + 'Ambient humidity assumed', '%', + 'The relative humidity the loss method uses -- losses are larger in ' + 'drier air. Not yet used by the solve.', + False, 'num', None) +_F['ps_loss_release_hours'] = ( + 'Hours from casting to release', '', + 'How many hours after casting the strand is cut / the panel is ' + 'stripped. Not yet used by the solve.', + False, 'num', None) +_F['ps_loss_erection_days'] = ( + 'Days from casting to erection', '', + 'How many days after casting the panel is erected in place. Not yet ' + 'used by the solve.', + False, 'num', None) + + +def _field(key, problems, seen, existing): + """One `field` block for `key`, plus a `problems` entry if it is missing. + + `seen` de-dupes: several groups below reuse a couple of keys (none do + today, but the guard costs nothing and keeps a future edit from quietly + filing the same problem twice). `existing` is `_existing_keys()`'s + result, threaded through rather than re-read per field so one broken + import during a render is reported once, not dozens of times. + """ + spec = _F.get(key) + if spec is None: + # Programming error inside this file, not a missing form key -- + # still degrade rather than raise, and say so plainly. + problems.append( + "Design Criteria has no text authored for the key '{0}' -- " + 'this is a bug in pa_criteria.py, not a missing form field.' + .format(key)) + return {'kind': 'field', 'key': key, 'label': key, 'symbol': None, + 'hint': None, 'required': False, 'editor': 'text', + 'choices': None} + label, symbol, hint, required, editor, choices = spec + if existing is None or key not in existing: + if key not in seen: + seen.add(key) + problems.append( + "'{0}' ({1}) has no backing field in panelanalysisform.FIELDS " + 'yet -- shown as an empty editor until it is added.' + .format(label, key)) + return {'kind': 'field', 'key': key, 'label': label, + 'symbol': symbol or None, 'hint': hint, 'required': required, + 'editor': editor, 'choices': list(choices) if choices else None} + + +def _heading(text, level): + return {'kind': 'heading', 'text': text, 'level': level} + + +def _cols(columns): + return {'kind': 'columns', 'columns': columns} + + +def _sentence(text): + return {'kind': 'sentence', 'text': text} + + +def _rule(): + return {'kind': 'rule'} + + +def _space(h=1): + return {'kind': 'space', 'h': h} + + +def _table(headers, rows, header_groups=None): + return {'kind': 'table', 'headers': list(headers), + 'header_groups': header_groups, 'rows': [list(r) for r in rows]} + + +def _cell(field_block): + """A `table` cell dict for one `_field()` result -- the Resistance + Factors table (see the module docstring's 2026-09-04 update) reuses + `_field()` only for its `editor` / `key` / `choices` and the + missing-backing-field `problems` entry it already files, never for the + `field` block itself (a bare table cell has nowhere to put a label or a + hint, so those are simply not repeated here).""" + return {'editor': field_block['editor'], 'key': field_block['key'], + 'choices': field_block['choices']} + + +def _group_or_sentence(values, keys, empty_text, build_filled): + """[blocks] -- a `sentence` when every key in `keys` is blank, else + whatever `build_filled()` returns. + + This is the "nothing here vs. never asked" split applied at group + granularity: `keys` all being blank in `values` is a real, filled-in + answer ("nothing entered"), not the absence of a form -- so it is shown + as a plain sentence instead of a wall of empty boxes, the same way + Eriksson's own printout shows a caption instead of blank inputs when + there is nothing to report. `values` is read with `.get(...)`, never + indexed, and never mutated. + + `build_filled` is a zero-argument CALLABLE, not a plain list -- the + filled-in branch typically calls `F(key)`, which files a `problems` + entry for any key still missing from `panelanalysisform.FIELDS`, and + that side effect must only happen for fields actually being shown. A + plain list argument would be built eagerly by the caller before this + function ever looks at `keys`, filing a phantom "missing field" problem + for a field the empty-sentence branch never renders. + """ + for k in keys: + v = values.get(k) + if v is not None and str(v).strip(): + return build_filled() + return [_sentence(empty_text)] + + +# --------------------------------------------------------- load combinations +# +# Standard ACI 318-14 basic strength/service combinations -- public code +# text, the same eight/ten rows any ACI-compliant tool would print, not this +# job's data. +# +# THESE ARE NOW A FALLBACK, not the source. The live copy is +# `panelanalysisform.LOAD_COMBO_ROWS`, because the factors became editable form +# VALUES and a value needs a key, which is that module's business. These stay +# because this file's whole discipline is that a sibling module mid-edit must +# not take the page down: if the import fails, the table still prints the ACI +# defaults (as plain text, with a named problem saying it cannot be edited) +# instead of vanishing. Keep the two in step -- a host test compares them. +_STRENGTH_COMBOS = ( + ('1', '1.4', '0', '0', '0', '0', '1', '1.6'), + ('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'), + ('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'), + ('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'), + ('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'), + ('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'), + ('7', '0.9', '0', '0', '1', '0', '1', '1.6'), + ('8', '0.9', '0', '0', '0', '1', '1', '1.6'), +) +_SERVICE_COMBOS = ( + ('1', '1', '0', '0', '0', '0', '0.75', '1'), + ('2', '1', '1', '0', '0', '0', '0.75', '1'), + ('3', '1', '0', '1', '0', '0', '0.75', '1'), + ('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'), + ('5', '1', '0', '0', '0.6', '0', '0.75', '1'), + ('6', '1', '0', '0', '0', '0.7', '0.75', '1'), + ('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'), + ('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'), + ('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'), + ('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'), +) +_COMBO_HEADERS = ['#', 'D', 'L', 'R', 'W', 'E', 'T', 'H', 'Include'] + + +def _combo_table(name, values, problems): + """One EDITABLE load-combination table, as an `lc_table` block. + + Not a plain `table` any more. The owner ruled (2026-09-03) that the load + factors are the user's to change and that a row can be added, and a `table` + cell is only ever a string or a tick box -- so this emits `lc_table`, whose + factor cells each carry an `{'editor': 'num', 'key': ...}` the renderer + turns into a live box, plus the `factors` list the renderer's "Add LC" + button needs to build a new row's cells. + + The FACTORS themselves come from `panelanalysisform.LOAD_COMBO_ROWS`, which + is the one place that knows both the ACI default and the key it is stored + under. The local tuples stay as a fallback for the case this file already + handles everywhere else -- the sibling module mid-edit and unimportable -- + so a broken import degrades this page to the code defaults with no live + keys rather than to no table at all. + + `values` is accepted (and read only for the user's own added rows) rather + than the row tuples the old signature took: the rows are no longer this + file's to decide. + """ + values = values if isinstance(values, dict) else {} + fallback = {'strength': _STRENGTH_COMBOS, 'service': _SERVICE_COMBOS} + factors = list(_COMBO_HEADERS[1:-1]) + try: + from vibe import panelanalysisform as paf + factors = list(paf.LOAD_COMBO_FACTORS) + row_defaults = dict((r[0], r) for r in paf.LOAD_COMBO_ROWS.get(name, ())) + row_ids = paf.load_combo_row_ids(name, values) + factor_key = paf.load_combo_factor_key + include_key = paf.load_combo_include_key + except Exception: + problems.append( + 'could not read panelanalysisform for the {0} load-combination ' + 'factors; the table below shows the ACI defaults as plain text ' + 'and cannot be edited until that module loads.'.format(name)) + row_defaults = dict((r[0], r) for r in fallback.get(name, ())) + row_ids = [r[0] for r in fallback.get(name, ())] + + def factor_key(nm, rid, col): + return 'load_combo.{0}.{1}.{2}'.format(nm, rid, col) + + def include_key(nm, rid): + return 'load_combo.{0}.{1}.include'.format(nm, rid) + + table_rows = [] + for rid in row_ids: + cells = [str(rid)] + default = row_defaults.get(rid) + for i, col in enumerate(factors): + cells.append({'editor': 'num', 'width': 44, + 'key': factor_key(name, rid, col), + 'default': (default[i + 1] if default and + i + 1 < len(default) else None)}) + cells.append({'editor': 'check', 'key': include_key(name, rid)}) + table_rows.append(cells) + return {'kind': 'lc_table', 'name': name, + 'headers': list(_COMBO_HEADERS), 'header_groups': None, + 'rows': table_rows, 'factors': factors, 'row_key': None} + + +# --------------------------------------------------------------------- page + +def page(values, reads=None): + """(blocks, problems) for the Design Criteria section. + + `values` is the form's current string-valued dict, read defensively -- + every lookup goes through `.get(...)` and a key this module expects but + does not find is treated as blank, never as an error, because another + agent is restructuring `panelanalysisform.FIELDS` at the same time this + is being written. `values` is never mutated. `reads` is optional and, on + this section, only used for the one-line "Panel " context caption + at the top -- everything else on Design Criteria is job-level or + material data, not something read off the framing element. + + Never raises: every lookup that could fail (the `panelanalysisform` + import, a missing dict key) degrades to a `problems` entry instead. + """ + values = values if isinstance(values, dict) else {} + reads = reads if isinstance(reads, dict) else {} + problems = [] + seen = set() + existing = _existing_keys() + if existing is None: + problems.append( + 'could not read panelanalysisform.FIELDS to check which keys ' + 'already exist (the module may be mid-edit); every field on ' + 'this page is shown, and is reported as missing until this is ' + 'confirmed.') + + def F(key): + return _field(key, problems, seen, existing) + + blocks = [] + blocks.append(_heading('Design Criteria', 1)) + + mark = (reads.get('mark') or '').strip() + design_number = (reads.get('design_number') or '').strip() + if mark or design_number: + blocks.append({'kind': 'kv', 'rows': [ + ('Panel', mark or '(mark not read)'), + ('Design number', design_number or '(not read)'), + ]}) + + # ----------------------------------------------------- Project Information + blocks.append(_heading('Project Information', 2)) + blocks.append(_cols([ + [F('project_name'), F('client_name'), F('project_description')], + [F('project_location'), F('engineer_initials')], + [F('project_date'), F('job_number'), F('design_spec')], + ])) + blocks.append(_rule()) + + # ------------------------------------------------------ Material Properties + blocks.append(_heading('Material Properties', 2)) + blocks.append(_heading('Concrete', 3)) + blocks.append(_cols([ + [F('fci_psi'), F('fc_psi')], + [F('conc_eci_psi'), F('conc_ec_psi')], + [F('unit_weight_pcf'), F('concrete_type')], + ])) + blocks.append(_space(1)) + blocks.append(_cols([ + [_heading('Rebar', 3), F('rebar_fy_ksi'), F('rebar_es_ksi')], + [_heading('Mesh', 3), F('mesh_fy_ksi'), F('mesh_es_ksi')], + [_heading('Strand', 3), F('ps_fpu_ksi'), F('strand_ep_ksi')], + ])) + blocks.append(_rule()) + + # ---------------------------------------------------------- Prestress Loss + # + # Two columns, not the one wide column this used to be laid out in: a + # single-column `_cols` hands `_two_col_row`'s star label a full page + # width to stretch into, which is what the 2026-09-04 review (WPA655) + # reported as the '%' symbol on `ps_loss_humidity_pct` "floating mid-row + # far from its editor" -- the symbol and editor are still adjacent (see + # `script.py`'s `_two_col_row`), but a page-wide row puts that pair a + # long way from the label a reader's eye is anchored to. Splitting the + # four fields into two narrower columns, the same way every other group + # on this page already does, keeps the whole row -- label, symbol, and + # editor together -- inside one eye span. + blocks.append(_heading('Prestress Losses', 2)) + blocks.extend(_group_or_sentence( + values, _PRESTRESS_LOSS_FIELDS, + 'No prestress loss assumptions entered for this panel.', + lambda: [_cols([ + [F('ps_loss_method'), F('ps_loss_humidity_pct')], + [F('ps_loss_release_hours'), F('ps_loss_erection_days')], + ])])) + blocks.append(_rule()) + + # -------------------------------------------------------------- Soil loads + # + # `sentence`, not `field` -- see the module docstring's "WHY EXTERIOR + # SOIL / INTERIOR SOIL ARE `sentence` BLOCKS" for why there is nothing + # to degrade to an editor for here. + problems.append( + 'soil loads are not collected anywhere in this form yet; Exterior ' + 'Soil and Interior Soil always read as "no loads" until a later ' + 'stage adds that input.') + blocks.append(_cols([ + [_heading('Exterior Soil', 2), + _sentence('Design contains no exterior soil loads.')], + [_heading('Interior Soil', 2), + _sentence('Design contains no interior soil loads.')], + ])) + blocks.append(_rule()) + + # ----------------------------------------------------------- Load Combos + blocks.append(_heading('Load Combinations', 1)) + blocks.append(_cols([ + [_heading('Strength Combinations', 2), + _combo_table('strength', values, problems), + F('design_criteria.min_earth_factor_strength')], + [_heading('Service Combinations', 2), + _combo_table('service', values, problems), + F('design_criteria.min_earth_factor_service')], + ])) + blocks.append(_rule()) + + # --------------------------------------------------------------- Settings + blocks.append(_heading('Settings', 1)) + col_a = [ + _heading('Deflection Limits', 2), + F('design_criteria.deflection_add_bow'), + F('design_criteria.deflection_additional_bow_in'), + F('deflection_limit'), + F('design_criteria.deflection_check_cantilever'), + F('design_criteria.deflection_cantilever_limit'), + _heading('Structural Analysis', 2), + F('second_order'), + F('cracking_assumption'), + F('design_criteria.analysis_beta_d'), + F('design_criteria.cracking_method'), + F('design_criteria.cracking_limit_sqrt_fc'), + F('design_criteria.rvalue_calc_enabled'), + ] + _group_or_sentence( + values, ('design_criteria.rvalue_calc_enabled',), + 'R-value is not being calculated for this panel.', lambda: []) + [ + F('design_criteria.min_reinf_checked'), + ] + _group_or_sentence( + values, ('design_criteria.min_reinf_checked',), + 'Minimum steel is not being checked for this panel.', lambda: []) + + col_b = [ + _heading('Stress Options', 2), + F('design_criteria.stress_failure_if_cracked'), + F('design_criteria.stress_ignore_lateral_ecc'), + F('design_criteria.stress_use_principal_axis'), + _heading('In-Place Stress Limits', 2), + F('inplace_tension_sqrt'), + F('design_criteria.inplace_modulus_rupture_sqrt_fc'), + F('inplace_comp_ratio'), + _heading('Handling Stress Limits', 2), + F('handling_tension_sqrt'), + F('handling_comp_ratio'), + _heading('Flexure Options', 2), + F('design_criteria.flexure_method'), + F('design_criteria.flexure_ignored_end_distance_in'), + F('design_criteria.flexure_cracking_check'), + ] + + col_c = [ + _heading('Resistance Factors', 2), + # A `table`, not the `columns`-of-`field`s this used to be -- the + # 2026-09-04 review (WPA655) found every phi editor rendered with + # zero width. That old layout was two `columns` sub-columns + # ("Developed" | "Transferring") of `field` blocks, nested inside + # `col_c` itself one of three columns -- two levels of halving, each + # `field` needing the renderer's fixed ~450px `_two_col_row` it never + # had a fraction of. `_render_table` puts a cell's editor in an + # ordinary (unconstrained) grid column instead, so there is always + # room. See the module docstring's 2026-09-04 update for why `_cell` + # still routes every key through `F()` first (it is what keeps the + # existing "no backing field yet" `problems` bookkeeping honest). + _table( + [u'φ', 'Developed', 'Transferring'], + [ + ['Bending', _cell(F('phi_flexure')), + _cell(F('design_criteria.phi_tension_transfer'))], + ['Compression', _cell(F('design_criteria.phi_compression')), + 'Not applicable once the strand has fully transferred.'], + ['Shear', _cell(F('phi_shear')), + _cell(F('design_criteria.phi_shear_transfer'))], + ], + ), + _heading('Development Multipliers', 2), + F('dev_mult'), + F('design_criteria.dev_mult_debonded'), + F('design_criteria.dev_mult_cut'), + F('design_criteria.dev_mult_mild'), + _heading('Shear Options', 2), + F('design_criteria.shear_treat_as_column'), + _heading('Axial Options', 2), + F('design_criteria.axial_ignore_tensile_failures'), + F('design_criteria.axial_ignore_buckling_failures'), + ] + + blocks.append(_cols([col_a, col_b, col_c])) + + return blocks, problems diff --git a/WillTools.extension/lib/vibe/pa_defaults.py b/WillTools.extension/lib/vibe/pa_defaults.py new file mode 100644 index 0000000..bcf9448 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_defaults.py @@ -0,0 +1,807 @@ +# -*- coding: utf-8 -*- +"""Aggressive Panel Analysis defaults: fill every blank the tool can defend. + +WHY THIS EXISTS. Owner, 2026-09-04: "目标是我按一下 WPA655 的 panel analysis, +然后手动输入 loading, 剩下的它就能跑了" -- press the button, type the loading, +everything else already has a number. The form opened that day with 141 of +315 fields blank. His instruction was explicit that BLANK is the failure mode +here, not a wrong-looking guess -- every field with a defensible default gets +one, and every one of them names where it came from. Only true LOADING (floor +loads, wind speed / pressure, seismic Cs, soil) is left for the user, because +those are the numbers this tool cannot know and must not invent. + +WHAT THIS MODULE OWNS. `panelanalysisform.prefill()` already fills what the +model can read plus a small `PREFILL_DEFAULTS` table of generic precast +constants (concrete type, rebar grade, mesh grade, the PCI loss-method +seeds). This module is a SECOND, later pass over the same `values` dict -- +covering the much larger set of Design Criteria / Structural Model / Handling +fields that table does not touch, most of which do not even have a home in +`panelanalysisform.FIELDS` yet (they live in `pa_criteria._F` and inline in +`pa_structural.page()` instead, under the `design_criteria.*` / `load_opt_*` +/ `wind_*` / `temp_*` / `restraint_*` / `stage..` naming this +codebase already uses). `apply_defaults()` is meant to run AFTER `prefill()` +in the button script, so the ordinary precedence rule holds without this +module doing anything special for it: a value already sitting in `values` -- +a model read, a plant `panelanalysis.settings.*` pin, or a +`PREFILL_DEFAULTS` seed -- always wins, and this module only ever fills what +is STILL blank. + +SOURCES CITED IN `why`. Three kinds, and every filled entry names one: + + * "Eriksson Wall default ( page)" -- the value the owner's own + Eriksson Wall runs show for this field on a fresh panel. This is the + fallback for most of the Design Criteria / Handling settings below -- + generic precast practice, not one job's data. + * an ACI 318-19 / ASCE 7-22 section number -- code text, not a guess. + * "plant knowledge " -- a real value read off the merged + knowledge layers (`know`), which always outranks the generic fallback + for the handful of fields a plant is expected to pin (the handling + stage multipliers and strength, the sling angle, the strand rail + datum, `top_in_form`, `stress_ignore_lateral_ecc`, the engineer's own + initials). Nothing here reads the Revit API or a live document -- + `know` is just the already-merged dict the button hands in. + +WHAT NEVER GETS A DEFAULT. `ps_loss_pct` (the method computes it), +`ext_soil_loads` / `int_soil_loads` (loading), every raw `seismic_cs` / +`seismic_zone_location` field (loading -- the owner ruled on this +explicitly, 2026-09-02: "风的计算还没有你就留着空着,让用户输入"), and +`engineer_initials` when no plant value names one -- a person's identity is +not a default, the same rule CLAUDE.md states for client and job names. +`conc_loads` (the old free-text field) is gone from the form entirely -- +concentrated loads are a row table now (`panelanalysisform.CONC_LOAD_TABLE`), +never defaulted, same rule. ASCE wind (`wind_speed_vult`, `wind_risk_ +category`, `wind_exposure`, `wind_enclosure`, `wind_asce_version`, +`wind_cc_chapter`, `wind_mwfrs_chapter`, `wind_mean_roof_height_ft`, +`wind_effective_wind_area_ft2`, `wind_pressure_psf`, `wind_suction_psf`) is +gone from this module too -- 2026-09-04 owner ruling moved ASCE site data +out of this form into project config entirely (see +`panelanalysisform`'s module docstring, Wind section); this module never +defaulted the two real-loading fields among them anyway, and now has +nothing left to default for the rest either. + +NEVER RAISES. `defaults_for` / `apply_defaults` / `why_of` / `known_keys` / +`unknown_keys` all degrade to an empty result rather than an exception -- +a malformed `values` (`None`, not a dict), a malformed `know` ('garbage', +not a dict), or a sibling module mid-edit (an import failure in +`panelanalysisform` / `pa_criteria` / `pa_structural`, all still being +written the same day this file was) each narrow what gets filled instead of +stopping the whole pass. `known_keys()` follows the same "could not judge +narrows, never guesses" discipline CLAUDE.md's `knowledge.same_face()` +example sets: a module that fails to import contributes no keys, not a +guessed set. + +`known_keys()` / `unknown_keys()` READ THE LIVE FORM, not a copy of its key +list. `pa_structural.page()` has no flat field table the way +`panelanalysisform.FIELDS` and `pa_criteria._F` do -- its field keys are +scattered through one long `page()` function as inline `_field(...)` calls. +Rather than hand-copy that list here (and drift the day someone edits it), +`_pa_structural_blocks()` calls `pa_structural.page({}, {})` -- documented as +never raising -- and walks the returned block tree for every `kind: 'field'` +entry, the same shape the WPF renderer itself would see. That also recovers +each field's `choices`, feeding the choice-membership check below. + +CHOICE VOCABULARY. Where a key has a `choices` list anywhere in the form +(`panelanalysisform.FIELD_CHOICES`, `pa_criteria._F`'s own choices column, +`pa_structural.page()`'s inline `choices=[...]`, or the Handling stage +matrix's per-column `choices` hint in `STAGE_OPTIONS`), the default picked +below is one of those choices -- verified by +`tests/test_pa_defaults.py`, not just asserted here. Two of the owner's +literal example values did not survive that check against the real editors +and were changed; see the module-level `_DECISIONS` note near the bottom of +this file for both. + +IRONPYTHON 2.7: no f-strings, `'{0}'.format(...)` throughout, this coding +cookie, no `pathlib` / `dataclasses` / typing syntax. Imports nothing but the +standard library at module scope; every `vibe.*` import is inside a function, +wrapped in `try/except Exception`, exactly so a sibling module mid-edit can +never take this one down. +""" + +import datetime + + +# --------------------------------------------------------------- key sources + +def _panelanalysisform(): + try: + from vibe import panelanalysisform as paf + return paf + except Exception: + return None + + +def _pa_criteria(): + try: + from vibe import pa_criteria as pc + return pc + except Exception: + return None + + +def _pa_structural_blocks(): + """[(key, editor, choices), ...] read off a live `pa_structural.page()` + call -- see the module docstring's "READ THE LIVE FORM" note for why + this walks the block tree instead of keeping a second copy of the field + list. Never raises: an import failure or a `page()` that cannot run + (both plausible while a sibling agent edits either file) returns []. + """ + try: + from vibe import pa_structural as ps + except Exception: + return [] + try: + blocks, _problems = ps.page({}, {}) + except Exception: + return [] + out = [] + + def walk(items): + for b in items or []: + if not isinstance(b, dict): + continue + kind = b.get('kind') + if kind == 'field' and b.get('key'): + out.append((b.get('key'), b.get('editor'), b.get('choices'))) + elif kind == 'columns': + for col in (b.get('columns') or []): + walk(col) + elif kind == 'section': + # A collapsed section (seismic / temperature / soil since + # 2026-09-04) carries its fields in `blocks`; they are still + # the form's keys. + walk(b.get('blocks')) + + walk(blocks) + return out + + +def known_keys(): + """Every key the Panel Analysis form is known to carry today, gathered + defensively from every section module this file knows how to ask: + `panelanalysisform.FIELDS`, the Handling stage matrix + (`stage_field_keys()`), `pa_criteria._F`, and a live read of + `pa_structural.page()`'s field blocks. A module that fails to import + only narrows this set -- see the module docstring. + """ + keys = set() + paf = _panelanalysisform() + if paf is not None: + try: + keys.update(f['key'] for f in paf.FIELDS) + except Exception: + pass + try: + keys.update(paf.stage_field_keys()) + except Exception: + pass + pc = _pa_criteria() + if pc is not None: + try: + keys.update(pc._F.keys()) + except Exception: + pass + for key, _editor, _choices in _pa_structural_blocks(): + keys.add(key) + return keys + + +def unknown_keys(): + """The keys this module WOULD default that `known_keys()` does not (yet) + recognise -- empty once the field-model agents have caught up. Never + raises; a lookup failure inside `known_keys()` already degrades to a + smaller set, so this can only ever over-report, never under-report, a + drift. + """ + known = known_keys() + out = [] + for key in _all_default_keys(): + if key not in known and key not in out: + out.append(key) + return out + + +# ------------------------------------------------------------- choice tables + +def _choice_registry(): + """key -> tuple(choices), unioned across every source that names one. + + A key with no entry here is free text (or a key nothing declares a + choice list for yet) -- `choices_for` returns None for it, and the + default picked for it is not checked against anything. + """ + out = {} + + def add(key, choices): + if not choices: + return + cur = out.get(key, ()) + merged = tuple(cur) + tuple(c for c in choices if c not in cur) + out[key] = merged + + paf = _panelanalysisform() + if paf is not None: + try: + for key, choices in paf.FIELD_CHOICES.items(): + add(key, choices) + except Exception: + pass + try: + for stage in paf.STAGES: + for opt_key, _label, _sym, _help, _kind, _drives, choices \ + in paf.STAGE_OPTIONS: + add(paf.stage_field_key(stage, opt_key), choices) + except Exception: + pass + pc = _pa_criteria() + if pc is not None: + try: + for key, spec in pc._F.items(): + choices = spec[5] if len(spec) > 5 else None + add(key, choices) + except Exception: + pass + for key, _editor, choices in _pa_structural_blocks(): + add(key, choices) + return out + + +def choices_for(key): + """The unioned choice vocabulary for `key`, or None for free text.""" + return _choice_registry().get(key) or None + + +# ------------------------------------------------------------------ helpers + +def _is_blank(value): + if value is None: + return True + try: + return value.strip() == '' + except AttributeError: + return False + + +def _num(text): + """A best-effort float from whatever sits in `values`, or None.""" + if text is None: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +def _fmt_num(value): + """A number as the shortest text the form shows -- '2' not '2.0'.""" + if value is None: + return '' + if isinstance(value, bool): + return 'Yes' if value else 'No' + try: + if isinstance(value, int): + return str(value) + except Exception: + pass + try: + f = float(value) + except (TypeError, ValueError): + return str(value) + if f == int(f): + return str(int(f)) + text = ('%.10f' % f).rstrip('0').rstrip('.') + return text + + +def _today_iso(): + return datetime.date.today().isoformat() + + +def _as_dict(value): + return value if isinstance(value, dict) else {} + + +# ---------------------------------------------------------- static defaults +# +# key -> (value_text, why). Fixed values -- no plant lookup, no computation +# from another field -- for every Design Criteria / Structural Model / +# Handling field the owner named that does not depend on plant knowledge or +# on another field's value. See `_DYNAMIC_DEFAULTS` and `_stage_dynamic_*` +# below for the ones that do. +_ERIKSSON = 'Eriksson Wall default' + + +def _e(page): + return '{0} ({1} page)'.format(_ERIKSSON, page) + + +_STATIC_DEFAULTS = ( + ('design_spec', 'ACI 318-19', + 'current governing code edition (ASCE 7-22 companion); the sample ' + 'Eriksson runs printed ACI 318-14 because they were older files'), + + # ---------------------------------------------------- Design Criteria + ('design_criteria.deflection_add_bow', 'No', _e('Settings')), + ('design_criteria.deflection_additional_bow_in', '0', _e('Settings')), + ('design_criteria.deflection_check_cantilever', 'No', _e('Settings')), + ('design_criteria.deflection_cantilever_limit', 'L/75', _e('Settings')), + ('design_criteria.analysis_beta_d', 'Non-Sway', + "matches the field's own choice vocabulary (Non-Sway / Sway) -- " + "Non-Sway is " + _e('Settings')), + ('design_criteria.cracking_method', 'Mast Method', _e('Settings')), + ('design_criteria.cracking_limit_sqrt_fc', '7.5', _e('Settings')), + ('design_criteria.rvalue_calc_enabled', 'No', _e('Settings')), + ('design_criteria.min_reinf_checked', 'No', _e('Settings')), + ('design_criteria.stress_failure_if_cracked', 'No', _e('Settings')), + ('design_criteria.stress_use_principal_axis', 'No', _e('Settings')), + ('design_criteria.inplace_modulus_rupture_sqrt_fc', '12', + u"Eriksson's own \"transition\" tension limit, 12 x sqrt(f'c)"), + ('design_criteria.flexure_method', 'Whitney Stress Block', + _e('Settings')), + ('design_criteria.flexure_ignored_end_distance_in', '0', + _e('Settings')), + ('design_criteria.flexure_cracking_check', 'None', _e('Settings')), + ('design_criteria.phi_compression', '0.65', "ACI 318-19 Sec. 21.2"), + ('design_criteria.phi_tension_transfer', '0.75', "ACI 318-19 Sec. 21.2"), + ('design_criteria.phi_shear_transfer', '0.75', "ACI 318-19 Sec. 21.2"), + ('design_criteria.dev_mult_debonded', '2', _e('Settings')), + ('design_criteria.dev_mult_cut', '1.3', _e('Settings')), + ('design_criteria.dev_mult_mild', '1.3', _e('Settings')), + ('dev_mult', '1.3', _e('Settings') + ' -- strand, normal'), + ('design_criteria.shear_treat_as_column', 'No', _e('Settings')), + ('design_criteria.axial_ignore_tensile_failures', 'No', _e('Settings')), + ('design_criteria.axial_ignore_buckling_failures', 'No', _e('Settings')), + ('design_criteria.min_earth_factor_strength', '0', _e('Settings')), + ('design_criteria.min_earth_factor_service', '0', _e('Settings')), + ('second_order', 'Yes', _e('Settings') + ' -- P-Delta'), + ('deflection_limit', 'L/150', _e('Settings')), + + # ------------------------------------------------------- Load Options + ('self_weight_factor', '1', _e('Settings')), + ('load_opt_selfweight_ecc_in', '0', _e('Settings')), + ('load_opt_offset_from', 'Deflection Section', + 'Eriksson calls this the Loading Datum; the sample runs measure ' + 'point-load offsets from the Deflection Section'), + ('load_opt_pattern_live', 'No', + _e('Settings') + ' -- Pattern Live Loads for Flexure is off in the ' + 'sample runs'), + + # -------------------------------------------------------- Temperature + ('temp_ext_f', '75', _e('Settings')), + ('temp_int_f', '75', _e('Settings')), + ('temp_elasticity_mult', '0.5', _e('Settings')), + ('temp_alpha_t', '5.5', + u'5.5 × 10⁻⁶ /°F (ACI 209 / PCI), entered as the ' + u'×10⁻⁶ mantissa the field asks for'), + + # Wind is GONE from this module -- 2026-09-04 owner ruling moved every + # ASCE site-data field (Vult, risk/exposure/enclosure, code version, + # C&C/MWFRS chapter, mean roof height, effective wind area) out of this + # form into project config; see `panelanalysisform`'s module docstring. + # `wind_mode` (the one wind field left) defaults on the form side, in + # `panelanalysisform.prefill` itself, not here. + + # ---------------------------------------------------------- Restraints + + # ------------------------------------------------------------ Seismic + # 'seismic_apply_to' picks WHAT the (still-blank, user-typed) seismic + # coefficient multiplies -- not a magnitude itself, so it is not the + # loading the owner ruled must stay blank. Its editor carries an + # explicit 'None' choice now, matched here rather than left blank. + ('seismic_apply_to', 'None', + "matches the field's own choice vocabulary (None / Out-of-plane / " + 'In-plane / Both); ' + _e('Structural Model') + ' -- no seismic ' + 'multiplier applied in the sample runs'), + + # ------------------------------------------------- Prestressing Strands + ('strand_rail_offset_in', '0', _e('Reinforcement')), + + # -------------------------------------------------------------- Extents + ('left_miter', 'No', _e('Concrete Extents')), + ('right_miter', 'No', _e('Concrete Extents')), +) + + +def _all_static_keys(): + return [key for key, _v, _w in _STATIC_DEFAULTS] + + +# ---------------------------------------------------------- dynamic values +# +# key -> function(values, know) -> (value_text, why) | None. None means +# "this module cannot defend a default here right now" -- the key stays +# blank, never a fabricated fallback. See the module docstring for the +# plant-knowledge paths each of these reads. + +def _dyn_project_date(values, know): + return _today_iso(), "today's date, the same convention design_date uses" + + +def _dyn_engineer_initials(values, know): + pa = _as_dict(_as_dict(know).get('panelanalysis')) + value = pa.get('engineer_initials') + if value is None: + return None + text = str(value).strip() + if not text: + return None + return text, 'plant knowledge panelanalysis.engineer_initials' + + +def _dyn_stress_ignore_lateral_ecc(values, know): + pa = _as_dict(_as_dict(know).get('panelanalysis')) + settings = _as_dict(pa.get('settings')) + value = settings.get('stress_ignore_lateral_ecc') + if value is not None: + text = str(value).strip().lower() + yn = 'No' if text in ('', 'no', 'none', 'false', '0', 'off') \ + else 'Yes' + return yn, ('plant knowledge ' + 'panelanalysis.settings.stress_ignore_lateral_ecc') + return 'No', _e('Settings') + + +def _dyn_top_in_form(values, know): + pa = _as_dict(_as_dict(know).get('panelanalysis')) + value = pa.get('top_in_form') + if value is not None: + text = str(value).strip() + if text: + for choice in ('Interior', 'Exterior'): + if text.lower() == choice.lower(): + text = choice + break + return text, 'plant knowledge panelanalysis.top_in_form' + return 'Interior', _e('Concrete Extents') + + +_RAIL_LABELS = { + 'left_rail': 'Left edge', + 'right_rail': 'Right edge', + 'centerline': 'Centerline', +} + + +def _dyn_strand_rail_location(values, know): + rails = _as_dict(_as_dict(know).get('rails')) + datum = rails.get('strand_datum') + if datum is not None: + label = _RAIL_LABELS.get(str(datum).strip().lower()) + if label: + return label, 'plant knowledge rails.strand_datum' + return 'Left edge', _e('Reinforcement') + + +_DYNAMIC_DEFAULTS = ( + ('project_date', _dyn_project_date), + ('engineer_initials', _dyn_engineer_initials), + ('design_criteria.stress_ignore_lateral_ecc', + _dyn_stress_ignore_lateral_ecc), + ('top_in_form', _dyn_top_in_form), + ('strand_rail_location', _dyn_strand_rail_location), + # `wind_effective_wind_area_ft2` / `wind_mean_roof_height_ft` are GONE -- + # see the module docstring's Wind note; both fields left the form. +) + + +# --------------------------------------------------------- handling stages +# +# `panelanalysisform.stage_field_key(stage, opt)` builds +# 'stage..' from the stage NAME, not the slug -- but this module +# hardcodes the slugs too (matching `_stage_slug`'s +# `name.lower().replace(' ', '_')` exactly) rather than importing the form +# at module scope, so a sibling agent mid-edit on `panelanalysisform.py` +# cannot take the whole default table down with it. `known_keys()` still +# checks these against the live `stage_field_keys()` output, so any drift +# between the hardcoded slug and the real one shows up as an `unknown_keys()` +# entry rather than silently mis-keying the form. +_STAGES = ('Stripping', 'Transportation', 'Primary Erection', + 'Secondary Erection') +_STAGE_SLUGS = { + 'Stripping': 'stripping', + 'Transportation': 'transportation', + 'Primary Erection': 'primary_erection', + 'Secondary Erection': 'secondary_erection', +} +_STAGE_PLANT_NAME = { + 'Stripping': 'stripping', + 'Transportation': 'transportation', + 'Primary Erection': 'erection', + 'Secondary Erection': None, +} +_STAGE_FALLBACK_MULTIPLIER = { + 'Stripping': 1.4, + 'Transportation': 1.5, + 'Primary Erection': 1.4, +} +_STAGE_FALLBACK_STRENGTH_LABEL = { + 'Stripping': 'Initial', + 'Transportation': 'In-Place', + 'Primary Erection': 'In-Place', +} +_STAGE_FALLBACK_MAX_TILT = { + 'Stripping': 0, + 'Transportation': 0, + 'Primary Erection': 90, +} +_STRENGTH_LABELS = {'fci': 'Initial', 'fc': 'In-Place'} + + +def _stage_key(stage, opt): + return 'stage.{0}.{1}'.format(_STAGE_SLUGS[stage], opt) + + +def _plant_stage(know, plant_name): + if plant_name is None: + return None + handling = _as_dict(_as_dict(know).get('handling')) + stages = handling.get('stages') + if not isinstance(stages, list): + return None + for entry in stages: + if isinstance(entry, dict) and entry.get('name') == plant_name: + return entry + return None + + +def _plant_sling_angle(know): + handling = _as_dict(_as_dict(know).get('handling')) + value = handling.get('sling_angle_deg') + return value if value is not None else None + + +# Static (non-plant-dependent) cells, one set per active stage -- generated +# once into `_STATIC_DEFAULTS`-shaped tuples so they go through the exact +# same blank-check / why_of machinery as everything else. Only +# `load_multiplier`, `concrete_strength` and `sling_angle` are plant-aware +# (see `_stage_dynamic_defaults` below); everything else about a stage is +# the same Eriksson-sample answer regardless of plant. +def _stage_static_defaults(): + out = [] + for stage in _STAGES: + if stage == 'Secondary Erection': + out.append(( + _stage_key(stage, 'include'), 'No', + _e('Handling') + ' -- secondary erection is not run for ' + 'this panel unless entered')) + continue + out.append((_stage_key(stage, 'include'), 'Yes', _e('Handling'))) + out.append((_stage_key(stage, 'lift_type'), 'Static Lift', + _e('Handling'))) + stress_limits = 'Handling' if stage == 'Stripping' else 'In-Place' + out.append((_stage_key(stage, 'stress_limits'), stress_limits, + _e('Handling'))) + out.append((_stage_key(stage, 'rigging'), 'Auto', _e('Handling'))) + out.append((_stage_key(stage, 'ground_reaction'), 'No', + _e('Handling'))) + out.append((_stage_key(stage, 'min_tilt_deg'), '0', _e('Handling'))) + out.append((_stage_key(stage, 'max_tilt_deg'), + _fmt_num(_STAGE_FALLBACK_MAX_TILT[stage]), + _e('Handling'))) + out.append((_stage_key(stage, 'top_crane_carries'), 'No', + _e('Handling'))) + return tuple(out) + + +_STAGE_STATIC_DEFAULTS = _stage_static_defaults() + + +def _stage_dynamic_defaults(values, know): + out = [] + sling = _plant_sling_angle(know) + if sling is not None: + sling_text = _fmt_num(sling) + sling_why = 'plant knowledge handling.sling_angle_deg' + else: + sling_text = '60' + sling_why = _e('Handling') + for stage in _STAGES: + if stage == 'Secondary Erection': + continue + plant_name = _STAGE_PLANT_NAME.get(stage) + pstage = _plant_stage(know, plant_name) + + multiplier = None + mult_why = _e('Handling') + if pstage is not None and pstage.get('multiplier') is not None: + multiplier = pstage.get('multiplier') + mult_why = 'plant knowledge handling.stages' + if multiplier is None: + multiplier = _STAGE_FALLBACK_MULTIPLIER[stage] + out.append((_stage_key(stage, 'load_multiplier'), + _fmt_num(multiplier), mult_why)) + + strength_label = _STAGE_FALLBACK_STRENGTH_LABEL[stage] + strength_why = _e('Handling') + if pstage is not None and pstage.get('strength') in \ + _STRENGTH_LABELS: + strength_label = _STRENGTH_LABELS[pstage.get('strength')] + strength_why = 'plant knowledge handling.stages' + out.append((_stage_key(stage, 'concrete_strength'), strength_label, + strength_why)) + + out.append((_stage_key(stage, 'sling_angle'), sling_text, + sling_why)) + return out + + +def _stage_dynamic_keys(): + keys = [] + for stage in _STAGES: + if stage == 'Secondary Erection': + continue + keys.append(_stage_key(stage, 'load_multiplier')) + keys.append(_stage_key(stage, 'concrete_strength')) + keys.append(_stage_key(stage, 'sling_angle')) + return keys + + +# --------------------------------------------------------------- the WHY map + +_WHY = {} +for _key, _value, _why in _STATIC_DEFAULTS: + _WHY[_key] = _why +for _key, _value, _why in _STAGE_STATIC_DEFAULTS: + _WHY[_key] = _why +del _key, _value, _why + +_WHY['project_date'] = ("today's date, the same convention design_date " + 'uses') +_WHY['engineer_initials'] = ( + 'plant knowledge panelanalysis.engineer_initials, when the plant names ' + 'one -- a person\'s identity is never guessed, so this key is left ' + 'blank without a plant value') +_WHY['design_criteria.stress_ignore_lateral_ecc'] = ( + 'plant knowledge panelanalysis.settings.stress_ignore_lateral_ecc when ' + 'set, else ' + _e('Settings')) +_WHY['top_in_form'] = ('plant knowledge panelanalysis.top_in_form when ' + 'set, else ' + _e('Concrete Extents')) +_WHY['strand_rail_location'] = ('plant knowledge rails.strand_datum when ' + 'set, else ' + _e('Reinforcement')) +for _stage in _STAGES: + if _stage == 'Secondary Erection': + continue + _WHY[_stage_key(_stage, 'load_multiplier')] = ( + 'plant knowledge handling.stages when a matching stage names a ' + 'multiplier, else ' + _e('Handling')) + _WHY[_stage_key(_stage, 'concrete_strength')] = ( + "plant knowledge handling.stages' strength field (fci -> Initial, " + 'fc -> In-Place) when present, else ' + _e('Handling')) + _WHY[_stage_key(_stage, 'sling_angle')] = ( + 'plant knowledge handling.sling_angle_deg when set, else ' + + _e('Handling')) +del _stage + + +def why_of(key): + """The source sentence for `key`'s default, or None if this module does + not know how to default that key. Independent of any particular + `values` / `know` -- see the module docstring for why: for a + plant-aware key this names the RULE ("plant knowledge X when set, else + Y"), not necessarily which branch a specific call took. + """ + try: + return _WHY.get(key) + except Exception: + return None + + +def _all_default_keys(): + keys = list(_all_static_keys()) + keys.extend(key for key, _v, _w in _STAGE_STATIC_DEFAULTS) + keys.extend(key for key, _fn in _DYNAMIC_DEFAULTS) + keys.extend(_stage_dynamic_keys()) + return keys + + +# ------------------------------------------------------------------- the API + +def _compute(values, know): + out = [] + seen = set() + + def emit(key, value_text, why): + if key in seen: + return + seen.add(key) + if not _is_blank(values.get(key)): + return + out.append((key, value_text, why)) + + for key, value_text, why in _STATIC_DEFAULTS: + emit(key, value_text, why) + for key, value_text, why in _STAGE_STATIC_DEFAULTS: + emit(key, value_text, why) + + for key, fn in _DYNAMIC_DEFAULTS: + try: + result = fn(values, know) + except Exception: + result = None + if result is None: + continue + value_text, why = result + emit(key, value_text, why) + + try: + stage_dyn = _stage_dynamic_defaults(values, know) + except Exception: + stage_dyn = [] + for key, value_text, why in stage_dyn: + emit(key, value_text, why) + + return out + + +def defaults_for(values, know=None): + """[(key, value_text, why), ...] for every key whose current value in + `values` is blank ('' / None / missing) and that this module knows a + default for. `values` and `know` are read only, never mutated. Never + raises -- a `values` that is not a dict, or a `know` that is not a + dict, both degrade to []/an empty knowledge tree rather than an error. + """ + if not isinstance(values, dict): + return [] + try: + return _compute(values, know if isinstance(know, dict) else {}) + except Exception: + return [] + + +def apply_defaults(values, know=None): + """Fills the blanks `defaults_for` finds, IN PLACE on `values`, and + returns the same [(key, value_text, why), ...] it applied. A value + that is no longer blank by the time this writes it (nothing here + should cause that, but the check costs nothing) is left untouched -- + this never overwrites a real value. Never raises: `values` that is not + a (mutable) dict returns [] and nothing is written. + """ + if not isinstance(values, dict): + return [] + try: + found = defaults_for(values, know) + except Exception: + return [] + applied = [] + for key, value_text, why in found: + try: + if _is_blank(values.get(key)): + values[key] = value_text + applied.append((key, value_text, why)) + except Exception: + continue + return applied + + +# --------------------------------------------------------------- _DECISIONS +# +# `design_criteria.analysis_beta_d` and `load_opt_offset_from` were checked +# against `pa_criteria`'s and `pa_structural`'s real editors while this file +# was being written and, at that point, neither editor's choice list +# actually contained the owner's own example value (a numeric-only editor +# for the first, `['Panel CG', 'Other']` for the second). Both editors were +# given real choice lists by another agent, concurrently, before this file's +# tests were finalised -- `design_criteria.analysis_beta_d` now offers +# `('Non-Sway', 'Sway')` and `load_opt_offset_from` now offers +# `('Deflection Section', 'Top in Form', 'Bottom in Form')` -- and both +# match the owner's original values exactly, so both are encoded as he +# specified. Left as a note because `choices_for()` reads the LIVE editors +# on every call: if either drifts again, `tests/test_pa_defaults.py:: +# test_every_default_with_choices_is_a_valid_choice` fails and names the +# mismatch rather than this comment going stale silently. +# +# dev_mult / second_order Both already carry a DIFFERENT generic default +# in `panelanalysisform.PREFILL_DEFAULTS` +# ('1' and 'No', committed 2026-09-03) that runs +# unconditionally in `prefill()`, before this +# module's `apply_defaults()` would ever see the +# key -- so under the current call order neither +# of this module's defaults (1.3, 'Yes') can ever +# actually fire; `values[key]` is already +# non-blank by the time `apply_defaults` looks at +# it. Encoded as the owner specified anyway +# (2026-09-04 postdates the 2026-09-03 seed), but +# this is a live policy conflict between the two +# tables, not a bug in either -- flagged for +# whoever wires `apply_defaults()` into `prefill()` +# to resolve by call order or by retiring the +# older seed. diff --git a/WillTools.extension/lib/vibe/pa_elevation.py b/WillTools.extension/lib/vibe/pa_elevation.py new file mode 100644 index 0000000..5ced94b --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_elevation.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis: Top/Bottom Elevation and in-place Restraints, PURE. + +Owner, 2026-09-04 (WPA655's morning run came up blank on `top_elevation_ft`, +`bottom_elevation_ft` and `restraints` -- see runlog): Eriksson's Concrete +Extents page carries Top Elevation / Bottom Elevation, and the Structural +Model page's in-place restraints are typically the same embed positions +`supports_ft` already found, just reported as elevations instead of a +distance up from the lower end. Both are arithmetic over numbers the caller +already measured -- no Revit API here, so this module is host-tested like +`pa_supports` / `pa_lifters` / `pa_reinf`, and the live half (reading a +framing element's world-space bounding box, in `script.py`'s `_read_ +elevations`) stays a thin Revit-touching wrapper around it. + +WHERE THE ELEVATION NUMBER COMES FROM. `elevations_from_bbox` takes the +framing element's AXIS-ALIGNED WORLD bounding box Z extent +(`el.get_BoundingBox(None)`), already in decimal feet the way every Revit Z +coordinate is -- the same "project elevation" a schedule or a section view +shows for anything placed in the model, because Revit's internal origin +already IS the datum those numbers are measured from. This is the piece AS +MODELLED, not a hypothetical erected attitude the way `pa_supports` / +`liftexport.erected_orientation` derive one for a piece that is still lying +flat on paper -- a wall panel placed in a real building model, the way this +tool's precast jobs use Revit, is modelled standing up in place, so its own +bounding box IS its in-place elevation. A piece not actually modelled +in-place (still on the casting-bed sheet, or a horizontal working view) gives +a bounding box that is NOT an elevation, and this module cannot tell the +difference -- callers name that limitation in the basis text they build +around this, never silently here. + +WHERE RESTRAINTS COME FROM. `restraints_from_supports` re-expresses the same +`supports_ft` positions `pa_supports.read_supports` already measured (feet +from the erected-lower end) as elevations, adding the panel's own +`bottom_elevation_ft`. It is NOT a second measurement -- a plant that names +no support families leaves `supports_ft` blank, and this function then has +nothing to convert and returns `(None, why)` rather than fabricating an +elevation from zero. + +Written for IronPython 2.7: no f-strings, no pathlib, `'{0}'.format(...)` +throughout. +""" +# pylint: disable=invalid-name + + +def elevations_from_bbox(min_z_ft, max_z_ft, length_ft=None, tol_ft=0.1): + """(top_ft, bottom_ft, height_ft, basis) off a world-space bounding box. + + `min_z_ft` / `max_z_ft` are the two Z extents of the element's own + bounding box (`el.get_BoundingBox(None)`), in any order -- the smaller + becomes `bottom_ft`, the larger `top_ft`. Either missing (`None`) is the + could-not-read answer: `(None, None, None, why)`, never a fabricated + elevation. + + `length_ft`, when given, is compared against the measured height + (`top_ft - bottom_ft`): for a wall panel modelled standing up, the two + should agree, and a real gap larger than `tol_ft` is named in the basis + rather than silently absorbed -- it usually means the piece is not + modelled fully upright, or `length_ft` was measured along a different + axis than the one that is actually vertical here. + """ + if min_z_ft is None or max_z_ft is None: + return None, None, None, ('could not read a world-space bounding ' + 'box on this piece') + bottom = round(min(min_z_ft, max_z_ft), 4) + top = round(max(min_z_ft, max_z_ft), 4) + height = round(top - bottom, 4) + basis = ('the framing element\'s own world-space bounding box, Z {0:.2f}' + ' to {1:.2f} ft -- the piece AS MODELLED, not a hypothetical ' + 'erected attitude'.format(bottom, top)) + if length_ft is not None: + try: + gap = abs(height - float(length_ft)) + except (TypeError, ValueError): + gap = None + if gap is not None and gap > tol_ft: + basis += ('; this differs from the measured length ({0:.2f} ft) ' + 'by {1:.2f} ft -- this piece may not be modelled ' + 'fully upright'.format(float(length_ft), gap)) + return top, bottom, height, basis + + +def restraints_from_supports(supports_ft_text, bottom_elevation_ft, + height_ft=None, length_ft=None, + stand_tol_ft=1.0): + """(text, basis) turning `supports_ft` positions into elevations, or + `(None, why)` when there is nothing to convert. + + `supports_ft_text` is the SAME comma-separated feet string `pa_supports. + read_supports` writes into `reads['supports_ft']` (feet from the + erected-lower end); `bottom_elevation_ft` is this panel's own bottom + elevation (`elevations_from_bbox`'s third return). Either blank/None, or + a token that will not parse as a number, comes back as `(None, why)` -- + never a partial or guessed conversion. The output is comma-separated, + the same grammar `panelanalysisform.parse_supports` already reads back + for both `supports_ft` and `restraints`. + + `height_ft` / `length_ft`, when both given, guard against a piece that + does NOT stand with its length vertical -- a flat slab's world-space + bounding-box height is its THICKNESS, not its length, and adding a + `supports_ft` position (measured along the length) to that height would + not be an elevation at all. A gap bigger than `stand_tol_ft` between the + two skips the conversion and says why, rather than emitting a number + that looks like an elevation but mixes two different axes. + """ + if height_ft is not None and length_ft is not None: + try: + gap = abs(float(height_ft) - float(length_ft)) + except (TypeError, ValueError): + gap = None + if gap is not None and gap > stand_tol_ft: + return None, ( + 'this piece\'s bounding-box height ({0:.2f} ft) does not ' + 'match its length ({1:.2f} ft) -- it does not appear to ' + 'stand with its length vertical, so supports_ft is not a ' + 'vertical offset here; restraints left blank rather than ' + 'converting a horizontal position into an elevation' + .format(float(height_ft), float(length_ft))) + + if not supports_ft_text or bottom_elevation_ft is None: + missing = [] + if not supports_ft_text: + missing.append('supports_ft') + if bottom_elevation_ft is None: + missing.append('bottom_elevation_ft') + return None, ('nothing to convert -- {0} not read'.format( + ' and '.join(missing))) + + positions = [] + for tok in str(supports_ft_text).replace(',', ' ').split(): + try: + positions.append(float(tok)) + except (TypeError, ValueError): + return None, ('supports_ft carried a value that is not a ' + 'number ({0!r}); restraints could not be ' + 'converted'.format(tok)) + if not positions: + return None, 'supports_ft parsed to no positions' + + try: + bottom = float(bottom_elevation_ft) + except (TypeError, ValueError): + return None, 'bottom_elevation_ft is not a number' + + elevations = sorted(round(bottom + p, 4) for p in positions) + text = ', '.join('{0:.2f}'.format(e) for e in elevations) + basis = ('the support embed positions ({0}) converted to elevations off ' + 'this panel\'s own bottom_elevation_ft ({1:.2f} ft) -- the ' + 'same embeds, not a second measurement'.format( + supports_ft_text, bottom)) + return text, basis diff --git a/WillTools.extension/lib/vibe/pa_extents.py b/WillTools.extension/lib/vibe/pa_extents.py new file mode 100644 index 0000000..a5f5cae --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_extents.py @@ -0,0 +1,674 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis input form -- the "Concrete Extents" section, laid out. + +The owner has said three times the Panel Analysis form is too ugly to read +("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02: "全部给我做成 +普通人能看懂的样子" -- every part of it has to be something an ordinary +person, not just an engineer, can understand. The fix agreed on is to make +the form look like Eriksson Wall's own printed page for this section (a solid +wall panel's "Concrete Extents" page: Panel Geometry, an Openings / Reveals / +Solid Zones table, and a drawings row) but with a plain-English `label` +beside every engineering `symbol` and a one-sentence `hint` on every field -- +never the symbol alone, standing in as if it were the explanation. + +`page(values, reads=None)` returns `(blocks, problems)`: a flat list of block +dicts from a fixed vocabulary a generic WPF renderer (built separately, not +here) consumes, plus a list of plain-English sentences for anything that +could not be laid out. This module never raises -- a field it cannot place +becomes a `problems` entry, never a traceback the owner has to read a +Python stack to understand. + +WHY THIS FILE OWNS NO Revit, NO WPF AND NO `panelsketch` IMPORT: it has to +load and run on plain CPython under `tests/conftest.py`'s trick (see that +file's docstring) the same way the rest of Revit-free `vibe` does, and it has +to run under IronPython 2.7 inside the button. `panelsketch` is named by +STRING in a `drawing` block's `source` field -- the renderer calls it, not +this module -- because a sibling agent is extending `panelsketch` at the same +time this file is being written, and importing a module mid-rewrite is how +two agents' work collides. + +WHY `values['openings_rows']` IS READ DEFENSIVELY: another agent owns +`vibe.panelanalysisform`, the source of the form's key names, and is +restructuring it at the same time this file is being written. A key this +module expects that is not actually there is "not present", never treated as +a bug -- `_paf_field` returns `None` and the caller falls back rather than +raising. The same discipline applies to the structured opening rows a third +agent is meant to be adding at `values['openings_rows']` (shaped +`{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross', +'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}`, per that agent's +own contract): when it is not there yet, or is not a list, this module falls +back to the raw `values['openings']` text field and SAYS SO in `problems`, +rather than guessing at a shape that has not landed. + +WHY THE DRAWING AREA NO LONGER PRINTS ERIKSSON'S OWN SECTION PROPERTIES ROWS +(Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb, St) OR A SIDE/FRONT +ELEVATION SPLIT: the owner ruled, 2026-09-03, that Concrete Extents "does +not need all those parameters -- Area, Width and so on. In that section +just draw the member's concrete edge. If there are openings, say so, and +add a few dimension lines that locate the openings on the drawing." That +replaced the old three-column drawings row (Side Elevation / Front +Elevation / Section Properties, all built on `panelsketch.elevation()`, +whose own docstring says it draws the front and side views together as ONE +picture -- so "Side Elevation" and "Front Elevation" used to be the same +combined image under two captions, a real limitation this module used to +paper over with a `problems` sentence rather than a fix) with exactly two +drawings and one sentence: `panelsketch.extents_outline()` (the panel's own +length x width edge, two overall dimensions, and any openings drawn to +scale with a locating dimension chain) and `panelsketch.extents_section()` +(the width x thickness edge, a thickness dimension, and the Sandwich wythe +layers when the section is one), plus `_openings_summary_sentence()`'s one +line: "N opening(s), M reveal(s)" or "No openings or reveals." Both new +`panelsketch` functions omit what `elevation()` / `section()` draw that this +page has no data of its own for -- supports, a side view, section +properties, and (the concrete reason the drawing "looked like garbage") the +strand dots and labels `section()` always draws when `values['strand_rows']` +happens to be present, which it can be even here since `values` is the +WHOLE form's dict, not a slice scoped to this page. + +WHY THE TWO DRAWINGS ARE NOW STACKED, FULL WIDTH, WITH A DRAGGABLE CUT LINE +(owner ruling, 2026-09-04): "concrete extent 那个地方,panel outline 的图放 +的更大一些。然后 cross section 跟它做成上下并列,而不是左右并列。panel +outline 上面做一个 cut-line,用户可以拖动,然后 cross section 动态加载, +显示当前的 cross-section,跟做核磁一样" -- the outline drawing gets bigger, +the section moves BELOW it (not beside it) so the outline is never squeezed +to make room for a companion, and a draggable cut line on the outline picks +which along-length station the section below it shows -- "like an MRI", +scrubbing through slices. And generally: "画图的时候,panel 的长度总要占满 +整个显示窗口的宽度,因为不管什么板,你总是横着画" -- the panel's length +always fills the full width of whatever window it is drawn in, because a +panel is always drawn lying on its side, whatever its actual proportions. +The two `columns`-wrapped, fixed-pixel (`w`/`h`) `drawing` blocks this page +used to emit are gone; `_extents_drawing_blocks()` below emits two +`full_width: True` blocks instead, each carrying `aspect` (the drawing's own +height/width ratio, so the renderer can give it exactly the full column +width and no more) rather than a baked-in pixel size, plus `module: +'pa_sketch_extents'` naming the sibling module that owns the actual drawing +functions (`outline()` / `section()`) -- NOT `panelsketch` any more, because +`panelsketch.extents_outline()` / `extents_section()` do not know about a +cut line or a station, and teaching them would mean growing the OLD +Structural Model drawings' shared module for a Concrete-Extents-only idea. +`pa_sketch_extents` calls straight into `panelsketch`'s existing helpers +(`extents_outline()` for the base outline picture, `_parse_opening_row()` for +the section's opening/reveal geometry) rather than re-deriving them -- see +that module's own docstring. The outline block also carries `cutline: {'key': +'_cut_frac', 'default': 0.5}`, telling the renderer which form value the +draggable line reads and writes (a plain 0..1 fraction of the length, stored +as text like every other field this form has), and both blocks carry +`depends_on`, the list of `values` keys a redraw should watch -- the section +block's list starts with `'_cut_frac'` itself, because dragging the line is +the whole point of the section being drawn separately from the outline in +the first place. + +Miters get the same "print a sentence, never leave a blank" discipline +Eriksson uses on its own page (an unchecked "Left Miter" box there is not +just left bare -- it prints "Left miter not included in design" underneath). +`_miter_sentence` reproduces exactly that: a miter left BLANK (unanswered) is +silent, because blank means "not yet decided", never "no" (same rule +`panelanalysisform._parse_bool` documents for its own checkboxes); a miter +answered explicitly "no" prints the sentence, in words, the way Eriksson +does. +""" + +# `panelanalysisform` carries no Revit import at module scope (see its own +# docstring), so this succeeds on plain CPython under `tests/conftest.py`'s +# trick and under IronPython 2.7 alike. If the sibling agent restructuring it +# has, at some moment, left it unimportable, this module still has to load -- +# so the import itself is guarded, and every reader of `paf` below is guarded +# again on top of that (see `_paf_field`). +try: + from vibe import panelanalysisform as paf +except ImportError: + paf = None + +# `feet_str` (decimal feet -> '8\'-4 1/2"') is pure standard-library, no +# Revit -- shared with `vibe.pa_handling`, which needed the identical +# rounding for its own kv/table cells (2026-09-05); kept as `_feet_str` here +# so every existing caller in this file needs no rewrite. +from vibe.pa_units import feet_str as _feet_str # noqa: E402 + + +# --------------------------------------------------------------- utilities + +def _num(value): + """`value` (a form string, or already a number) as a float, or None. + + None covers "blank", "not a number", and "not present" alike -- the + caller only ever needs to know whether there is a usable number, and + `validate()`-style error text is not this module's job (the button's + completeness gate already owns that; this module only lays out what it + is given). + """ + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +def _fmt_num(value): + """A float as the plain string this page prints -- '8', not '8.0'.""" + if value is None: + return '' + if float(value) == int(value): + return str(int(value)) + return ('%.4f' % value).rstrip('0').rstrip('.') + +def _dim_or_dash(value): + """A table cell for one dimension: Eriksson's own '-' for blank.""" + if value is None: + return '-' + if isinstance(value, str): + value = value.strip() + return value if value else '-' + text = _feet_str(value) + return text if text is not None else '-' + + +def _from_word(word): + """'start' -> 'Start'; blank/None -> ''. Never invents a datum word.""" + if not word: + return '' + word = str(word).strip() + return word[:1].upper() + word[1:].lower() if word else '' + + +def _pos_cell(pos, frm): + """A 'Position' / 'Horizontal Position' cell: '8" from Start', or '-'.""" + dim = _dim_or_dash(pos) + if dim == '-': + return '-' + frm_text = _from_word(frm) + return '{0} from {1}'.format(dim, frm_text) if frm_text else dim + + +_TYPE_LABELS = {'opening': 'Opening', 'reveal': 'Reveal', + 'solid zone': 'Solid Zone', 'solid_zone': 'Solid Zone'} + + +def _type_label(value): + """The Type column's text -- a known word capitalised Eriksson's way, + + or, for anything this table does not recognise, the entered text back + in title case rather than a guessed category -- echoing what was typed + is honest; picking the "closest" known type is not. + """ + if not value: + return '-' + key = str(value).strip().lower().replace('_', ' ') + if key in _TYPE_LABELS: + return _TYPE_LABELS[key] + return str(value).strip().title() if str(value).strip() else '-' + + +def _tri_bool(text): + """(True/False/None) for a checkbox's raw text. None = "not yet said". + + Mirrors `panelanalysisform._parse_bool`'s own rule (blank is + "unspecified", never "no") without importing that private helper across + a module boundary neither module owns the other's internals of. + """ + if text is None: + return None + t = str(text).strip().lower() + if not t: + return None + if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included', 'checked'): + return True + if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded', + 'unchecked'): + return False + return None + + +# --------------------------------------------------------- panelanalysisform + +def _paf_field(key): + """The raw field dict `panelanalysisform.FIELDS` has for `key`, or None. + + None covers every way `paf` can fail to have the answer: the module + failed to import, it has no `FIELDS` (mid-restructure), or `key` is + simply not one it declares yet. All three are "not present", never an + error -- see the module docstring. + """ + if paf is None: + return None + try: + fields = paf.FIELDS + except AttributeError: + return None + try: + for f in fields: + if f.get('key') == key: + return f + except (TypeError, AttributeError): + return None + return None + + +def _field(key, editor, fallback_label, fallback_symbol, fallback_hint, + fallback_required, problems, choices=None, visible_when=None): + """One `field` block, sourced from `panelanalysisform` when it has the + key and from the fallback text when it does not. + + A missing key still gets a field on the page -- the Eriksson layout this + section is copying has a slot there -- but `problems` names it, because + a value typed into a field `panelanalysisform` does not declare cannot + be saved into the deck yet. That is the honest state of a form being + restructured by another agent at the same time, not a bug in this file. + """ + spec = _paf_field(key) + if spec is not None and paf is not None: + try: + label = paf.label_of(key) + symbol = paf.symbol_of(key) or None + hint = paf.help_of(key) or fallback_hint + required = bool(spec.get('required')) + except AttributeError: + spec = None + if spec is None: + label = fallback_label + symbol = fallback_symbol or None + hint = fallback_hint + required = fallback_required + problems.append( + "{0!r} is not a field vibe.panelanalysisform declares yet; " + "shown here to match Eriksson's own Concrete Extents page, but " + "a value typed into it will not be saved until that module " + "adds this key.".format(key)) + block = {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol, + 'hint': hint, 'required': required, 'editor': editor, + 'choices': choices} + if visible_when: + # `{'key': , 'in': [, ...]}` -- the page shows + # this field only while `values[key]` is one of those (see + # `docs/pa-web-spec.md`, "visible_when"); absent = always shown. + block['visible_when'] = dict(visible_when) + return block + + +def _miter_sentence(values, key, side): + """Eriksson's own discipline: an OFF miter prints a sentence, not a gap. + + Blank (unanswered) prints nothing here, same as Eriksson's page shows + nothing extra for a miter nobody has touched yet -- only an explicit + "no" earns the sentence, because blank means "not yet decided", not + "excluded" (see `_tri_bool`). + + `'quiet': True` (owner ruling, 2026-09-04): this is a footnote beside the + miter field it explains, not a finding -- it stays a `sentence` block (the + renderer already draws every `sentence` in grey italic, see `script.py`'s + `_render_sentence`) with the extra flag set so a renderer that later wants + to tell "a footnote beside a field" apart from "the openings summary + line" (the other `sentence` this page emits, deliberately left un-quiet) + can do so without guessing from the text. + """ + if _tri_bool(values.get(key)) is False: + return {'kind': 'sentence', 'quiet': True, + 'text': '{0} miter not included in this panel\'s design.' + .format(side)} + return None + + +# ------------------------------------------------------------- sub-sections + +def _panel_geometry_columns(values, problems): + """The three columns Eriksson's own Panel Geometry block prints: + + (Top Elevation, Bottom Elevation, Height, Section Type, Top in Form) | + (Width, Left Miter, Right Miter, Top In Form, Width, Height) | + (Thickness). + + `length_ft` (this codebase's name for the panel's long dimension) is + what Eriksson calls "Height" on this page -- the same field, read + through `panelanalysisform`'s own label rather than renamed here, so a + reviewer sees the one name every section of the form uses for it. + """ + col1 = [ + _field('top_elevation_ft', 'num', 'Top elevation', 'ft', + 'How high the top edge of the panel sits above the ' + 'project reference elevation. Leave blank if not yet ' + 'known.', False, problems), + _field('bottom_elevation_ft', 'num', 'Bottom elevation', 'ft', + 'How high the bottom edge of the panel sits above the ' + 'project reference elevation. Leave blank if not yet ' + 'known.', False, problems), + _field('length_ft', 'num', 'Panel length', 'ft', + "The panel's long dimension, end to end. Required -- " + 'everything downstream is measured against this span.', + True, problems), + _field('section_type', 'text', 'Section type', '', + 'A short description of the cross-section (solid, ' + 'sandwich, ribbed, ...). Reference only.', False, problems), + _field('top_in_form', 'text', 'Which face is up in the casting ' + 'bed', 'Interior / Exterior', + 'Which face of the panel faces up while it is cast. ' + 'Reference only.', False, problems, + choices=['Interior', 'Exterior'], + # Owner, 2026-09-07 (issue #22, item 3): "section type = + # solid 的时候,which face is up the casting bed 不应该存在" + # -- a Solid panel has no face that matters here, so the + # field is shown only while the section is Sandwich. The + # page hides/shows it live off `section_type` (see + # `docs/pa-web-spec.md`, `visible_when`). + visible_when={'key': 'section_type', 'in': ['Sandwich']}), + ] + + col2 = [ + _field('width_ft', 'num', 'Panel width', 'ft', + "The panel's dimension across the span -- how wide it is. " + 'Required.', True, problems), + _field('left_miter', 'choice', 'Left miter', '', + 'Whether the left end of the panel is cut at an angle ' + '(mitered) instead of square. Leave blank if this has ' + 'not been decided yet.', False, problems, + choices=['Yes', 'No']), + ] + sentence = _miter_sentence(values, 'left_miter', 'Left') + if sentence: + col2.append(sentence) + col2.append( + _field('right_miter', 'choice', 'Right miter', '', + 'Whether the right end of the panel is cut at an angle ' + '(mitered) instead of square. Leave blank if this has ' + 'not been decided yet.', False, problems, + choices=['Yes', 'No'])) + sentence = _miter_sentence(values, 'right_miter', 'Right') + if sentence: + col2.append(sentence) + # `top_in_form_check` / `top_in_form_width_in` / `top_in_form_height_in` + # (Eriksson's "top in form" STEP -- a change in formed thickness at + # the top edge) used to render here and are deliberately NOT rendered + # since 2026-09-07 (issue #22, item 4): nothing reads them -- not + # `pa_sketch_extents`, not the weight/centroid summary, not + # `panellift` -- and the owner's complaint was exactly a checkbox and + # two boxes that took input and changed nothing. They stay declared in + # `panelanalysisform.FIELDS` so a saved value survives, and return to + # this column the day the step is folded into the drawn section, the + # weight and the centroid. See `test_pa_extents.py`'s tombstone. + + col3 = [ + _field('thickness_in', 'num', 'Panel thickness', 'in', + 'How thick the panel is. Required.', True, problems), + ] + + return [{'kind': 'heading', 'text': 'Panel Geometry', 'level': 2}, + {'kind': 'columns', 'columns': [col1, col2, col3]}] + + +# --------------------------------------------------------- extents drawings +# +# The block dict shape below is a CONTRACT with the renderer sibling (owner +# ruling, 2026-09-04 -- see the module docstring's "WHY THE TWO DRAWINGS ARE +# NOW STACKED" note): every key here is read by name on the other side of +# that contract, so none of them is decorative and none may be renamed +# without breaking the renderer's half. `full_width: True` and `aspect` +# together replace the old fixed `w` / `h` pixel pair -- the renderer gives +# the drawing the whole column width and asks THIS module for a height via +# `aspect` (height/width), rather than this module baking in a pixel size +# that only happened to fit one window. + +_OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, _OUTLINE_ASPECT_DEFAULT = 0.12, 0.5, 0.3 +_SECTION_ASPECT_MIN, _SECTION_ASPECT_MAX, _SECTION_ASPECT_DEFAULT = 0.12, 0.35, 0.2 + +# Live-render finding, 2026-09-04: the outline's container was sized purely +# off `width_ft / length_ft`, with no allowance for the fixed pixel rows +# `pa_sketch_extents.outline()` draws OUTSIDE the panel rectangle itself -- +# the title / cut-station label above it, the L label and its dimension +# chain below. With no extra room the panel's own bottom edge was pinned to +# the canvas bottom and the L label was clipped away entirely. This is a +# flat ADDITION to the clamped ratio (not folded into the clamp bounds +# above, which stay the panel's own true proportion) -- `outline_geometry()` +# is what actually reserves the matching top/bottom pixel margins inside +# whatever height this produces; see that function's own docstring. +_OUTLINE_LABEL_ROWS_ASPECT = 0.10 + + +def _ratio_clamp(numerator, denominator, lo, hi, default): + """`numerator / denominator`, clamped to `[lo, hi]` -- `default` when + either side is missing or the division is not a sane positive ratio + (zero/negative denominator, a numerator that is not a real number yet). + Never raises: an aspect ratio this module cannot compute yet still has + to be SOME number, because the renderer sizes the drawing's canvas off + it before this module ever gets a chance to draw anything into that + canvas -- there is no "wait for real geometry" option here the way + `sketch()` / `extents_outline()` have one (an empty `primitives` list).""" + if numerator is None or denominator is None or denominator <= 0.0 \ + or numerator <= 0.0: + return default + ratio = numerator / denominator + if ratio < lo: + return lo + if ratio > hi: + return hi + return ratio + + +def _extents_drawing_blocks(values): + """The outline + section `drawing` block pair, stacked (owner ruling, + 2026-09-04): outline first, full width; section BELOW it, also full + width -- never side by side any more (see the module docstring). Both + name `module: 'pa_sketch_extents'`, the sibling module that owns + `outline()` / `section()`, the actual drawing functions -- this function + only builds the two block dicts the renderer reads to decide how big to + make each canvas and what to redraw it on, never draws anything itself. + """ + length_ft = _num(values.get('length_ft')) + width_ft = _num(values.get('width_ft')) + thickness_in = _num(values.get('thickness_in')) + thickness_ft = thickness_in / 12.0 if thickness_in and thickness_in > 0 \ + else None + + outline_aspect = _ratio_clamp( + width_ft, length_ft, _OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, + _OUTLINE_ASPECT_DEFAULT) + _OUTLINE_LABEL_ROWS_ASPECT + section_aspect = _ratio_clamp( + thickness_ft, width_ft, _SECTION_ASPECT_MIN, _SECTION_ASPECT_MAX, + _SECTION_ASPECT_DEFAULT) + + outline_block = { + 'kind': 'drawing', 'module': 'pa_sketch_extents', 'source': 'outline', + 'full_width': True, 'aspect': outline_aspect, + 'caption': 'Panel outline -- drag the red cut line to move the ' + 'section', + 'cutline': {'key': '_cut_frac', 'default': 0.5}, + # Names `pa_sketch_extents.outline_geometry` so the renderer's + # `_add_cutline()` can place the draggable line's x at + # `x0 + frac*(x1-x0)` -- the same box `outline()`'s own cut marker + # uses -- instead of the bare canvas width. See that module's + # "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER" note. + 'geometry': 'outline_geometry', + # 'opening' is the ROW TABLE prefix (`opening..`) -- the + # renderer's `redrawDependents` treats a `depends_on` entry as + # either an exact key match or a `key + '.'` PREFIX match, so this + # one entry watches every cell of every opening row (add/remove + # included) the same way 'openings_rows' used to watch the old + # passthrough list. 'openings_rows' itself stays listed too, for a + # saved panel still carrying the legacy passthrough with no table + # rows at all. + 'depends_on': ['opening', 'openings_rows', 'length_ft', 'width_ft'], + } + section_block = { + 'kind': 'drawing', 'module': 'pa_sketch_extents', 'source': 'section', + 'full_width': True, 'aspect': section_aspect, + 'caption': 'Cross-section at the cut line', + 'depends_on': ['_cut_frac', 'opening', 'openings_rows', + 'thickness_in', 'width_ft', 'section_type', + 'wythe_top_in', 'insulation_in', 'wythe_bottom_in'], + } + return [outline_block, section_block] + + +# Owner ruling, 2026-09-05: the Openings / Reveals / Solid Zones table is an +# EDITABLE ROW TABLE (`kind: 'editable_table'`, `table: 'opening'`), the same +# mechanism `pa_structural.py` uses for `restraint` / `conc_load` / +# `wind_zone` -- not a read-only dump of whatever `values['openings_rows']` +# happened to carry. Columns are the table's own simplified, ABSOLUTE- +# position vocabulary (`vibe.panelanalysisform.OPENING_COLS`): x_ft/length_ft +# along the panel's length (from the start), y_ft/width_ft across its width +# (from the bottom), depth_in for a Reveal's through-thickness cut only. +_OPENING_COLUMNS = [ + {'col': 'type', 'label': 'Type', 'editor': 'choice', + 'choices': ['Opening', 'Reveal', 'Solid zone']}, + {'col': 'x_ft', 'label': 'Position along length (ft)', 'editor': 'num'}, + {'col': 'length_ft', 'label': 'Length (ft)', 'editor': 'num'}, + {'col': 'y_ft', 'label': 'Position across width (ft)', 'editor': 'num'}, + {'col': 'width_ft', 'label': 'Width (ft)', 'editor': 'num'}, + {'col': 'depth_in', 'label': 'Depth, reveals only (in)', 'editor': 'num'}, +] + + +def _openings_blocks(values, problems): + """The Openings, Reveals, and Solid Zones heading + editable table. + + Always builds the table -- an editable row table has somewhere to add a + first row even when `values` carries none yet, unlike the old read-only + dump this replaced, which had nothing to show and no way to add + anything when `openings_rows` came up empty or absent. `problems` is + accepted for the same signature every other block-builder in this + module uses; nothing in this function raises one today. + """ + blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid ' + 'Zones', 'level': 2}] + blocks.append({'kind': 'sentence', 'quiet': True, + 'text': 'One row per opening, reveal or solid zone. ' + 'Position and length are measured from the ' + "panel's own start (length) and bottom (width); " + 'Depth applies to a Reveal only -- leave it ' + 'blank for an Opening (always full depth) or a ' + 'Solid Zone.'}) + blocks.append({'kind': 'editable_table', 'table': 'opening', + 'columns': _OPENING_COLUMNS, 'add_label': 'Add row'}) + return blocks + + +# `_TYPE_LABELS` above already names the three row types this page's own +# Openings/Reveals/Solid Zones TABLE recognises; the one-line drawing-area +# summary counts only the first two by name, per the owner's own wording +# (2026-09-03): "say so" means "N opening(s), M reveal(s)", not a count of +# every row type the table happens to carry. + +def _opening_table_type_rows(values): + """[{'type': text}, ...] read LIVE off the `opening` row table (owner + ruling, 2026-09-05), one dict per present row -- or `None` when + `panelanalysisform` cannot be reached (import failure) or the table + carries no present rows at all, so the caller can fall back to whatever + legacy `openings_rows` / `openings` text this `values` still carries + (a panel saved before this table existed). Reading the table live here, + rather than the `openings_rows` PASSTHROUGH_KEYS snapshot `prefill` took + at spec-build time, is what keeps this summary sentence agreeing with + the table after an edit + save + reload -- the passthrough key is never + updated by a row edit, only the table cells are. + """ + if paf is None: + return None + try: + rows = paf.rows_of(paf.OPENING_TABLE, values, paf.OPENING_COLS) + except AttributeError: + return None + if not rows: + return None + return [{'type': r.get('type') or ''} for r in rows] + + +def _openings_summary_sentence(values): + """The ONE line of text the drawing area prints in place of the old + parameter dump: 'N opening(s), M reveal(s)', or 'No openings or + reveals.' when there are none. Counts only rows whose `type` is + 'Opening' or 'Reveal' (case-insensitive) -- a Solid Zone is not an + opening and the owner's own phrasing names only the two. + + Prefers the LIVE `opening` row table (`_opening_table_type_rows`); when + that table carries no rows (or `panelanalysisform` cannot be reached), + falls back to the legacy `values['openings_rows']` list, and then to the + raw `openings` text field -- the same three-deep fallback `validate()` + itself uses, so this line and the Run button never disagree about which + source is live. + """ + values = values or {} + rows = _opening_table_type_rows(values) + if rows is None: + rows = values.get('openings_rows') + if not isinstance(rows, list): + raw = values.get('openings') + raw = raw.strip() if isinstance(raw, str) else \ + (str(raw).strip() if raw else '') + if raw: + return ('Openings/reveals were entered as plain text, not the ' + 'structured rows this line needs to count: ' + '{0!r}'.format(raw)) + return 'No openings or reveals.' + + n_open = 0 + n_reveal = 0 + for row in rows: + if not isinstance(row, dict): + continue + key = str(row.get('type') or '').strip().lower().replace('_', ' ') + if key == 'opening': + n_open += 1 + elif key == 'reveal': + n_reveal += 1 + + if n_open == 0 and n_reveal == 0: + return 'No openings or reveals.' + + def _plural(n, word): + return '{0} {1}{2}'.format(n, word, '' if n == 1 else 's') + + return '{0}, {1}.'.format(_plural(n_open, 'opening'), + _plural(n_reveal, 'reveal')) + + +# ------------------------------------------------------------------ page() + +def page(values, reads=None): + """(blocks, problems) for the Concrete Extents section. + + `values` is the form's current string-valued dict (plus, where a + sibling agent has landed it, the structured `openings_rows` list -- see + the module docstring). `reads` is what the button read straight off the + live framing element, the same shape `panelanalysisform.prefill` takes; + it is accepted for symmetry with the other section builders but not + read here -- everything this section needs is already folded into + `values` by the time `page` sees it, and reading `reads` again here + would risk showing a MODEL value beside a form value the user has since + typed over it, exactly the kind of stale-looking page the owner has + complained about. + + Never raises. Every field this module cannot place is a `problems` + sentence, not a traceback -- see the module docstring for the two + running examples (a `panelanalysisform` key that is not there yet, and + an `openings_rows` shape that has not landed yet). + """ + values = values if values is not None else {} + problems = [] + blocks = [{'kind': 'heading', 'text': 'Concrete Extents', 'level': 1}] + + blocks.extend(_panel_geometry_columns(values, problems)) + blocks.append({'kind': 'space', 'h': 8}) + + blocks.extend(_openings_blocks(values, problems)) + blocks.append({'kind': 'space', 'h': 8}) + + # Just the member's own concrete edge, plus its openings -- not the + # Area/Width/Height parameter dump the owner ruled out, 2026-09-03: + # "Concrete Extents does not need all those parameters ... In that + # section just draw the member's concrete edge. If there are openings, + # say so, and add a few dimension lines that locate the openings." And, + # 2026-09-04: the outline drawing bigger, full width; the section BELOW + # it (not beside it), full width too, reading a draggable cut line off + # the outline -- see `_extents_drawing_blocks()` and the module + # docstring's "WHY THE TWO DRAWINGS ARE NOW STACKED" note for why this + # is two `full_width` blocks naming `pa_sketch_extents`, not the old + # side-by-side `columns` pair naming `panelsketch.extents_outline` / + # `extents_section` directly. + blocks.extend(_extents_drawing_blocks(values)) + blocks.append({'kind': 'sentence', 'text': _openings_summary_sentence(values)}) + + return blocks, problems diff --git a/WillTools.extension/lib/vibe/pa_handling.py b/WillTools.extension/lib/vibe/pa_handling.py new file mode 100644 index 0000000..2b56059 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_handling.py @@ -0,0 +1,1296 @@ +# -*- coding: utf-8 -*- +"""The Panel Analysis form's "Handling" section, rendered as generic blocks. + +WHY THIS EXISTS: the owner has now said three times that the Panel Analysis +input form is too ugly and unreadable to use ("太丑了", "你这么输入用户上哪儿能 +看懂"), and the last ruling was blunt about the fix -- "全部给我做成普通人能看懂 +的样子", make every part of it something an ordinary person can understand. The +"Handling" section is the worst offender: it used to be three lonely text +boxes (`strip_mult` / `transport_mult` / `erection_mult`) even though Eriksson +Wall's own printed page for this section is a whole per-stage MATRIX -- +eleven questions asked once per handling stage, plus lifter/dunnage tables and +a plan drawing. `vibe.panelanalysisform` was rebuilt first (its own docstring +covers that ruling and the Eriksson-parity one before it) and now exposes a +real stage matrix (`STAGES`, `STAGE_OPTIONS`, `stage_matrix`, +`stage_field_key`). This module is the second half: turning that data plus +the plain-language field metadata into the flat, renderer-agnostic BLOCK list +a generic WPF page consumes, matching Eriksson's own page 25/26 layout +(Stage Options matrix + legend + weight/centroid + plan drawing + lifter and +dunnage tables + Lifting Hardware) as closely as the two modules' real data +will honestly support. + +2026-09-04 RULING, THREE PARTS, all folded into this pass: + + 1. "handling 板块里面小灰字太多了,去掉放到 notes 里面,默认折叠" -- every + helper sentence this module used to print inline now carries + `'quiet': True` alongside its `kind: 'sentence'`. The block VOCABULARY + is unchanged (`_KNOWN_KINDS` in the test suite does not grow); `quiet` + is a hint for the renderer to route the sentence into the same + collapsed Notes panel `problems` already lands in, not a new kind. This + module does not (and cannot) verify the renderer honours the hint -- + that is the renderer's job, tracked separately -- so every sentence + this module builds carries it regardless, and a page-import failure's + own explanation sentence is no exception (see `page()`'s early return). + 2. "centroid 该算要算的" -- the Panel Weight / Centroid block now COMPUTES + the centroid instead of saying it is not tracked. The arithmetic lives + in the sibling `vibe.pa_sketch_handling` module (`centroid()`), shared + with the plan drawing's centroid marker so the two numbers can never + disagree; see that module's docstring for why Y is not mass-weighted + for a Sandwich section. + 3. "lift&dunnage detail 画的完全不对...你总是横着画" -- the LIFTER AND + DUNNAGE DETAIL drawing no longer names `panelsketch.plan` as its + `source`. It names `vibe.pa_sketch_handling.lifter_dunnage` instead (a + new `module` key on the `drawing` block, alongside `source` -- see + `page()`'s drawing block for the exact shape), full-width, with an + `aspect` computed from the panel's own length/width so the container + is sized to roughly match the drawing's length-fills-the-width layout + before the drawing itself ever runs. + +2026-09-04 SECOND RULING, off a headless render of this tab against a real +wall (four edge lifters all sitting at `x == length_ft`), five more parts: + + 1. THE FACE / SIDE EDGE LIFTERS TABLE labelled every `edge_lifters` row + 'Side Edge' by which FIELD it came from, never by where it actually + sits -- wrong for the four lifters at `x == length_ft`, which are + TOP-edge lifters. `_lifter_tables` now classifies through + `pa_sketch_handling.parse_edge_lifters()` (the SAME function the + drawing calls, via `_classified_edge_rows_for_table` / + `_edge_type_label`), so the table's Type column reads 'Face' / 'Side + Edge' / 'Top Edge' / 'Bottom Edge' and can never disagree with the + picture above it. `top_edge_lifters` / `side_edge_lifters` -- new + free-text fields the reads sibling supplies, same `'x_ft @ frac'` + grammar -- are read when present, ahead of re-classifying + `edge_lifters` by geometry. + 2. THE DUNNAGE SENTENCE used to read "Design contains no dunnage + entered" unconditionally -- there has never been a `dunnage` FIELD, + so it never once looked at whether `values['dunnage']` or the reads + sibling's `values['dunnage_positions_ft']` actually held anything. + It now asks `pa_sketch_handling.dunnage_positions()` (see + `_lifter_section_blocks`), and the RED inline note the drawing itself + used to print under the picture for the same "nothing entered" case + is gone -- see that function's own docstring. There is now exactly + ONE quiet sentence about dunnage on the whole page. + 3. THE OLD "PANEL WEIGHT DISTRIBUTION" `weight_diagram` DRAWING under + Lifting Hardware is gone -- it was a small fixed-size picture with no + real per-stage weight-split data behind it. `_lifting_hardware_blocks` + now builds a "Lifting hardware" TABLE (Family / Kind / Qty) off + `lifters_basis` (see `_parse_lifters_basis` / + `_lifting_hardware_table_block`) -- the sentence + `pa_lifters.read_lifters()` already writes naming each lifter FAMILY + and how many of each kind. Capacities are not tracked anywhere this + page can reach yet ("come later"), which is now the section's only + remaining `problems` gap. + 4. THE 'SHAPES' LEGEND moved to sit directly under the plan drawing + (`page()` appends it right after the `drawing` block, before the + Face / Side Edge Lifters section) instead of under the Stage Options + matrix, and carries `'compact': True` -- see `_legend_block`. + 5. THE PANEL WEIGHT / CENTROID kv block now PREFERS + `values`/`reads['weight_centroid_ft']` -- an `{'x', 'y', 'z'}` dict + the reads sibling supplies straight off the concrete solid's own + measured centroid, basis 'concrete solid centroid' -- over + `pa_sketch_handling.centroid()`'s rectangle-plus-openings arithmetic. + See `_weight_centroid_xyz`. + +2026-09-04 THIRD RULING, off a LIVE render of the second ruling's own fix, +two parts: + + 1. THE 'TOP EDGE LIFTERS' AND 'DUNNAGE' HEADINGS rendered with nothing + visible under them -- both had exactly one child block, and it was a + `quiet` sentence, which the renderer routes OUT of inline view + entirely (into the collapsed Notes panel) -- so a heading whose only + content is a quiet sentence reads as an empty section, not a heading + with a quiet note under it. `_lifter_tables` now returns TWO tables + instead of one: `main_table` (Face + Side Edge rows only, matching + Eriksson's own "Face / Side Edge Lifters" table) and `top_edge_table` + (Top Edge + Bottom Edge rows), the latter printed under the SEPARATE + "Top Edge Lifters" heading by `_lifter_section_blocks` instead of + being folded into the first table. "Dunnage" gets the same treatment + -- `_dunnage_table_block` prints an actual positions table (one row + per distinct dunnage position) off `pa_sketch_handling. + dunnage_positions()` when there is anything to show. Both headings + still fall back to a field editor (if one ever exists) and finally to + ONE quiet sentence, but ONLY when there is genuinely nothing of + either kind -- never a bare heading. + 2. THE PANEL WEIGHT / CENTROID kv block had `Y (from bottom)` and `Z + (from left)` SWAPPED for a `weight_centroid_ft` read: the dict's own + axes are `{'x': along length, 'y': across width, 'z': through + thickness}` -- the MODEL's XYZ, not Eriksson's row labelling -- and + `_weight_centroid_xyz` used to read `dict['y']` straight into + `cg_y` (which the kv rows print as "from bottom", i.e. through + THICKNESS) and `dict['z']` into `cg_z` ("from left", i.e. across + WIDTH). It now returns `(cg_x, cg_y, cg_z)` ALREADY in the kv rows' + own axes -- `cg_y <- dict['z']`, `cg_z <- dict['y']` -- so + `_weight_centroid_block` never has to remember the crossover itself. + Read `_weight_centroid_xyz`'s own docstring before touching this + mapping again; getting it backwards is exactly what happened here. + +THE BLOCK VOCABULARY IS FIXED and belongs to the page renderer, not to this +module -- 'heading' / 'columns' / 'field' / 'matrix' / 'table' / 'legend' / +'kv' / 'sentence' / 'drawing' / 'rule' / 'space', nothing else. An unknown +`kind` renders as nothing, so a typo here is a silently blank section rather +than a crash; `page()` is tested against exactly that vocabulary for that +reason. + +READ `vibe.panelanalysisform` DEFENSIVELY. Another agent owns that module and +was mid-restructure of exactly the stage matrix this file depends on while +this file was first written, and that discipline has not relaxed since. +Every accessor this module leans on -- `STAGES` / `STAGE_OPTIONS` / +`stage_field_key` / `FIELDS` / `label_of` / `symbol_of` / `help_of` -- is +reached through `getattr`/`hasattr` or a try/except, never assumed. A shape +that is not there yet (or has changed shape) becomes one plain-English +sentence in `problems`, and the rest of the page still renders around the +hole -- never a raised exception, and never an invented value standing in +for the one that could not be read. This is the same "could not judge" vs +"judged, found nothing" discipline as everywhere else in this codebase (see +CLAUDE.md SS3): a missing accessor is reported by name, not silently +skipped and not guessed at. `vibe.pa_sketch_handling` (the centroid / +drawing sibling this module now depends on) and `vibe.pa_lifters` (the +lifter-line parser) are read the same defensively -- see `_import_psh` / +`_import_pa_lifters` -- because they are just as capable of being mid-edit +by another agent as `panelanalysisform` always was. + +WHAT COULD NOT BE BUILT FROM REAL DATA, and why it is a `problems` entry +instead of a fabricated block -- shrunk twice since this module's first +draft (2026-09-04's first ruling closed the centroid and the drawing; +2026-09-04's second closed the edge-lifter grouping, the dunnage sentence +and the hardware table), but Eriksson's own "Handling" pages still carry +things `vibe.panelanalysisform` does not model yet: + + * a per-lifter STRUCTURED row (quantity, longitudinal position, two + transverse positions, a tick per stage, "on top face") for editing. + The form only has `face_lifters` / `edge_lifters` (plus, when the reads + sibling supplies them, `top_edge_lifters` / `side_edge_lifters`) as + free-text fields ("column @ fraction of width", one line per lifter); + this section SUMMARISES that text as an Eriksson-shaped table, grouped + into Face / Side Edge / Top Edge / Bottom Edge (see + `_lifter_tables`), but the underlying text fields are still what + the user actually edits, and stay on the page, visible, right below + the table. + * a dedicated "top edge lifters" FIELD on `panelanalysisform` itself. + There isn't one -- `top_edge_lifters` only ever arrives as a `values` + key the reads sibling fills in, never as a `panelanalysisform.FIELDS` + entry -- so the "Top Edge Lifters" heading falls back to an EDITABLE + field only if a future restructure adds one; today, with no field and + no top/bottom-edge lifter data either, it falls all the way to a + sentence saying so, in the same voice Eriksson's own page uses for "no + top edge lifters on this design" (never a blank, per CLAUDE.md SS3's + "nothing here" vs "never asked" rule -- an omitted field and an empty + one must not look the same). When there IS top/bottom-edge data, the + heading gets a real table instead (see the THIRD ruling above) -- the + sentence is only the last resort, not the whole story. + * a "Dunnage" FIELD of any kind. There isn't one -- so the "Dunnage" + heading falls back to a positions TABLE (see `_dunnage_table_block`) + when `values['dunnage']` / `values['dunnage_positions_ft']` held + anything, off the same `pa_sketch_handling.dunnage_positions()` the + drawing itself calls, and only to the ONE quiet sentence when neither + did -- same "table first, sentence as the last resort" shape as Top + Edge Lifters above. + * the "Lifting Configurations" sketches on Eriksson's second page (one + small diagram per included stage, each with its own G/CG marker), and + a CAPACITY rating per lifter family. No `panelsketch` entry point for + the former exists, and nothing on this page's data path carries a + capacity number for the latter (the "Lifting hardware" table -- see + `_lifting_hardware_table_block` -- names each family and how many of + each kind, off `lifters_basis`, but stops there) -- so both remain one + `problems` entry apiece and a short explanatory (quiet) sentence, not + an invented drawing source or an invented number. + +THE STAGE MATRIX'S "DOES NOT APPLY" COLUMNS are a STRUCTURAL fact about which +questions make sense for which stage, not a report of which stage happens to +be ticked on right now. Reading Eriksson's own page 25 by column: Rigging +Configuration / Sling Angle / Add Ground Reaction / Minimum-Maximum Tilt +Angle / Assume Top Edge Lifters Carry Panel Weight are answered only for the +two erection stages (Primary, Secondary) and dashed for Stripping and +Transportation in EVERY sample seen, independent of whether a given erection +stage is itself switched on -- a crane pick has a rigging configuration and a +tilt range; a stripping pull or a truck ride does not. `_ERECTION_ONLY` below +is that fixed structural map. It is deliberately NOT keyed off each stage's +own `include` flag: the owner's rule for `panelanalysisform._parse_stage_matrix` +is that switching a stage off must never make its OTHER cells un-requestable +or drop what the user already typed into them, and printing an em dash over a +cell the user could still type into would do exactly that. So the only cells +this module ever marks `{'na': True}` are ones no stage state can turn back +on; a merely-unticked stage's cells stay ordinary editable cells, matrix-wide. + +STAGE-MATRIX EDITOR KINDS (owner, 2026-09-04: "很多东西应该是选项,而不是让用户 +填写") -- `_stage_options_editor` already upgrades every row that carries a +fixed vocabulary (`lift_type` / `concrete_strength` / `stress_limits` / +`rigging`) to a `choice` editor, and `include` is always a `check`; this is +unchanged by the ruling above, kept here as confirmation, not as new work. + +Standard-library only. No Revit, no WPF, no `panelsketch` import (a source +name is enough -- see the block vocabulary's 'drawing' kind). Runs on +IronPython 2.7 in the button and on plain CPython in `tests/`: no f-strings, +no dataclasses/typing/enum, `'{0}'.format(...)` throughout. +""" + +import re + +# `feet_str` (decimal feet -> '8\'-4 1/2"') -- owner ruling, every length +# this tool displays is feet-inches, never decimal feet (CLAUDE.md / +# `docs/pa-web-spec.md`). Shared with `vibe.pa_extents`, which had the +# identical need first (2026-09-04) -- see `vibe.pa_units`'s own docstring +# for why the rounding lives in one place rather than two copies that can +# drift. Pure standard library, no Revit, safe to import at module scope. +from vibe.pa_units import feet_str as _feet_str + +# Editor kind a FIELDS-style `kind` renders as, for the plain fields this +# section pulls straight from `vibe.panelanalysisform.FIELDS` (weight_lb, +# self_weight_factor, face_lifters, edge_lifters). Anything not listed here +# falls back to a free-text box -- 'supports' / 'openings' / 'strands' are +# all typed as structured-but-still-one-text-box fields on the real form, and +# nothing in the Handling section uses them, but the fallback keeps this +# honest if that ever changes. +_EDITOR_OF_KIND = { + 'text': 'text', + 'num': 'num', + 'int': 'num', + 'supports': 'text', + 'openings': 'text', + 'strands': 'text', +} + +# Fallback choice vocabulary for the free-text stage-matrix options, used +# ONLY when `STAGE_OPTIONS`'s own row is the older 6-tuple shape with no +# choices slot of its own (see `_unpack_stage_option`). Once the row carries +# its own choices, that is authoritative and this is never consulted -- two +# sources of truth for the same list would drift. These are OFFERED choices, +# never a default -- the current value still comes from whatever is in +# `values[key]`, blank if nothing was typed. Picking one is the user's +# decision; the owner's instruction is explicit that none of them stand in +# as a default. +_STAGE_CHOICES_FALLBACK = { + 'lift_type': ['Static Lift', 'Rolling Block'], + 'concrete_strength': ['Initial', 'In-Place'], + 'stress_limits': ['Handling', 'In-Place'], + 'rigging': ['Auto'], +} + + +def _num(value): + """A float, or None. Accepts a number already, or text; blank -> None. + Small and local rather than imported -- see the module docstring's "no + panelsketch import" rule, which this module extends to its own tiny + parsing needs (the drawing `aspect` and a table's width_ft) the same + way `panelsketch.py` keeps its own `_num` rather than importing one.""" + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +def _clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + + +def _unpack_stage_option(opt): + """(opt_key, label, symbol, help, kind, choices) from one STAGE_OPTIONS + row, or None if the row cannot be read at all. + + Defensive against exactly the kind of mid-refactor shape change this + module was written under: `STAGE_OPTIONS` grew a seventh `choices` slot + on live disk between this module's first draft and this test run (a + 7-tuple with the new slot, where the 6-tuple it replaced had none). Both + shapes are accepted; `choices` falls back to + `_STAGE_CHOICES_FALLBACK` only for the older shape, and is `None`/`[]` + for an option with no fixed vocabulary either way. `drives` (index 5 of + the 7-tuple) is not needed here -- NA-ness is decided structurally by + `_ERECTION_ONLY`, not by `drives` -- so it is simply not unpacked.""" + try: + opt_key, label, symbol, help_text, kind, _drives, choices = opt + choices = list(choices) if choices else None + return opt_key, label, symbol, help_text, kind, choices + except (TypeError, ValueError): + pass + try: + opt_key, label, symbol, help_text, kind, _drives = opt + choices = _STAGE_CHOICES_FALLBACK.get(opt_key) + return opt_key, label, symbol, help_text, kind, choices + except (TypeError, ValueError): + return None + +# The stage-matrix options that only make sense for a stage where the panel +# is actually being picked up and set (Primary / Secondary Erection). See the +# module docstring's "STRUCTURAL fact" paragraph -- this is NOT about whether +# a given stage is ticked on. +_ERECTION_ONLY = set([ + 'rigging', 'sling_angle', 'ground_reaction', + 'min_tilt_deg', 'max_tilt_deg', 'top_crane_carries', +]) +_NOT_ERECTION_STAGES = set(['Stripping', 'Transportation']) + +# Fields this section pulls straight out of `vibe.panelanalysisform.FIELDS` +# by key, grouped the way this page presents them. Every key here is checked +# against the real module before use -- see `_field_block`. +_LOAD_FACTOR_KEYS = ('weight_lb', 'self_weight_factor') +_LIFTER_TEXT_KEYS = ('face_lifters', 'edge_lifters') + +# 2026-09-04: the Face / Side Edge Lifters TABLE used to label every +# `edge_lifters` row "Side Edge" unconditionally, by which FIELD it came +# from -- a headless render against a real wall (four edge lifters all +# sitting at x == length_ft, i.e. TOP-edge lifters) caught that as wrong. +# The label now comes from `pa_sketch_handling.parse_edge_lifters()`'s +# actual classification (see `_edge_type_label` / `_lifter_tables`), +# never from the source field's name alone. +_EDGE_TYPE_LABEL = {('end', 'top'): 'Top Edge', ('end', 'bottom'): 'Bottom Edge', + ('side', 'bottom'): 'Side Edge', ('side', 'top'): 'Side Edge'} + + +def _edge_type_label(kind, side): + """'Top Edge' / 'Bottom Edge' / 'Side Edge' for one + `pa_sketch_handling.parse_edge_lifters()` row -- Eriksson's own three + groups (Face / Side Edge / Top Edge) plus the symmetric Bottom Edge + case that group naming does not otherwise cover. Never raises: an + unrecognised `(kind, side)` pair (should not happen -- see that + function's own docstring for the only values it returns) falls back to + the plain 'Edge' rather than a KeyError.""" + return _EDGE_TYPE_LABEL.get((kind, side), 'Edge') + + +def _import_paf(): + """The real `vibe.panelanalysisform`, or (None, reason). + + A separate function so `page()` reads like the rest of this module: one + early check, one plain-English reason, never a raised ImportError. + """ + try: + from vibe import panelanalysisform as paf + except ImportError as exc: + return None, ('vibe.panelanalysisform could not be imported ' + '({0}); the Handling section cannot be built at ' + 'all'.format(exc)) + return paf, None + + +def _import_psh(): + """The real `vibe.pa_sketch_handling` (centroid arithmetic + the plan + drawing), or (None, reason) -- same "never raise" discipline as + `_import_paf`. A sibling this module now depends on for its two + 2026-09-04 additions (centroid, the plan drawing), so it gets the same + defensive treatment as every `panelanalysisform` accessor.""" + try: + from vibe import pa_sketch_handling as psh + except ImportError as exc: + return None, ('vibe.pa_sketch_handling could not be imported ' + '({0}); the panel centroid and the lifter/dunnage ' + 'drawing cannot be built'.format(exc)) + return psh, None + + +def _import_pa_lifters(): + """The real `vibe.pa_lifters` (the lifter-line parser), or (None, + reason). Used only for `_lifter_tables`'s grouped summary table -- + a missing/broken module degrades that ONE table to a `problems` entry, + never the whole page.""" + try: + from vibe import pa_lifters + except ImportError as exc: + return None, ('vibe.pa_lifters could not be imported ({0}); the ' + 'Face / Side Edge Lifters table cannot be built (the ' + 'text fields below still work)'.format(exc)) + return pa_lifters, None + + +def _field_exists(paf, key): + """Whether `key` is a real entry in `paf.FIELDS` right now. + + Never assumes `FIELDS` itself is present or list-shaped -- the module + this reads from is being restructured by another agent as this is + written. + """ + fields = getattr(paf, 'FIELDS', None) + if not fields: + return False + try: + for f in fields: + if isinstance(f, dict) and f.get('key') == key: + return True + except TypeError: + return False + return False + + +def _field_kind(paf, key): + for f in getattr(paf, 'FIELDS', None) or []: + if isinstance(f, dict) and f.get('key') == key: + return f.get('kind') or 'text', bool(f.get('required')) + return 'text', False + + +def _field_block(paf, key): + """A `{'kind': 'field', ...}` block for a real `panelanalysisform` key. + + Caller must have already confirmed `_field_exists(paf, key)`. Labels, + symbols and hints all come from the module's own accessors rather than + being re-typed here, so this page can never drift from the field it is + describing -- and those accessors already degrade to the bare key if + something about the field record is missing, so this cannot raise. + """ + label = paf.label_of(key) if hasattr(paf, 'label_of') else key + symbol = paf.symbol_of(key) if hasattr(paf, 'symbol_of') else '' + hint = paf.help_of(key) if hasattr(paf, 'help_of') else '' + kind, required = _field_kind(paf, key) + editor = _EDITOR_OF_KIND.get(kind, 'text') + return { + 'kind': 'field', 'key': key, 'label': label, + 'symbol': symbol or None, 'hint': hint or None, + 'required': required, 'editor': editor, 'choices': None, + } + + +def _row_label(label, symbol): + """'label — symbol', matching `panelanalysisform.display_label`'s own + join -- the symbol rides beside the plain English, never replaces it.""" + if symbol: + return u'{0} — {1}'.format(label, symbol) + return label + + +def _quiet(text): + """One `{'kind': 'sentence', ...}` block, always `'quiet': True` -- see + the module docstring's 2026-09-04 ruling #1. Every helper sentence this + module builds goes through this, never a bare sentence dict, so a + sentence added later cannot forget the flag.""" + return {'kind': 'sentence', 'text': text, 'quiet': True} + + +def _stage_options_editor(kind, choices=None): + """The matrix cell's editor kind: `check` / `num`, or `choice` for any + text row that carries its own fixed vocabulary (lift_type, + concrete_strength, stress_limits, rigging today), else plain `text`. + + Matrix cells are built by `make_editor` directly (`_render_matrix` + bypasses `_render_field`'s registry-upgrade fallback -- see + `panelanalysisform.choices_for`'s docstring), so a row with real + `choices` must ask for `choice` explicitly here or it would render as a + free-text box forever, no matter what `FIELD_CHOICES` says (owner, + 2026-09-04: "很多东西应该是选项,而不是让用户填写"). + """ + if kind == 'bool': + return 'check' + if kind == 'num': + return 'num' + if choices: + return 'choice' + return 'text' + + +def _stage_matrix_block(paf, problems): + """The Stage Options matrix block, or None if it cannot be built. + + Reads `STAGES` and `STAGE_OPTIONS` off `paf` defensively -- each missing + or malformed piece is one `problems` sentence naming it, and a row that + cannot be unpacked is skipped rather than raising, so a partial + restructure of the source module degrades this one matrix instead of the + whole page. + """ + stages = list(getattr(paf, 'STAGES', ()) or ()) + if not stages: + problems.append('vibe.panelanalysisform has no STAGES list yet; ' + 'the Stage Options matrix cannot be built') + return None + options = getattr(paf, 'STAGE_OPTIONS', None) + if not options: + problems.append('vibe.panelanalysisform has no STAGE_OPTIONS list ' + 'yet; the Stage Options matrix cannot be built') + return None + stage_field_key = getattr(paf, 'stage_field_key', None) + if not callable(stage_field_key): + problems.append('vibe.panelanalysisform has no stage_field_key() ' + 'yet; the Stage Options matrix cells have no keys ' + 'to write to') + return None + + rows = [] + for opt in options: + unpacked = _unpack_stage_option(opt) + if unpacked is None: + problems.append('a Handling stage option row has an unexpected ' + 'shape ({0!r}); skipped'.format(opt)) + continue + opt_key, label, symbol, help_text, kind, choices = unpacked + editor = _stage_options_editor(kind, choices) + cells = [] + for stage in stages: + if opt_key in _ERECTION_ONLY and stage in _NOT_ERECTION_STAGES: + cells.append({'na': True}) + continue + try: + key = stage_field_key(stage, opt_key) + except Exception as exc: + # A broken key builder must not take the whole page down; + # name it and leave that one cell blank instead. + problems.append( + 'could not build the form key for {0} / {1} ({2}); that ' + 'cell is left blank'.format(stage, label, exc)) + cells.append(None) + continue + cells.append({'editor': editor, 'key': key, 'choices': choices}) + rows.append({'label': _row_label(label, symbol), + 'hint': help_text or None, 'cells': cells}) + + if not rows: + problems.append('every Handling stage option row was unreadable; ' + 'the Stage Options matrix has nothing to show') + return None + return {'kind': 'matrix', 'columns': stages, 'rows': rows} + + +def _legend_block(): + """The plan drawing's shape key, redrawn generically to match + `pa_sketch_handling.lifter_dunnage`'s glyph vocabulary (2026-09-04 + ruling #3 replaced the drawing this legend describes, so the legend + changed with it -- a face lifter is a filled circle, an edge lifter a + triangle on the edge it sits on, dunnage a dashed transverse line; the + old Fills group described `panelsketch.plan`'s per-stage marker colors, + which the new drawing has no data to populate, so it is dropped rather + than kept as a key nothing on the page uses). + + A SECOND 2026-09-04 ruling moved this block to sit directly under the + drawing (`page()` appends it immediately after the `drawing` block, no + heading or space between) and asks for it smaller -- `'compact': True` + is a rendering hint for that, the same "the renderer does not read it + yet, carried so a later renderer change needs no block-shape change" + status as `pa_sketch_handling`'s own `dashed` / `font_px` hints. + """ + return { + 'kind': 'legend', + 'compact': True, + 'groups': [ + ('Shapes', [ + (u'●', 'Face lifter'), + (u'▲', 'Edge lifter (side or top)'), + (u'- - -', 'Dunnage'), + ]), + ], + } + + +def _fmt_centroid(value, basis): + """Feet-inches, never decimal feet (owner ruling -- every length this + tool displays is `8'-4 1/2"`, to 1/8"). `_feet_str` only fails (`None`) + when `value` itself is not a number; that case is folded into the same + 'not entered yet' text a genuinely missing centroid already prints.""" + if value is None: + return 'not entered yet' + text = _feet_str(value) + if text is None: + return 'not entered yet' + if basis: + return '{0} ({1})'.format(text, basis) + return text + + +def _weight_centroid_xyz(values, reads): + """(cg_x, cg_y, cg_z) in ft, ALREADY in the kv block's own axes -- X + from the start (along length), Y from the bottom (through thickness), + Z from the left (across width) -- off `values['weight_centroid_ft']` / + (as a fallback) `reads['weight_centroid_ft']`. + + THE DICT ITSELF USES A DIFFERENT AXIS ASSIGNMENT than the kv rows do, + and this is the one thing to get right here: the reads sibling writes + `{'x': along length, 'y': across width, 'z': through thickness}` -- the + MODEL's own XYZ, not Eriksson's row labelling. Reading `dict['y']` + straight into `cg_y` and `dict['z']` straight into `cg_z` (what an + identity mapping would do, and what this function did before + 2026-09-04's THIRD ruling caught it on a live render -- "Centroid, Y + (from bottom)" printing the WIDTH figure and "Centroid, Z (from left)" + printing the THICKNESS figure) is backwards: `cg_y` (from bottom, + thickness) must come from `dict['z']`, and `cg_z` (from left, width) + from `dict['y']`. `dict['x']` (along length) DOES map straight to + `cg_x` -- only the Y/Z pair crosses over. + + `None` unless all three components read as a number -- a partial dict + is not a centroid, and mixing a read axis with a computed one would be + exactly the kind of invented pairing CLAUDE.md SS3 rules out, so this + is all-or-nothing: either every axis comes from the same measured + source, or none does and `centroid()` computes the lot from geometry + instead.""" + wc = values.get('weight_centroid_ft') + if wc is None and reads: + wc = reads.get('weight_centroid_ft') + if not isinstance(wc, dict): + return None + along_length = _num(wc.get('x')) + across_width = _num(wc.get('y')) + through_thickness = _num(wc.get('z')) + if along_length is None or across_width is None \ + or through_thickness is None: + return None + return along_length, through_thickness, across_width + + +def _weight_centroid_block(paf, psh, values, reads, problems): + """The Panel Weight / Centroid kv block. + + Weight comes from the real `weight_lb` field -- the user's override, or + whatever the model could read, exactly like every other prefilled field + on this form -- converted to kips to match Eriksson's own display. The + centroid (X from start / Y from bottom / Z from left, Eriksson's own + order) PREFERS `values`/`reads`' own `weight_centroid_ft` -- the reads + sibling's measured concrete-solid centroid, basis 'concrete solid + centroid' (2026-09-04) -- and falls back to + `pa_sketch_handling.centroid()`'s rectangle-plus-openings arithmetic + when that is not there (owner, 2026-09-04: "centroid 该算要算的") -- see + that function's docstring for the arithmetic and for why Y is never + mass-weighted for a Sandwich section. A row that could not be computed + either way says so in plain words rather than printing a fabricated + 0.000. + """ + weight_row = ('Panel weight', 'not entered yet -- computed from the ' + 'geometry above once you run the analysis') + if _field_exists(paf, 'weight_lb'): + text = (values.get('weight_lb') or '').strip() + if not text and reads: + text = paf._fmt(reads.get('weight_lb')) if hasattr(paf, '_fmt') \ + else str(reads.get('weight_lb') or '') + if text: + try: + kips = float(text) / 1000.0 + weight_row = ('Panel weight', + '{0:.3f} kips (as entered)'.format(kips)) + except (TypeError, ValueError): + weight_row = ('Panel weight', + '{0} lb (as entered, not a plain number)' + .format(text)) + else: + problems.append('vibe.panelanalysisform has no weight_lb field; ' + 'panel weight cannot be shown') + + cg_x = cg_z = cg_y = None + xz_basis = y_basis = None + measured = _weight_centroid_xyz(values, reads) + if measured is not None: + cg_x, cg_y, cg_z = measured + xz_basis = y_basis = 'concrete solid centroid' + elif psh is not None: + cg_x, cg_z, cg_y, xz_basis, y_basis, cg_problems = psh.centroid(values) + problems.extend(cg_problems) + else: + problems.append('the panel centroid could not be computed -- see ' + 'the vibe.pa_sketch_handling problem above') + + return { + 'kind': 'kv', + 'rows': [ + weight_row, + ('Centroid, X (from start)', _fmt_centroid(cg_x, xz_basis)), + ('Centroid, Y (from bottom)', _fmt_centroid(cg_y, y_basis)), + ('Centroid, Z (from left)', _fmt_centroid(cg_z, xz_basis)), + ], + } + + +def _num_or_none(v): + return _num(v) + + +def _fmt_transverse(fracs, width_ft): + """'1'-6", 3'-2 3/8"' when `width_ft` is known, else '25%, 75%' -- the + frac values are what is actually typed (`values['face_lifters']` / + `values['edge_lifters']`, 'x_ft @ frac'); a real width converts them to + a distance because that is what "Transverse Position(s)" means on + Eriksson's own page, but the fraction alone is still shown rather than + blocking the whole row on a field that happens to be blank. A distance + prints feet-inches (owner ruling -- every length this tool displays is + `8'-4 1/2"`, never decimal feet); the fraction stays a percentage, + which is not a length at all.""" + bits = [] + for f in fracs: + if width_ft: + text = _feet_str(f * width_ft) + bits.append(text if text is not None else '{0:.2f} ft'.format( + f * width_ft)) + else: + bits.append('{0:.0%}'.format(f)) + return ', '.join(bits) + + +def _group_lifter_rows(rows): + """[(x_ft, [frac, ...])], grouped by (rounded) x_ft, transverse fracs + kept in ascending order -- "grouping identical x into one row with its + transverse list" (owner, 2026-09-04). `rows` is the list of `{'x_ft', + 'frac'}` dicts `pa_lifters.parse_lifter_lines` returns.""" + groups = {} + order = [] + for r in rows: + key = round(r['x_ft'], 3) + if key not in groups: + groups[key] = [] + order.append(key) + groups[key].append(r['frac']) + order.sort() + return [(x, sorted(groups[x])) for x in order] + + +def _classified_edge_rows_for_table(psh, values, length_ft, width_ft, + problems): + """[(x_ft, type_label, frac), ...] for every edge lifter, off + `pa_sketch_handling.parse_edge_lifters()` -- the SAME classifier the + drawing itself uses, so the table and the drawing can never disagree + about which group a lifter belongs to (2026-09-04: this is exactly the + bug a headless render caught -- the table used to label every + `edge_lifters` row 'Side Edge' by field name alone, even for the four + lifters sitting at `x == length_ft` the drawing correctly places on the + TOP edge). `[]`, with a `problems` note naming it, when `psh` is + unavailable or does not (yet) define `parse_edge_lifters` -- an older + `pa_sketch_handling` mid-refactor degrades this one grouping, never the + whole table.""" + if psh is None: + return [] + classify = getattr(psh, 'parse_edge_lifters', None) + if not callable(classify): + problems.append( + 'vibe.pa_sketch_handling has no parse_edge_lifters() yet; the ' + 'Side Edge / Top Edge split cannot be built for the table') + return [] + rows, edge_problems = classify(values, length_ft, width_ft) + problems.extend(edge_problems) + return [(r['x_ft'], _edge_type_label(r['kind'], r['side']), r['frac']) + for r in rows] + + +_MAIN_TABLE_TYPES = set(['Face', 'Side Edge']) +_TOP_EDGE_TABLE_TYPES = set(['Top Edge', 'Bottom Edge']) +_LIFTER_TABLE_HEADERS = ['Type', 'Qty', 'Longitudinal Position', + 'Transverse Position(s)'] + + +def _build_lifter_table(rows, width_ft): + """One 'table' block off `[(x_ft, type_label, qty, fracs), ...]`, or + `None` for an empty list -- the shared row-formatting tail for both + tables `_lifter_tables` returns.""" + if not rows: + return None + rows = sorted(rows, key=lambda r: (r[0], r[1])) + rows_out = [] + for x_ft, type_label, qty, fracs in rows: + x_text = _feet_str(x_ft) + rows_out.append([type_label, str(qty), + x_text if x_text is not None + else '{0:.2f} ft'.format(x_ft), + _fmt_transverse(fracs, width_ft)]) + return {'kind': 'table', 'headers': list(_LIFTER_TABLE_HEADERS), + 'rows': rows_out} + + +def _lifter_tables(paf, psh, pa_lifters_mod, values, problems): + """(main_table, top_edge_table) -- two 'table' blocks (Type, Qty, + Longitudinal Position, Transverse Position(s)), either `None` when + there is nothing parseable of that kind to show. + + 2026-09-04 THIRD ruling: these used to be ONE combined table holding + every lifter type, printed under the "Face / Side Edge Lifters" + heading -- which put a 'Top Edge' row under a heading that says 'Side + Edge Lifters', and left the separate "Top Edge Lifters" heading below + it with nothing but a quiet sentence (routed out of inline view) under + it, reading as an empty section on a live render. `main_table` now + holds ONLY Face + Side Edge rows (Eriksson's own "Face / Side Edge + Lifters" table shape); `top_edge_table` holds Top Edge + Bottom Edge + rows, printed by `_lifter_section_blocks` under the separate "Top Edge + Lifters" heading instead. + + `face_lifters` parses with `pa_lifters.parse_lifter_lines`, the SAME + rule the prefill side writes its lines in (owner, 2026-09-04: "parse + with the same rule pa_lifters.lifter_lines writes"), so the table can + never show a different reading of the text than the field itself + holds. Edge lifters are classified by + `pa_sketch_handling.parse_edge_lifters()` (see + `_classified_edge_rows_for_table`) into 'Side Edge' / 'Top Edge' / + 'Bottom Edge' groups -- reading `top_edge_lifters` / `side_edge_lifters` + when either carries text, else classifying `edge_lifters` by geometry, + exactly like the drawing. Both tables are read-only SUMMARIES; editing + stays in the text fields, which `_lifter_section_blocks` still appends + right after the main one -- "the fields must be visible and editable + on the page, not hidden." + """ + if pa_lifters_mod is None: + return None, None + width_ft = _num_or_none(values.get('width_ft')) + length_ft = _num_or_none(values.get('length_ft')) + combined = [] + + # Owner ruling, 2026-09-05: reads the `face_lifter` ROW TABLE (via + # `pa_sketch_handling.face_lifter_rows`, the SAME function the drawing + # reads) when it carries any row, falling back to the legacy + # `face_lifters` free-text field otherwise -- so the table and the + # drawing can never disagree, the same reasoning already applied to + # edge lifters below. + if psh is not None and hasattr(psh, 'face_lifter_rows'): + face_rows, face_problems = psh.face_lifter_rows(values) + if face_problems: + problems.append( + 'Face lifters text could not be parsed as \'x_ft @ frac\' ' + 'lines ({0}); shown in the text field below, not in the ' + 'table.'.format('; '.join(face_problems))) + if face_rows: + row_dicts = [{'x_ft': x, 'frac': f} for x, f in face_rows] + for x_ft, fracs in _group_lifter_rows(row_dicts): + combined.append((x_ft, 'Face', len(fracs), fracs)) + else: + face_text = values.get('face_lifters') + if face_text and str(face_text).strip(): + rows, parse_problems = pa_lifters_mod.parse_lifter_lines(face_text) + if parse_problems: + problems.append( + 'Face lifters text could not be parsed as \'x_ft @ ' + 'frac\' lines ({0}); shown in the text field below, ' + 'not in the table.'.format('; '.join(parse_problems))) + elif rows: + for x_ft, fracs in _group_lifter_rows(rows): + combined.append((x_ft, 'Face', len(fracs), fracs)) + + edge_triples = _classified_edge_rows_for_table(psh, values, length_ft, + width_ft, problems) + edge_groups = {} + edge_order = [] + for x_ft, type_label, frac in edge_triples: + key = (round(x_ft, 3), type_label) + if key not in edge_groups: + edge_groups[key] = [] + edge_order.append(key) + edge_groups[key].append(frac) + for key in edge_order: + x_ft, type_label = key + fracs = sorted(edge_groups[key]) + combined.append((x_ft, type_label, len(fracs), fracs)) + + main_rows = [r for r in combined if r[1] in _MAIN_TABLE_TYPES] + top_rows = [r for r in combined if r[1] in _TOP_EDGE_TABLE_TYPES] + return (_build_lifter_table(main_rows, width_ft), + _build_lifter_table(top_rows, width_ft)) + + +def _dunnage_table_block(psh, values, problems): + """One 'table' block, one row per dunnage position (ft, ascending), or + `None` when there is nothing to show. + + Reads through the SAME `pa_sketch_handling.dunnage_positions()` the + drawing itself calls, so the table and the picture can never disagree. + `None` for a genuinely empty result carries no `problems` entry of its + own (that function is already quiet about "nothing entered" -- see its + docstring); the caller decides what that gap is worth saying, once. + """ + if psh is None or not hasattr(psh, 'dunnage_positions'): + return None + positions, dunnage_problems = psh.dunnage_positions(values) + problems.extend(dunnage_problems) + if not positions: + return None + rows = [] + for p in sorted(positions): + text = _feet_str(p) + rows.append([text if text is not None else '{0:.2f} ft'.format(p)]) + return {'kind': 'table', 'headers': ['Dunnage Position'], 'rows': rows} + + +def _lifter_section_blocks(paf, psh, pa_lifters_mod, values, problems): + """Face / Side Edge Lifters, Top Edge Lifters, Dunnage -- three headed + groups, NONE of which may render as a heading with nothing visible + under it (2026-09-04 THIRD ruling -- a live render showed exactly that + for the last two, because a `quiet` sentence is routed out of inline + view entirely, so a heading whose only child was one read as an empty + section even though a block technically existed). + + "Face / Side Edge Lifters" prints an Eriksson-shaped summary table + (see `_lifter_tables`) above the two editable text fields it is built + from -- Face + Side Edge rows ONLY now; Top Edge / Bottom Edge rows + moved to the section below. "Top Edge Lifters" prints ITS OWN table + when there is anything in it, falling back to the (not-yet-real) + `top_edge_lifters` FIELD, then to a quiet sentence only when there is + truly nothing of either kind -- see the module docstring for why an + absent field is still a `problems` entry. "Dunnage" prints a positions + table off `pa_sketch_handling.dunnage_positions()` -- the SAME function + the drawing itself calls, so the two can never disagree -- falling back + the same way: field, then ONE quiet sentence, never a bare heading. + """ + blocks = [{'kind': 'heading', 'text': 'Face / Side Edge Lifters', + 'level': 2}] + main_table, top_edge_table = _lifter_tables(paf, psh, pa_lifters_mod, + values, problems) + if main_table is not None: + blocks.append(main_table) + else: + blocks.append(_quiet( + 'No face or side edge lifter positions entered yet.')) + + # Owner ruling, 2026-09-05: the `face_lifters` / `edge_lifters` free- + # text boxes ("3.50 @ 0.18", one lifter per line) are replaced by two + # EDITABLE ROW TABLES -- `face_lifter` (x_ft along the length, y_ft + # across the width) and `edge_lifter` (edge: Top/Bottom/Left/Right, + # position_ft along that edge) -- the same mechanism `pa_structural.py` + # uses for `restraint` / `wind_zone`. `pa_sketch_handling. + # face_lifter_rows` / `.parse_edge_lifters` already prefer these tables + # over the legacy text fields (see that module's own comment); the old + # `FIELDS` entries stay declared, read only as a fallback for a panel + # saved before these tables existed, never rendered on the page itself. + # `empty_state` (issue #22, items 5/11): a table with no rows says WHY + # -- the model read's own `lifters_basis` sentence when it wrote one, + # else this text -- so a blank table is never mistaken for "no lifters". + blocks.append({'kind': 'editable_table', 'table': 'face_lifter', + 'columns': [ + {'col': 'x_ft', 'label': 'Position along length (ft)', + 'editor': 'num'}, + {'col': 'y_ft', 'label': 'Position across width (ft)', + 'editor': 'num'}, + ], 'add_label': 'Add face lifter', + 'empty_state': { + 'basis_keys': ['lifters'], + 'text': 'No face lifters read from the model and ' + 'none entered yet -- the read left no ' + 'reason, so this is could-not-judge, not ' + '"no face lifters".'}}) + blocks.append({'kind': 'editable_table', 'table': 'edge_lifter', + 'columns': [ + {'col': 'edge', 'label': 'Edge', 'editor': 'choice', + 'choices': ['Top', 'Bottom', 'Left', 'Right']}, + {'col': 'position_ft', 'label': 'Position along ' + 'that edge (ft)', 'editor': 'num'}, + ], 'add_label': 'Add edge lifter', + 'empty_state': { + 'basis_keys': ['lifters'], + 'text': 'No edge lifters read from the model and ' + 'none entered yet -- the read left no ' + 'reason, so this is could-not-judge, not ' + '"no edge lifters".'}}) + blocks.append(_quiet( + 'Top/Bottom are the panel\'s two long edges (position measured ' + 'along the length); Left/Right are its two ends (position ' + 'measured across the width). The summary table above groups rows ' + 'that share a position into one line.')) + + blocks.append({'kind': 'heading', 'text': 'Top Edge Lifters', 'level': 2}) + if top_edge_table is not None: + blocks.append(top_edge_table) + elif _field_exists(paf, 'top_edge_lifters'): + # Not expected today, but if a future restructure adds the field, + # render it rather than silently keeping the old sentence. + blocks.append(_field_block(paf, 'top_edge_lifters')) + else: + problems.append('vibe.panelanalysisform has no top edge lifters ' + 'field; this section always reads as empty') + blocks.append(_quiet('Design contains no top edge lifters.')) + + # Owner ruling, 2026-09-05: a bare "Dunnage" heading with nothing + # visibly under it (the old fallback was a `_quiet` sentence, which the + # renderer routes OUT of inline view entirely into the collapsed Notes + # panel -- the exact same bug the THIRD ruling above already fixed for + # Top Edge Lifters) is worse than either hiding the heading or saying + # why in plain sight. This module cannot tell "the plant has no dunnage + # concept configured at all" apart from "this panel's own handling plan + # just does not use dunnage" -- `page()` is never handed `know`, only + # `values`/`reads` -- so rather than guess at that distinction, the + # heading always shows and the explanation is always VISIBLE (not + # `_quiet`) whenever there is no table: a reader sees a real sentence + # under the heading either way, never an empty-looking section. + blocks.append({'kind': 'heading', 'text': 'Dunnage', 'level': 2}) + dunnage_table = _dunnage_table_block(psh, values, problems) + if dunnage_table is not None: + blocks.append(dunnage_table) + elif _field_exists(paf, 'dunnage'): + blocks.append(_field_block(paf, 'dunnage')) + else: + problems.append('vibe.panelanalysisform has no dunnage field; ' + 'this section always reads as empty') + blocks.append({'kind': 'sentence', + 'text': 'No dunnage positions entered, and none ' + 'were read off the model or the plant\'s own ' + 'handling configuration -- this panel is ' + 'shown with no dunnage support in the ' + 'drawing above.'}) + return blocks + + +def _parse_lifters_basis(text): + """[(qty, kind, family_names)] off one `lifters_basis` sentence, e.g. + "8 face lifters (FAMILY_A) and 4 edge lifters (FAMILY_B) read off the + assembly." -- `pa_lifters.read_lifters()`'s own `why` sentence, the + only source this module has for a lifter's FAMILY name (the position + fields carry no such thing). `kind` is 'Face' or 'Edge', matching + `read_lifters()`'s own two-group vocabulary (it does not itself split + edge into side/top -- that split only exists in the position table + above, off different data). `family_names` is `[]` when the sentence + named none (no parenthetical) -- reported as 'not named' by the + caller, never guessed at. `[]` for a blank/unrecognisable sentence; + never raises.""" + if not text: + return [] + out = [] + for m in re.finditer( + r'(\d+)\s+(face|edge)\s+lifters?(?:\s*\(([^)]*)\))?', + str(text), re.IGNORECASE): + qty = int(m.group(1)) + kind = m.group(2).capitalize() + fams_raw = m.group(3) or '' + families = [f.strip() for f in fams_raw.split(',') if f.strip()] + out.append((qty, kind, families)) + return out + + +def _import_knowledge(): + """The real `vibe.knowledge` (the five-layer merged capacity lookup), or + (None, reason) -- same "never raise" discipline as `_import_paf`.""" + try: + from vibe import knowledge + except ImportError as exc: + return None, ('vibe.knowledge could not be imported ({0}); the ' + 'Lifting Hardware table cannot show Tension/Shear ' + 'capacities'.format(exc)) + return knowledge, None + + +def _capacity_cell(know_mod, know, family, kind, fc_psi, assembly_families): + """One Tension/Shear table cell: `{'text', 'title'}` -- a plain + read-only cell with the rating's own basis sentence as its hover title + (owner ruling, 2026-09-05), or `'-'` (a bare dash, no title) when + nothing is named, nothing is known, or `vibe.knowledge` could not be + reached at all. `vibe.knowledge.lifter_capacity` already tries + override -> plant chart -> back-cast -> catalogue, in that order, and + explains itself either way (`why`) -- this cell only formats what it + returns, never re-derives a number. + """ + if know_mod is None or know is None or not family: + return '-' + try: + value, basis, why = know_mod.lifter_capacity( + know, family, fc_psi=fc_psi, kind=kind, + assembly_families=assembly_families) + except Exception as exc: + return {'text': '-', 'title': 'lifter_capacity() raised: {0}' + .format(exc)} + if value is None: + return {'text': '-', 'title': why or basis or 'not rated'} + return {'text': '{0:,.0f} lb'.format(value), 'title': why or basis or ''} + + +def _lifting_hardware_table_block(values, reads, know, problems): + """One 'table' block, "Family / Kind / Qty / Tension / Shear" -- each + lifter family named in `lifters_basis` (see `_parse_lifters_basis`), + read off `values` first, `reads` as a fallback, the same order every + other prefilled field on this page uses. `None` when there is no basis + sentence to parse, or nothing in it. + + Tension/Shear (owner ruling, 2026-09-05) come from `vibe.knowledge. + lifter_capacity` -- override, then the plant's own tested chart, then a + back-cast, then the published catalogue, per that function's own + precedence -- rated at `fci_psi` (release strength; Handling is a + stripping/transport/erection stage, never the in-place condition) when + it is a number, else unadjusted. A row naming more than one family (a + basis sentence with no per-family split) rates only the FIRST -- there + is no way to tell which family a shared qty belongs to from the + sentence alone, so rating an arbitrary one and labelling it as if it + covered the row would overstate what is actually known; the cell's + hover title says so. `know` missing (no plant/job knowledge layer + reached this page) or `vibe.knowledge` unimportable both print a bare + `-`, never a fabricated number. + """ + text = values.get('lifters_basis') + if not text and reads: + text = reads.get('lifters_basis') + entries = _parse_lifters_basis(text) if text else [] + if not entries: + problems.append( + 'No lifter family basis (lifters_basis) entered or read off ' + 'the model yet; the Lifting Hardware table cannot be built.') + return None + + know_mod = None + if know is not None: + know_mod, know_reason = _import_knowledge() + if know_mod is None: + problems.append(know_reason) + + all_families = [] + for _qty, _kind, families in entries: + all_families.extend(families) + fc_psi = _num(values.get('fci_psi')) + if fc_psi is None: + fc_psi = _num(values.get('fc_psi')) + + rows = [] + for qty, kind, families in entries: + family_text = ', '.join(families) if families else 'not named' + rate_family = families[0] if families else None + title_extra = (' (rating the first of {0} families named on this ' + 'row)'.format(len(families)) + if len(families) > 1 else '') + tension = _capacity_cell(know_mod, know, rate_family, 'tension', + fc_psi, all_families) + shear = _capacity_cell(know_mod, know, rate_family, 'shear', + fc_psi, all_families) + if title_extra and isinstance(tension, dict): + tension['title'] = (tension.get('title') or '') + title_extra + if title_extra and isinstance(shear, dict): + shear['title'] = (shear.get('title') or '') + title_extra + rows.append([family_text, '{0} lifter'.format(kind), str(qty), + tension, shear]) + return {'kind': 'table', + 'headers': ['Family', 'Kind', 'Qty', 'Tension', 'Shear'], + 'rows': rows} + + +def _lifting_hardware_blocks(values, reads, know, problems): + """Eriksson page 26: the lifter family/quantity/capacity table plus a + note on what could not be carried over (see the module docstring's + last bullet). + + 2026-09-04: the OLD tiny "PANEL WEIGHT DISTRIBUTION" `weight_diagram` + drawing is gone -- it drew nothing this page's own data actually + supports (a per-stage weight split `panelanalysisform` does not model) + -- replaced by a real "Lifting hardware" table built from + `lifters_basis`. 2026-09-05: that table grew Tension/Shear capacity + columns (`_capacity_cell`); the lifting-configuration sketch remains + the only unbuilt gap. + """ + blocks = [{'kind': 'heading', 'text': 'Lifting Hardware', 'level': 2}] + table = _lifting_hardware_table_block(values, reads, know, problems) + if table is not None: + blocks.append(table) + else: + blocks.append(_quiet( + 'No lifter family/quantity basis available yet; the Lifting ' + 'Hardware table has nothing to show.')) + problems.append( + 'Eriksson also prints a small lifting-configuration sketch per ' + 'included stage (a G/CG marker over each stage\'s lift points); ' + 'there is no data source here for it yet, so it is not shown.') + return blocks + + +def _lifter_dunnage_drawing_block(values): + """The LIFTER AND DUNNAGE DETAIL `drawing` block: `module` + + `source` name `pa_sketch_handling.lifter_dunnage` (2026-09-04 ruling + #3 -- the old block named `panelsketch.plan`, which letterboxes to + whichever axis is tighter instead of always filling the width). `aspect` + is `width_ft / length_ft` clamped to `[0.15, 0.5]`, computed here off + whatever the user has typed so far so the container is sized close to + right before the drawing itself ever runs; `0.3` (the middle of that + range) stands in only when there is not yet enough to compute a real + one -- a layout guess, not an engineering value. + """ + length_ft = _num(values.get('length_ft')) + width_ft = _num(values.get('width_ft')) + if length_ft and length_ft > 0 and width_ft and width_ft > 0: + aspect = _clamp(width_ft / length_ft, 0.15, 0.5) + else: + aspect = 0.3 + return { + 'kind': 'drawing', 'module': 'pa_sketch_handling', + 'source': 'lifter_dunnage', 'full_width': True, 'aspect': aspect, + 'caption': 'Lifter and dunnage detail', + # Every `values` key `pa_sketch_handling.lifter_dunnage_geometry` + # reads, so an edit to any of them redraws this picture. The + # `face_lifter` / `edge_lifter` / `opening` entries are TABLE + # names: the page matches a row key (`face_lifter.3.x_ft`) on the + # `.` prefix, the same way `pa_structural`'s free body lists + # `restraint` / `support`. Until 2026-09-07 (issue #22, item 7) + # this listed only the legacy free-text keys, so editing a lifter + # ROW never redrew the picture -- the owner saw a read-wrong + # lifter he could not correct ("如果你读错了,用户不就是改不了了嘛"). + 'depends_on': ['face_lifter', 'edge_lifter', 'opening', + 'face_lifters', 'edge_lifters', 'openings_rows', + 'length_ft', 'width_ft', 'thickness_in', 'dunnage'], + } + + +def page(values, reads=None, know=None): + """(blocks, problems) for the Handling section of the Panel Analysis form. + + `values` is the form's current string-valued dict, read-only here -- this + function never assigns into it, so the caller's dict comes back + unchanged no matter what this renders. `reads` is the optional dict of + what the model could read off the framing element (the same shape + `panelanalysisform.prefill` takes); it is used only as a fallback for the + weight row when `values` itself is blank, and its absence never raises. + `know` is the optional merged knowledge tree (`vibe.knowledge.load()`'s + own shape) -- used only by the Lifting Hardware table's Tension/Shear + columns (`_lifting_hardware_table_block`); `None` prints a bare `-` in + both, never a fabricated rating. `vibe.pa_pagespec._load_page` passes + this through only when it detects `page` declares the parameter, so + this stays backward compatible with any caller still passing two + arguments. + + Returns a flat list of block dicts and a list of plain-English problem + sentences -- never raises, per the module docstring's "could not judge" + discipline. An empty `problems` list means every accessor this section + depends on was present and shaped as expected; a non-empty one names + every gap, so the caller can decide whether to surface it to the user + (a stale accessor is worth knowing about even though the page itself + still renders around it). + """ + problems = [] + paf, reason = _import_paf() + if paf is None: + problems.append(reason) + return ([{'kind': 'heading', 'text': 'Handling', 'level': 1}, + _quiet('The Handling section could not be built -- see ' + 'the problem above.')], + problems) + + psh, psh_reason = _import_psh() + if psh is None: + problems.append(psh_reason) + pa_lifters_mod, pal_reason = _import_pa_lifters() + if pa_lifters_mod is None: + problems.append(pal_reason) + + values = values or {} + blocks = [{'kind': 'heading', 'text': 'Handling', 'level': 1}] + + # --- Stage Options matrix ------------------------------------------- + blocks.append({'kind': 'heading', 'text': 'Stage Options', 'level': 2}) + matrix_block = _stage_matrix_block(paf, problems) + if matrix_block is not None: + blocks.append(matrix_block) + else: + blocks.append(_quiet('The per-stage handling options could not ' + 'be built -- see the problem above.')) + blocks.append({'kind': 'space', 'h': 8}) + + # --- Panel Weight / Centroid ------------------------------------------ + blocks.append({'kind': 'heading', 'text': 'Panel Weight & Centroid', + 'level': 3}) + blocks.append(_weight_centroid_block(paf, psh, values, reads, problems)) + blocks.append({'kind': 'rule'}) + + # --- Load factors (weight override / self-weight factor) -------------- + load_factor_blocks = [] + for key in _LOAD_FACTOR_KEYS: + if _field_exists(paf, key): + load_factor_blocks.append(_field_block(paf, key)) + else: + problems.append('vibe.panelanalysisform has no {0} field' + .format(key)) + if load_factor_blocks: + blocks.append({'kind': 'heading', 'text': 'Load Factors', + 'level': 3}) + blocks.extend(load_factor_blocks) + blocks.append({'kind': 'space', 'h': 8}) + + # --- plan drawing, legend directly under it (2026-09-04 ruling) ------ + blocks.append(_lifter_dunnage_drawing_block(values)) + blocks.append(_legend_block()) + blocks.append({'kind': 'space', 'h': 8}) + + # --- lifter / dunnage sections ----------------------------------- + blocks.extend(_lifter_section_blocks(paf, psh, pa_lifters_mod, values, + problems)) + blocks.append({'kind': 'rule'}) + + # --- Lifting Hardware (page 26) ------------------------------------ + blocks.extend(_lifting_hardware_blocks(values, reads, know, problems)) + + return blocks, problems diff --git a/WillTools.extension/lib/vibe/pa_lifters.py b/WillTools.extension/lib/vibe/pa_lifters.py new file mode 100644 index 0000000..2047f6e --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_lifters.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis: pre-fill the Handling page's Face / Edge lifter fields off +what Panel Lift already measured on the assembly. + +`liftexport.export()` already reads every lifter on the piece into +`job['panel']['lifters']` -- family, mark, `x` (ft along the panel LENGTH, +from the end that ends up lower), `y` (ft across the WIDTH), `on_edge`, and +more. The Handling page's `face_lifters` / `edge_lifters` text fields +(`panelanalysisform.FIELDS`) were never pre-filled from that even though the +data was sitting right there -- this module is the missing hop. + +FACE VS EDGE is the plant's own grouping, read out of `knowledge` the same +way the rest of the tool reads it: + + handling.lifter_groups.face.families -- `knowledge._is_face_lifter` + handling.lifter_groups.edge_top.families -- substring match, same way + +A lifter is FACE when `knowledge._is_face_lifter` says its family is one. +Otherwise it is EDGE when either the model already measured it `on_edge`, or +its family matches `edge_top.families`. A family that matches neither list +and was not measured on an edge is a family the plant has not grouped -- +`liftexport._group_of` calls the same case out as `ungrouped` -- and this +module treats it the same way: it is reported in `why`, and written to +neither field, rather than guessed at. + +Nothing here touches Revit or the live document -- it is pure formatting +over data `liftexport.export()` already produced, exactly like +`panelanalysisform.parse_strand_rows` is pure over strand text. +""" + +from vibe import knowledge + + +def _frac(y, width_ft): + """y / width_ft, or None when either is missing or width is zero.""" + if y is None or not width_ft: + return None + try: + return float(y) / float(width_ft) + except (TypeError, ValueError, ZeroDivisionError): + return None + + +def _is_edge_top_family(know, family_name): + """Same substring match as `knowledge._is_face_lifter`, against + `handling.lifter_groups.edge_top.families` instead of `.face.families`. + """ + up = (family_name or '').upper() + families = knowledge.get( + know, 'handling.lifter_groups.edge_top.families', []) or [] + for fam in families: + token = knowledge.as_text(fam).strip().upper() + if token and token in up: + return True + return False + + +def _classify(know, lifter): + """'face' / 'edge' / None for one lifter dict, per the module docstring.""" + family = lifter.get('family') + if knowledge._is_face_lifter(know, family): + return 'face' + if lifter.get('on_edge') or _is_edge_top_family(know, family): + return 'edge' + return None + + +def _fmt_rows(rows): + """rows: list of (x_ft, frac) -> one '{x:.2f} @ {frac:.2f}' per line, + sorted by x then frac. Empty input -> ''.""" + ordered = sorted(rows, key=lambda r: (r[0], r[1])) + return '\n'.join('{0:.2f} @ {1:.2f}'.format(x, frac) + for x, frac in ordered) + + +def lifter_lines(lifters, width_ft, know): + """(face_text, edge_text, why) off a list of Panel Lift lifter dicts. + + One line per lifter -- '{x:.2f} @ {frac:.2f}' where frac = y / width_ft, + lines sorted by (x, frac). Several lifters sharing an x is a row (a face + pair, an edge group) and is expected -- it produces one line per lifter, + not one per row. + + A lifter whose x or y did not measure (either is None), or whose frac + could not be computed (no width), is skipped -- not placed on a guess -- + and counted in `why` rather than silently dropped. A lifter whose family + matches neither the face nor the edge_top group is also left off both + texts and counted in `why`, by family name, the same way + `liftexport.export`'s own `ungrouped` problem names one. + + `why` is one sentence naming how many face / edge lifters came from which + families, plus what was skipped. + """ + lifters = lifters or [] + face_rows = [] + edge_rows = [] + face_families = set() + edge_families = set() + unclassified = set() + skipped = 0 + + for lifter in lifters: + x = lifter.get('x') + y = lifter.get('y') + frac = _frac(y, width_ft) + if x is None or frac is None: + skipped += 1 + continue + group = _classify(know, lifter) + family = lifter.get('family') + if group == 'face': + face_rows.append((x, frac)) + face_families.add(family) + elif group == 'edge': + edge_rows.append((x, frac)) + edge_families.add(family) + else: + unclassified.add(family) + + face_text = _fmt_rows(face_rows) + edge_text = _fmt_rows(edge_rows) + + def _plural(n): + return '' if n == 1 else 's' + + sentence = ( + '{0} face lifter{1}{2} and {3} edge lifter{4}{5} read off the ' + 'assembly'.format( + len(face_rows), _plural(len(face_rows)), + ' ({0})'.format(', '.join(sorted(f for f in face_families if f))) + if face_families else '', + len(edge_rows), _plural(len(edge_rows)), + ' ({0})'.format(', '.join(sorted(f for f in edge_families if f))) + if edge_families else '')) + extras = [] + if unclassified: + extras.append( + '{0} lifter{1} matched neither the face nor the edge_top group ' + '({2})'.format(len(unclassified), _plural(len(unclassified)), + ', '.join(sorted(f for f in unclassified if f)))) + if skipped: + extras.append( + '{0} lifter{1} skipped (no measured position)'.format( + skipped, _plural(skipped))) + why = sentence + if extras: + why += '; ' + '; '.join(extras) + why += '.' + return face_text, edge_text, why + + +# How close to an END (x near 0 or near length_ft) still counts as 'top' / +# 'bottom' rather than a mid-length edge lifter -- half a foot, the same +# order of magnitude `liftexport.rows_of`'s own row-grouping tolerance uses +# for "close enough to be the same station". +_END_TOL_FT = 0.5 + +# How close to a SIDE (frac near 0 or 1) still counts as 'left' / 'right'. +_SIDE_TOL_FRAC = 0.05 + + +def _edge_direction(x, frac, length_ft): + """'top' / 'bottom' / 'left' / 'right' for one EDGE lifter, or None when + it sits at neither an end nor a side closely enough to call. + + END wins over SIDE when both could apply (a true corner lifter): the + piece's own two ends are the more specific fact, and this module's + whole reason for existing is a wall whose edge lifters sat at the END + (x = length) and were being reported as generic 'edge' with no end/side + distinction at all. + """ + if length_ft: + try: + if x is not None and abs(x - 0.0) <= _END_TOL_FT: + return 'bottom' + if x is not None and abs(x - length_ft) <= _END_TOL_FT: + return 'top' + except TypeError: + pass + if frac is not None: + if abs(frac - 0.0) <= _SIDE_TOL_FRAC: + return 'left' + if abs(frac - 1.0) <= _SIDE_TOL_FRAC: + return 'right' + return None + + +def edge_lifter_lines(lifters, width_ft, length_ft, know): + """(top_text, side_text, why) -- the same EDGE lifters `lifter_lines` + already finds, split again by which edge they are really on. + + Same 'x @ frac' grammar and (x, frac) sort as `lifter_lines`; `top_text` + carries the 'top' + 'bottom' lifters, `side_text` the 'left' + 'right' + ones. An edge lifter whose x/frac reads but calls neither direction + (`_edge_direction` returns None -- it sits at neither an end nor a side + within tolerance) is left off BOTH texts and counted in `why`, never + placed on a guess; it still appears in `lifter_lines`' own `edge_text`. + """ + lifters = lifters or [] + top_rows = [] + side_rows = [] + top_dirs = set() + side_dirs = set() + uncalled = 0 + + for lifter in lifters: + x = lifter.get('x') + y = lifter.get('y') + frac = _frac(y, width_ft) + if x is None or frac is None: + continue + if _classify(know, lifter) != 'edge': + continue + direction = _edge_direction(x, frac, length_ft) + if direction in ('top', 'bottom'): + top_rows.append((x, frac)) + top_dirs.add(direction) + elif direction in ('left', 'right'): + side_rows.append((x, frac)) + side_dirs.add(direction) + else: + uncalled += 1 + + top_text = _fmt_rows(top_rows) + side_text = _fmt_rows(side_rows) + + def _plural(n): + return '' if n == 1 else 's' + + why = ( + '{0} end (top/bottom) edge lifter{1}{2} and {3} side (left/right) ' + 'edge lifter{4}{5}, split from the edge lifters above by x within ' + '{6:g} ft of a panel end or frac within {7:g} of 0/1'.format( + len(top_rows), _plural(len(top_rows)), + ' ({0})'.format(', '.join(sorted(top_dirs))) if top_dirs else '', + len(side_rows), _plural(len(side_rows)), + ' ({0})'.format(', '.join(sorted(side_dirs))) if side_dirs + else '', + _END_TOL_FT, _SIDE_TOL_FRAC)) + if uncalled: + why += ('; {0} edge lifter{1} matched neither an end nor a side ' + 'closely enough to call'.format(uncalled, _plural(uncalled))) + why += '.' + return top_text, side_text, why + + +def read_lifters(job, know): + """{'face_lifters', 'edge_lifters', 'top_edge_lifters', + 'side_edge_lifters', 'lifters_basis', 'edge_direction_basis'} for the + Handling page, straight off `job['panel']['lifters']` -- Panel Lift + already measured every lifter on the assembly; this only reformats it + for the text fields. + + `top_edge_lifters` / `side_edge_lifters` are the SAME edge lifters + `edge_lifters` already carries, split again by which edge they are + really on (see `edge_lifter_lines`) -- kept ALONGSIDE `edge_lifters`, + never replacing it, for whatever still reads the older undivided field. + + Every one of the four text keys is omitted when its own text is empty; + `lifters_basis` / `edge_direction_basis` are always present alongside + them so a blank field still says why. `{}` when `job` is None or carries + no lifters at all, and defensively `{}` on any exception -- a pre-fill + that guesses wrong is worse than a blank field the owner fills by hand. + """ + try: + if not job: + return {} + panel = job.get('panel') or {} + lifters = panel.get('lifters') + if not lifters: + return {} + width_ft = panel.get('width_ft') + length_ft = panel.get('length_ft') + face_text, edge_text, why = lifter_lines(lifters, width_ft, know) + out = {'lifters_basis': why} + if face_text: + out['face_lifters'] = face_text + if edge_text: + out['edge_lifters'] = edge_text + top_text, side_text, edge_why = edge_lifter_lines( + lifters, width_ft, length_ft, know) + out['edge_direction_basis'] = edge_why + if top_text: + out['top_edge_lifters'] = top_text + if side_text: + out['side_edge_lifters'] = side_text + return out + except Exception: + return {} + + +def read_dunnage(job): + """{'dunnage_positions_ft', 'dunnage', 'dunnage_basis'} off Panel Lift's + own job, or {}. + + The support LINE positions `liftexport.dunnage_supports` already worked + out for whichever handling stage sits the piece down on dunnage instead + of hanging it from its lifters (`stage['supports']`, written only for + the stage(s) whose `pci_case` is a sit-down case -- today + 'transportation' -- and only when the plant has configured + `handling.dunnage`; see that function's own docstring). Nothing here + re-derives a support position: it is read straight off the job Panel + Lift already built, the first stage that carries one. + + `dunnage_positions_ft` is the raw list, feet from the panel's own x = 0, + ascending. `dunnage` is the SAME list formatted as the one-number-per- + line text `pa_sketch_handling._dunnage_positions` (and the Handling + page's own `dunnage` field) already parses -- so a plant that has + configured dunnage gets the field pre-filled instead of blank, and one + that has not gets neither key at all (not a guessed pair of quarter + points). + + `{}` when `job` is None, no stage carries a `supports` list, or on any + exception. + """ + try: + if not job: + return {} + stages = job.get('stages') or [] + xs = None + stage_name = None + for stage in stages: + supports = stage.get('supports') + if supports: + xs = sorted(round(float(x), 4) for x in supports) + stage_name = stage.get('name') or stage.get('pci_case') \ + or 'stage' + break + if not xs: + return {} + return { + 'dunnage_positions_ft': xs, + 'dunnage': '\n'.join('{0:.2f}'.format(x) for x in xs), + 'dunnage_basis': ( + '{0} dunnage support position(s), feet from the panel\'s ' + 'own x = 0, read off Panel Lift\'s own job (handling.' + 'dunnage, stage {1!r}) -- not re-measured here.'.format( + len(xs), stage_name)), + } + except Exception: + return {} + + +def parse_lifter_lines(text): + """(rows, problems) -- one lifter per LINE, 'x_ft @ frac'. + + All-or-nothing by line, the same discipline as + `panelanalysisform.parse_strand_rows`: any line that fails to parse + refuses the WHOLE field (`rows` comes back `[]`) rather than keeping the + lines that did parse -- a lifter schedule half-read is not a lifter + schedule. Blank text -> ([], []). + """ + rows = [] + problems = [] + if text is None: + text = '' + for i, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if not line: + continue + parts = [p.strip() for p in line.split('@')] + if len(parts) != 2: + problems.append( + "line {0}: expected 'x_ft @ frac', got {1!r}".format( + i, line)) + continue + x_txt, frac_txt = parts + try: + x_ft = float(x_txt) + except (TypeError, ValueError): + problems.append( + 'line {0}: x_ft is not a number ({1!r})'.format(i, x_txt)) + continue + try: + frac = float(frac_txt) + except (TypeError, ValueError): + problems.append( + 'line {0}: frac is not a number ({1!r})'.format(i, frac_txt)) + continue + rows.append({'x_ft': x_ft, 'frac': frac}) + if problems: + return [], problems + return rows, [] diff --git a/WillTools.extension/lib/vibe/pa_pagespec.py b/WillTools.extension/lib/vibe/pa_pagespec.py new file mode 100644 index 0000000..35872ca --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_pagespec.py @@ -0,0 +1,747 @@ +# -*- coding: utf-8 -*- +u"""Panel Analysis's WPF input form (前端), collapsed into ONE JSON document. + +WHY THIS FILE EXISTS: the WPF renderer in `PanelAnalysis.pushbutton/script.py` +is being replaced by an HTML page rendered in WebView2 by another agent. That +agent does not write Python and does not read the five section modules +(`pa_criteria` / `pa_extents` / `pa_structural` / `pa_handling` / +`pa_reinforcement`) or the field model (`panelanalysisform`) directly -- this +module is the seam: it calls every `page(values, reads)` the way `script.py` +does, walks the block trees the way `script.py`'s `_render_block` dispatch +does, and turns the result into one `dict` that survives `json.dumps` with no +further Python involved. See `docs/pa-web-spec.md` for the exact JSON shape, +one example per block kind, the value/row-key contract, and the host<->page +message protocol built on top of this. + +WHAT THIS MODULE IS NOT: it does not decide what a field means, what a +default is, or how a drawing is laid out -- all of that stays owned by +`panelanalysisform`, `pa_defaults`, and the four `pa_sketch_*` modules +respectively. `build_pagespec()` only serialises what those modules already +compute, the same "one seam, no new policy" discipline `script.py`'s own +renderer follows for the WPF side. + +NO REVIT, EITHER PYTHON. Like every module it imports, this loads and runs +on plain CPython (`tests/conftest.py`'s trick, and `tools/pa_pagespec.py`'s +standalone CLI) and on IronPython 2.7 inside the button. No f-strings, no +`pathlib`, no `dataclasses`, no typing syntax; string formatting is +`'{0}'.format(...)`; every import is wrapped so a sibling module mid-edit (a +SyntaxError, not just an ImportError) degrades that one page to a named +`problems` entry rather than taking the whole spec down -- the same +discipline `script.py`'s `_load_section_module` already applies on the WPF +side, borrowed here rather than re-derived. + +NEVER RAISES. `build_pagespec()` is meant to run with NO Revit and +sometimes with NOTHING typed in yet (the owner's scope note: the page must +open and run before Revit ever supplies a value) -- every lookup below is +defensive, and anything this module could not resolve becomes a `problems` +sentence, never a traceback. + +THE TWO CALLING SHAPES: + + build_pagespec(values, know) -- `values` already built (the + button's own `F.prefill()` + result, live from Revit). + build_pagespec(know=know, reads=reads) -- `values` is `None`; this + module builds it itself via + `panelanalysisform.prefill( + reads or {}, know)`, which + already applies every + `PREFILL_DEFAULTS` / + `DERIVED_FIELDS` / row-table + seed / `pa_defaults. + apply_defaults()` rule on an + EMPTY `reads` exactly as it + would on a real one -- so a + page opened with no Revit at + all still gets a complete, + defaulted form, not a blank + one. + +Both shapes end up with a real `values` dict before anything else runs, so +every other rule below (row-table padding to `min_rows`, geometry +degrading to a placeholder rectangle, `choices` upgraded for a known +enumeration) applies identically whether Revit was ever in the loop. +""" + +import json + + +# --------------------------------------------------------------- the pages +# +# (page id, display title, sibling module name) -- mirrors `script.py`'s own +# `SECTION_MODULES`, in the same order, so a reviewer can tell this is the +# same five sections, not a sixth invention. +PAGE_MODULES = ( + ('design_criteria', 'Design Criteria', 'pa_criteria'), + ('concrete_extents', 'Concrete Extents', 'pa_extents'), + ('structural_model', 'Structural Model', 'pa_structural'), + ('handling', 'Handling', 'pa_handling'), + ('reinforcement', 'Reinforcement', 'pa_reinforcement'), +) + +# (drawing 'module', drawing 'source') -> the geometry function that module +# now exposes (see each `pa_sketch_*` module's own "real-unit geometry (ft)" +# section, added alongside this file). `pa_sketch_extents`'s 'outline' and +# 'section' sources share ONE geometry function: `section()`'s own picture is +# just the SAME outline+openings data cut at the current station, and +# `pa_sketch_extents.section_at()` is the general algorithm a page runs +# against that one geometry blob for any station -- there is no second, +# separate "section geometry" to compute up front. Extending this dict is +# the whole of what a sixth drawing needs on this side. +_GEOMETRY_FUNCS = { + ('pa_sketch_extents', 'outline'): 'outline_geometry_ft', + ('pa_sketch_extents', 'section'): 'outline_geometry_ft', + ('pa_sketch_freebody', 'freebody'): 'freebody_geometry', + ('pa_sketch_handling', 'lifter_dunnage'): 'lifter_dunnage_geometry', + ('pa_sketch_reinf', 'section'): 'section_geometry', + ('pa_sketch_reinf', 'outline'): 'outline_geometry', +} + +_UNIT_RE = None # set lazily -- see `_split_unit`, kept off module import + # so a `re` compile failure (never seen, but this module's + # own rule is "no lookup may raise") cannot break import. + + +# ------------------------------------------------------------- basic safety + +def _import_module(modname): + """(module, None) or (None, exception) -- never raises. + + Mirrors `script.py`'s `_load_section_module`: a sibling module mid-edit + (a SyntaxError, not just an ImportError while it does not exist yet) is + "not available", never a crash that takes every OTHER page down with it. + """ + try: + mod = __import__('vibe.' + modname, fromlist=[str(modname)]) + return mod, None + except Exception as exc: + return None, exc + + +def _text_type(): + try: + return (str, unicode) # Python 2 + except NameError: + return (str,) # Python 3 + + +_TEXT_TYPES = _text_type() + + +def _json_safe(obj): + """`obj`, recursively coerced into something `json.dumps` accepts and a + JS reader gets back exactly what it expects: no tuples (-> lists), no + sets (-> sorted lists), no `NaN`/`Infinity` floats (-> `None`, the + honest "could not compute" answer this codebase uses everywhere else), + no non-string dict keys (-> `str(key)`). Never raises -- an object this + cannot recognise is returned as-is and left to `json.dumps` itself, + which raises a much clearer error than anything guessed here would. + """ + if obj is None or isinstance(obj, bool): + return obj + if isinstance(obj, _TEXT_TYPES): + return obj + if isinstance(obj, float): + try: + if obj != obj or obj in (float('inf'), float('-inf')): + return None + except Exception: + return None + return obj + if isinstance(obj, int): + return obj + if isinstance(obj, dict): + out = {} + for k, v in obj.items(): + if not isinstance(k, _TEXT_TYPES): + k = str(k) + out[k] = _json_safe(v) + return out + if isinstance(obj, (list, tuple, set, frozenset)): + return [_json_safe(v) for v in obj] + return obj + + +def _split_unit(label): + """('Elevation', 'ft') from 'Elevation (ft)'; (label, None) when there + is no trailing parenthetical. A display convenience only -- the label + text itself is never changed anywhere else in this module, this only + powers the `tables[*]['columns'][*]['unit']` field `docs/pa-web-spec.md` + documents. Never raises on a non-string label.""" + if not label or not isinstance(label, _TEXT_TYPES): + return label, None + text = label.strip() + if text.endswith(')') and '(' in text: + i = text.rfind('(') + unit = text[i + 1:-1].strip() + base = text[:i].strip() + if unit and base: + return base, unit + return label, None + + +# --------------------------------------------------------- field vocabulary + +def _choices_for(key): + """`panelanalysisform.choices_for(key)`, or `None` on any failure -- + same could-not-judge discipline every lookup in this module follows.""" + try: + from vibe import panelanalysisform as F + return F.choices_for(key) + except Exception: + return None + + +def _field_spec(key): + try: + from vibe import panelanalysisform as F + return F._spec(key) + except Exception: + return None + + +_MULTI_VALUE_KINDS = ('supports', 'openings') + +# Phrases a field's or matrix row's own `hint` uses to say the solve does +# not read it (the section modules already write one of these on every +# such control). A block whose hint carries one is stamped +# `unused_by_solve: true` so the page can mute it visibly -- the owner's +# question, 2026-09-07 (issue #22, item 2): "我现在选择 code,有意义吗?" A +# control that looks live and changes nothing is the thing this exists to +# stop; the hint text alone reaches only the collapsed Notes panel. +_UNUSED_HINT_PHRASES = ('not yet used by the solve', 'not used by the solve') + +# Keys the solve does not read whose hint does not say so in those words. +# `design_spec`: no formula in `panellift` or `tools/panelanalysis.py` +# branches on the ACI edition (grep, 2026-09-07). The two miter flags +# only print a footnote sentence (`pa_extents._miter_sentence`); +# `top_in_form` (which face is up) is reference only. `section_type` is +# NOT here although its hint says "reference only": it drives the +# Sandwich-only fields' visibility and the wythe drawing, so muting it +# would misdescribe a control the page itself reacts to. +_UNUSED_BY_SOLVE_KEYS = frozenset(('design_spec', 'left_miter', + 'right_miter', 'top_in_form')) + + +def _hint_says_unused(hint): + try: + text = (hint or '').lower() + except Exception: + return False + return any(phrase in text for phrase in _UNUSED_HINT_PHRASES) + + +def _sandwich_only_keys(): + try: + from vibe import panelanalysisform as F + return tuple(getattr(F, 'SANDWICH_ONLY_KEYS', ()) or ()) + except Exception: + return () + + +def _normalize_field(block, ctx): + """Mutates one `field` block dict in place: upgrades a plain text/num + editor to `choice` when `panelanalysisform` already knows a fixed list + for that key (mirrors `script.py`'s `_render_field`, so a page built + from this JSON offers the exact same dropdowns the WPF form does), + tags `multi_value` for a `supports`/`openings`-kind field (the field + model's OWN 'kind', which can disagree with the block's `editor` -- + see `script.py`'s comment on `F._spec(key)['kind']` winning), and files + the key's choices (if any) into the shared `choices` dict.""" + key = block.get('key') + editor = block.get('editor') + choices = block.get('choices') + if editor in (None, 'text', 'num') and key: + registry_choices = _choices_for(key) + if registry_choices: + editor = 'choice' + if not choices: + choices = list(registry_choices) + block['editor'] = editor + block['choices'] = list(choices) if choices else None + + resolved = editor + if resolved not in _MULTI_VALUE_KINDS: + spec = _field_spec(key) if key else None + canonical = (spec or {}).get('kind') + if canonical in _MULTI_VALUE_KINDS: + resolved = canonical + block['multi_value'] = resolved in _MULTI_VALUE_KINDS + + # Dependent visibility (issue #22, item 3): a Sandwich-only material / + # wythe field (`panelanalysisform.SANDWICH_ONLY_KEYS`, the field + # model's own declaration, which the retired WPF renderer honoured and + # the HTML page never did) is shown only while `section_type` is + # Sandwich. A block that already carries its own `visible_when` (the + # section module's ruling) keeps it. + if key and not block.get('visible_when') and key in _sandwich_only_keys(): + block['visible_when'] = {'key': 'section_type', 'in': ['Sandwich']} + + # "Not used by the solve" cue (issue #22, item 2) -- see + # `_UNUSED_HINT_PHRASES`. Never overrides a module's explicit False. + if 'unused_by_solve' not in block: + if (key in _UNUSED_BY_SOLVE_KEYS) or _hint_says_unused( + block.get('hint')): + block['unused_by_solve'] = True + + if key: + ctx['field_keys'].add(key) + if block['choices']: + ctx['choices'][key] = list(block['choices']) + + +def _register_cell_choices(cell, ctx): + """A `table` / `lc_table` / `matrix` cell dict -- files `choices[key]` + when the cell carries both, same as a `field` block does. Cells are + plain strings far more often than dicts; a non-dict cell is a no-op.""" + if isinstance(cell, dict) and cell.get('key') and cell.get('choices'): + ctx['choices'][cell['key']] = list(cell['choices']) + + +def _register_table(block, ctx): + """Files one `editable_table` block's shape into the shared `tables` + dict (`docs/pa-web-spec.md`'s `{name: {columns, min_rows, add_label}}`) + and each column's own `choices`, if any, under `'
.'` -- + a column's choices apply to every row of that column, not to one row's + key (which does not exist yet until a row is added), so this is the one + place `choices` is keyed by something other than a `values` key.""" + name = block.get('table') + if not name: + return + colspecs = block.get('columns') or [] + columns = [] + for c in colspecs: + col = c.get('col') + label, unit = _split_unit(c.get('label')) + entry = {'key': col, 'label': c.get('label') or col, + 'unit': unit, 'editor': c.get('editor')} + if c.get('choices'): + entry['choices'] = list(c['choices']) + ctx['choices']['{0}.{1}'.format(name, col)] = list(c['choices']) + columns.append(entry) + entry = { + 'columns': columns, 'min_rows': block.get('min_rows') or 0, + 'add_label': block.get('add_label') or 'Add row', + } + # `empty_state` (issue #22, items 5/11): what the page prints under a + # table with no rows -- `{'basis_keys': [...], 'text': ...}`, the first + # `basis[]` sentence found wins, else `text`. Mirrored here so a + # page can find it without walking every block, same as `columns`. + if isinstance(block.get('empty_state'), dict): + entry['empty_state'] = dict(block['empty_state']) + ctx['tables'][name] = entry + + +# ------------------------------------------------------------- geometry + +def _drawing_geometry(block, ctx): + """Computes and files `ctx['geometry'][drawing_key]` for one `drawing` + block, and returns `drawing_key` so the caller can stamp it onto the + block. Never raises: a missing mapping, an unimportable module, a + missing function, or the geometry function itself raising all degrade + to a `{'problems': [...]}` stand-in -- a page must always find SOME dict + at `geometry[drawing_key]`, never a missing key.""" + modname = block.get('module') + source = block.get('source') + drawing_key = '{0}.{1}'.format(ctx['page_id'], source or 'drawing') + if drawing_key in ctx['geometry']: + return drawing_key + + fn_name = _GEOMETRY_FUNCS.get((modname, source)) + if not fn_name: + ctx['geometry'][drawing_key] = {'problems': [ + 'no geometry function is registered for {0}.{1} yet; this ' + 'drawing cannot be built from data alone.'.format( + modname, source)]} + return drawing_key + + mod, err = _import_module(modname) + if mod is None: + ctx['geometry'][drawing_key] = {'problems': [ + "vibe.{0} could not be loaded ({1}).".format(modname, err)]} + return drawing_key + + fn = getattr(mod, fn_name, None) + if fn is None: + ctx['geometry'][drawing_key] = {'problems': [ + "'{0}' is not available in vibe.{1}.".format(fn_name, modname)]} + return drawing_key + + try: + geo = fn(ctx['values']) + except Exception as exc: + geo = {'problems': ['{0} raised while computing geometry: {1!r}' + .format(fn_name, exc)]} + if not isinstance(geo, dict): + geo = {'problems': ['{0} returned something that was not a dict; ' + 'no geometry available.'.format(fn_name)]} + ctx['geometry'][drawing_key] = _json_safe(geo) + return drawing_key + + +# --------------------------------------------------------------- the walk + +def _walk(block, ctx): + """One block (or a list of blocks) -> the same shape, JSON-safe, with + every nested container recursed into and every field/table/drawing + registered into `ctx`. Mirrors `script.py`'s `_render_block` dispatch + one kind at a time, but SERIALISES instead of rendering -- nothing here + ever builds a WPF object, and nothing here ever raises: an exception + from a nested call is caught per-block, the same 'a bad block never + takes the page down' rule `script.py` follows, and becomes a `sentence` + block naming the failure in its place. + """ + if isinstance(block, list): + return [_walk(b, ctx) for b in block] + if not isinstance(block, dict): + return block + + try: + out = dict(block) + kind = out.get('kind') + if kind == 'columns': + out['columns'] = [[_walk(b, ctx) for b in (col or [])] + for col in (out.get('columns') or [])] + elif kind == 'section': + out['blocks'] = [_walk(b, ctx) for b in (out.get('blocks') or [])] + elif kind == 'field': + _normalize_field(out, ctx) + elif kind in ('table', 'lc_table'): + rows = [] + for row in (out.get('rows') or []): + new_row = [] + for cell in row: + _register_cell_choices(cell, ctx) + new_row.append(cell) + rows.append(new_row) + out['rows'] = rows + elif kind == 'matrix': + rows = [] + for row in (out.get('rows') or []): + row = dict(row) if isinstance(row, dict) else {'cells': []} + if 'unused_by_solve' not in row and _hint_says_unused( + row.get('hint')): + row['unused_by_solve'] = True + cells = [] + for cell in (row.get('cells') or []): + _register_cell_choices(cell, ctx) + cells.append(cell) + row['cells'] = cells + rows.append(row) + out['rows'] = rows + elif kind == 'editable_table': + _register_table(out, ctx) + elif kind == 'drawing': + out['drawing_key'] = _drawing_geometry(out, ctx) + return _json_safe(out) + except Exception as exc: + return {'kind': 'sentence', 'quiet': False, + 'text': '[block failed to serialise: {0!r}]'.format(exc)} + + +def _page_accepts_know(fn): + """Whether `fn` (a page module's `page`) declares a `know` parameter -- + checked by INTROSPECTION, not by calling it and catching `TypeError`, + because a `TypeError` raised from INSIDE a working `page()` call would + otherwise be silently reinterpreted as 'this module's signature is + older' and retried, masking a real bug as a compatibility fallback. + Both Pythons: `inspect.signature` (3.3+) first, `inspect.getargspec` + (still present on IronPython 2.7's CPython-2-shaped `inspect`, though + removed from CPython 3.11+) as the fallback. Any introspection failure + -- neither exists, or `fn` is not introspectable at all -- answers + `False`, the safe "call it the old way" direction. + """ + import inspect + try: + return 'know' in inspect.signature(fn).parameters + except (AttributeError, ValueError, TypeError): + pass + try: + spec = inspect.getargspec(fn) # noqa: E501 (py2/IronPython path) + return 'know' in spec.args + except Exception: + return False + + +def _load_page(page_id, title, modname, values, reads, know=None): + mod, err = _import_module(modname) + if mod is None: + return {'id': page_id, 'title': title, 'blocks': [], + 'problems': ["vibe.{0} could not be loaded ({1}).".format( + modname, err)]} + try: + # `know` (the merged knowledge tree) is a NEWER, optional third + # argument -- today only `pa_handling.page` reads it (the Lifting + # Hardware table's Tension/Shear columns, `vibe.knowledge. + # lifter_capacity`). Every other page module still only accepts + # `(values, reads)`; `_page_accepts_know` decides which call shape + # to use so an older module is never handed an argument it does + # not declare. + if _page_accepts_know(mod.page): + blocks, page_problems = mod.page(values, reads, know=know) + else: + blocks, page_problems = mod.page(values, reads) + except Exception as exc: + return {'id': page_id, 'title': title, 'blocks': [], + 'problems': ['page() raised: {0!r}'.format(exc)]} + return {'id': page_id, 'title': title, 'blocks': blocks or [], + 'problems': list(page_problems or [])} + + +# ------------------------------------------------------------- provenance + +def _basis_dict(values): + """`values['_basis']` (keys like `'mark_basis'`, one sentence each, + written by `panelanalysisform.prefill()`) -> `{key: sentence}` with the + `_basis` suffix stripped, so the page can look a field's provenance up + by the SAME key it already uses for that field's value. Never raises on + a malformed `_basis`.""" + raw = values.get('_basis') if isinstance(values, dict) else None + out = {} + if not isinstance(raw, dict): + return out + for key, sentence in raw.items(): + if not isinstance(key, _TEXT_TYPES): + continue + clean = key[:-len('_basis')] if key.endswith('_basis') else key + out[clean] = sentence + return out + + +def _derived_dict(): + """`panelanalysisform.DERIVED_FIELDS` as `{key: {'sources': [...], + 'formula': }}` -- the page recomputes each derived field + client-side as a source is edited (issue #22, item 1), mirroring the + named formula (`concrete_modulus_psi`: Ec = 33 * wc^1.5 * sqrt(f'c), + rounded to whole psi, exactly as `prefill` does) until the user types + into the derived field itself. Empty on any failure: the page then + simply never recomputes, which is what it did before.""" + out = {} + try: + from vibe import panelanalysisform as F + for key, spec in (getattr(F, 'DERIVED_FIELDS', None) or {}).items(): + sources, fn = spec + out[key] = {'sources': list(sources), + 'formula': getattr(fn, '__name__', str(fn))} + except Exception: + return {} + return out + + +def _problem_targets(): + """`panelanalysisform.LEGACY_TABLE_KEYS` -- legacy field key -> the row + table that replaced it, so a `validate()` problem still keyed by the + legacy name (`supports_ft`) lands on the table the user actually sees + (issue #22, item 11). Empty on any failure.""" + try: + from vibe import panelanalysisform as F + return dict(getattr(F, 'LEGACY_TABLE_KEYS', None) or {}) + except Exception: + return {} + + +def _defaults_why_dict(values, field_keys): + """`{key: why}` from two sources, neither dropped: + + 1. `values['_defaults_applied']` -- the `[(key, value_text, why), ...]` + list `panelanalysisform.prefill()` records for defaults it actually + applied THIS run (a real value read off the model always wins, so a + key here means the default is what is actually showing). + 2. `pa_defaults.why_of(key)`, for every field key this spec renders that + (1) did not already cover -- the RULE ("plant knowledge X when set, + else Y"), not necessarily the run's own choice, but still the honest + answer to "why would this ever be filled in" for a field the model + supplied a real value for and so never went through `apply_defaults` + at all. + + Never raises: a malformed `_defaults_applied` entry is skipped, and a + `pa_defaults` that fails to import leaves (2) empty rather than losing + (1). + """ + out = {} + applied = values.get('_defaults_applied') if isinstance(values, dict) \ + else None + for entry in (applied or []): + try: + key, _value_text, why = entry + except (TypeError, ValueError): + continue + if isinstance(key, _TEXT_TYPES): + out[key] = why + try: + from vibe import pa_defaults + for key in field_keys: + if key in out: + continue + why = pa_defaults.why_of(key) + if why: + out[key] = why + except Exception: + pass + return out + + +def _pad_table_rows(values, tables): + """Adds blank rows, in place on `values`, until every table in `tables` + has at least its own `min_rows` present rows -- the CLI / no-Revit path + the owner asked for: a page opened with nothing typed yet still shows + each table's minimum shape, not zero rows. A `min_rows` of 0 (every + table today) makes this a no-op; it exists so a future page module that + DOES set `min_rows` gets the row padding for free, on Revit or off it, + the same way `script.py`'s `editable_table_widget` already pads on the + WPF side. Never raises -- a `panelanalysisform` that will not import + leaves every table exactly as `prefill()` (or the caller) built it. + """ + try: + from vibe import panelanalysisform as F + except Exception: + return + for name, meta in tables.items(): + min_rows = meta.get('min_rows') or 0 + if min_rows <= 0: + continue + cols = [c['key'] for c in meta.get('columns') or [] if c.get('key')] + if not cols: + continue + try: + ids = F.row_ids(name, values) + while len(ids) < min_rows: + F.add_row(name, values, cols, {}) + ids = F.row_ids(name, values) + except Exception: + continue + + +# ------------------------------------------------------------------ public + +def build_pagespec(values=None, know=None, reads=None): + u"""The whole Panel Analysis form as one JSON-serialisable `dict`. + + `values` is `panelanalysisform.prefill()`'s own string-valued dict (plus + its structured passthrough keys, e.g. `openings_rows`) -- pass it when + the caller already built one (the live button always will). Pass `None` + to have this module build it itself, from `reads` (a live-model read + dict, or `{}`/`None` for "no Revit at all") and `know` (the merged + knowledge tree, or `None`/`{}` for "no plant layer") -- see the module + docstring's "THE TWO CALLING SHAPES". Either way the result is a COPY; + the caller's own `values` dict (if it passed one) is never mutated. + + Returns: + + {'schema': 1, 'title': 'Panel Analysis', 'mark': str, + 'pages': [{'id', 'title', 'blocks': [...], 'problems': [...]}, ...], + 'values': {key: str, ...}, # every table row key included + 'basis': {key: str, ...}, + 'defaults_why': {key: str, ...}, + 'choices': {key: [str, ...], ...}, + 'tables': {name: {'columns': [...], 'min_rows', 'add_label'}, ...}, + 'geometry': {'.': {...}, ...}, + 'problems': [str, ...]} + + See `docs/pa-web-spec.md` for the full schema, one example per block + kind, and the value/row-key contract. Never raises: every stage below is + wrapped so a caller gets a usable (if degraded, and honestly labelled) + spec rather than an exception, on Revit or off it. + """ + top_problems = [] + know = know if isinstance(know, dict) else {} + reads = reads if isinstance(reads, dict) else {} + + if values is None: + try: + from vibe import panelanalysisform as F + values = F.prefill(reads, know=know) + except Exception as exc: + top_problems.append( + 'panelanalysisform.prefill() raised while building values ' + 'from reads/know: {0!r}; the spec below has no fields ' + 'pre-filled.'.format(exc)) + values = {} + values = dict(values) if isinstance(values, dict) else {} + + # Owner ruling, 2026-09-05: `openings_rows` (what the outline/section + # drawings and `panelanalysisform._openings_for_deck` read) is DERIVED + # from the `opening` row table whenever that table carries any present + # row, so an edit made through the page's own Add/Remove-row UI is what + # the drawings redraw from on the NEXT `/api/spec` -- not the frozen + # model-read snapshot `prefill`'s PASSTHROUGH_KEYS copy took at + # spec-build time. A table with no present rows (a fresh blank form, or + # a panel saved before this table existed) leaves `values['openings_ + # rows']` exactly as `prefill` set it -- the same legacy fallback + # `validate()` and `pa_extents._openings_summary_sentence` also use. + try: + from vibe import panelanalysisform as _F_opening + _table_opening_rows = _F_opening._opening_rows_from_table(values) + if _table_opening_rows: + values['openings_rows'] = _table_opening_rows + except Exception: + pass + + ctx = {'tables': {}, 'choices': {}, 'geometry': {}, + 'field_keys': set(), 'values': values, 'page_id': None} + + pages = [] + for page_id, title, modname in PAGE_MODULES: + raw_page = _load_page(page_id, title, modname, values, reads, + know=know) + ctx['page_id'] = page_id + raw_page['blocks'] = [_walk(b, ctx) for b in raw_page['blocks']] + pages.append(raw_page) + + _pad_table_rows(values, ctx['tables']) + + basis = _basis_dict(values) + defaults_why = _defaults_why_dict(values, ctx['field_keys']) + + values_out = {} + for key, v in values.items(): + if key in ('_basis', '_defaults_applied'): + continue + values_out[key] = v + values_out = _json_safe(values_out) + + mark = (reads.get('mark') or values.get('mark') or '') + try: + mark = mark.strip() + except Exception: + mark = str(mark) if mark else '' + + return { + 'schema': 1, + 'title': 'Panel Analysis', + 'mark': mark, + 'pages': pages, + 'values': values_out, + 'basis': _json_safe(basis), + 'defaults_why': _json_safe(defaults_why), + 'choices': _json_safe(ctx['choices']), + 'tables': _json_safe(ctx['tables']), + 'geometry': _json_safe(ctx['geometry']), + 'derived': _json_safe(_derived_dict()), + 'problem_targets': _json_safe(_problem_targets()), + 'problems': top_problems, + } + + +def dumps(spec, **kwargs): + """`json.dumps(spec)`, with `sort_keys=True` and `ensure_ascii=False` + unless the caller overrides either -- a thin convenience so + `tools/pa_pagespec.py` and any test do not each pick their own + formatting. + + `ensure_ascii=False`, not json's own `True` default: IronPython 2.7's + `json` module has no C-accelerated ASCII escaper, and its pure-Python + fallback (`py_encode_basestring_ascii`) raises `UnicodeEncodeError` on + an ordinary BMP character (measured live, 2026-09-05, on one of this + form's own superscript/prime symbols, e.g. U+207B in a field's + `symbol`) instead of escaping it -- so the ASCII-safe path is the one + that actually crashes inside Revit. `ensure_ascii=False` sidesteps that + encoder entirely and writes real UTF-8 (matching every other UTF-8 file + this codebase writes, coding-cookie included); the caller's `io.open` + with `encoding='utf-8'` is what actually gets the bytes right, the same + as every other JSON file this codebase writes off Revit. + """ + kwargs.setdefault('sort_keys', True) + kwargs.setdefault('indent', 1) + kwargs.setdefault('ensure_ascii', False) + return json.dumps(spec, **kwargs) diff --git a/WillTools.extension/lib/vibe/pa_prefill.py b/WillTools.extension/lib/vibe/pa_prefill.py new file mode 100644 index 0000000..e467328 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_prefill.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis: the pure merge rule for the model-measured pre-fill. + +`script.py`'s `_reads_for` reads three separate things off one panel -- +`pa_supports.read_supports`, `pa_lifters.read_lifters`, `pa_reinf.read_ +reinforcement_prefill` -- each returning its own small dict of form-field +values (or {}, defensively, when it could not measure anything). This module +holds the one rule for folding all three into the button's running `reads` +dict, kept apart from `script.py` because that file imports the Revit API at +module scope and cannot be imported under `pytest` on host CPython (see +`tests/conftest.py`'s docstring) -- so the part that is pure logic, and +therefore worth a fast host test, lives here instead. + +THE RULE, in one sentence: everywhere else in `_reads_for` a value already +read off the piece wins over a model guess (`setdefault` -- fill only what is +still blank); `OVERRIDE_KEYS` are the exception, because for those keys the +NEW read *is* the measurement and anything already sitting in `reads` is a +weaker guess from an earlier, cruder pass -- `mild_as` is the clearest case: +`_assembly_reinforcement`'s old marks-list (a bare comma list of piece marks) +is worth less than `pa_reinf.rebar_prefill`'s `(bar size, type name)` summary +with a real total area, so the new value has to win even though `reads` +already carries something under that key. + +`None` never overwrites anything, for any key, override or not -- a module +that could not measure something says so by leaving the key out (or handing +back `None`), never by asserting a blank. +""" + +# The new measurement wins over whatever is already in `reads`, blank or not. +# Every other key follows plain `setdefault` -- only a still-empty slot gets +# filled. See the module docstring for why `mild_as` is here: the new +# `(bar size, type name)` summary from `pa_reinf.rebar_prefill` outranks the +# old marks-list `_assembly_reinforcement` may already have written under the +# same key. +OVERRIDE_KEYS = ('supports_ft', 'face_lifters', 'edge_lifters', + 'ps_rows', 'mild_as') + + +def merge_model_reads(reads, parts): + """Fold `parts` into `reads` IN PLACE, per `OVERRIDE_KEYS`, and return it. + + `parts` is either one dict or a list/tuple of dicts (`pa_supports. + read_supports`'s, `pa_lifters.read_lifters`'s, `pa_reinf.read_ + reinforcement_prefill`'s, in whatever order the caller has them handy -- + order only matters between two dicts that both carry the same key, which + none of the three do today). A `None` value in any part is skipped + outright; it never counts as "the model answered" and never overwrites + an existing value OR fills a blank. + + Returns `reads` (the same object, mutated) so a caller can chain it, but + the mutation is the real effect -- this is not written to be used as a + pure expression. + """ + if isinstance(parts, dict): + parts = [parts] + for part in (parts or []): + if not part: + continue + for key, value in part.items(): + if value is None: + continue + if key in OVERRIDE_KEYS or reads.get(key) is None: + reads[key] = value + return reads diff --git a/WillTools.extension/lib/vibe/pa_reinf.py b/WillTools.extension/lib/vibe/pa_reinf.py new file mode 100644 index 0000000..d17e1fc --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_reinf.py @@ -0,0 +1,761 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis Reinforcement page -- the real strand/rebar pre-fill. + +WHY THIS EXISTS: the owner's complaint, verbatim, 2026-09-03: "reinforcement +也没正确识别 rebar 跟 strands" (the Reinforcement page pre-fills almost +nothing). Two separate reads, kept in two functions because they come from +two different sources and fail independently: + + * `strand_prefill` turns Panel Lift's own strand read (`liftexport. + read_prestress`'s `panel.prestress` shape -- diameter, loss, and a list + of `{area_in2, initial_pull_lb, y, z_in}` per strand) into the form's + `ps_*` fields, in the exact grammar `panelanalysisform.parse_strand_rows` + reads back (`'count @ depth_in [@ pull_pct]'` per line). + + * `rebar_prefill` classifies an assembly's members the same way + `_assembly_reinforcement` in the PanelAnalysis button script does + (`K.is_strand`, the WWF prefix, `K.reinforcing_verdict`), but for the + REBAR half it stops reporting member MARKS (useless -- a comma list of + piece marks does not say how much steel is in the panel) and instead + reports a real `(bar size, type name)` summary with a total area. + +`read_reinforcement_prefill` is the merged entry point the button would call +-- see the bottom of this docstring for the wiring `_reads_for` still needs. + +WHERE z_in and depth_in AGREE, so no conversion runs here +----------------------------------------------------------- +`ps_rows`' `depth_in` is documented as "how deep from the bottom" +(`panelanalysisform.py`, the `ps_rows` `_F(...)` entry, and again in +`parse_strand_rows`'s docstring: "e.g. '4 @ 2 @ 75' is 4 strands, 2 in. from +the bottom"). `liftexport.read_prestress` measures its own `z_in` the same +way -- `centre[T] - lo[T]`, with the comment directly above it: "Up from the +form face. The form face is the low end of the thickness axis: a panel is +cast face down on the bed." (`liftexport.py` lines 1601-1604). Both are +"distance up from the low end of the thickness axis" -- the same axis, the +same zero, the same direction -- and `panelanalysisform._prestress_from` +proves it operationally: when it turns a typed `ps_rows` line back into a +strand dict for the solve, it writes `'z_in': row['depth_in']` directly, with +no thickness arithmetic in between (`panelanalysisform.py` line 1849). So +`strand_prefill` below does the same thing in reverse -- `depth_in = +z_in` -- and `thickness_in` is accepted (for parity with the caller, which +already has it on hand) but never used to flip or offset anything. If a +future family of parts ever measures `z_in` from the OTHER face, that is the +place to add `thickness_in - z_in`; nothing observed on this job needs it. + +No Revit import at module scope. `rebar_prefill` and `read_reinforcement_ +prefill` reach for `vibe.knowledge` / `vibe.ticketqc` only inside the +function that needs them, exactly like `_assembly_reinforcement` in the +button script -- so this module imports cleanly on host CPython (`tests/ +conftest.py`'s trick) even though `ticketqc` itself cannot. +""" + +import re + + +# Same default the module docstring above cites: no plant maps +# `panelanalysis.bar_size_pattern`, so the last two digits of the TYPE name +# are read as the bar size -- 'S04' -> #4, 'MC_17-04' -> #4, 'MC_T2-05' -> #5. +DEFAULT_BAR_SIZE_PATTERN = r'(\d{2})$' + +# Standard ASTM bar areas, in^2. A size this table does not know contributes +# no area to `mild_as_in2` -- same discipline as `panelanalysisform. +# strand_area_in2`: an unknown size is never guessed at, only named. +_BAR_AREA_IN2 = {3: 0.11, 4: 0.20, 5: 0.31, 6: 0.44, 7: 0.60, 8: 0.79} + +_DEFAULT_FPU_KSI = 270.0 + + +def _mode(values): + """The most common value, ties broken by first appearance. None if empty. + + Plain dict-counting rather than `collections.Counter` -- this file has to + import on IronPython 2.7 inside the button as well as host CPython, and + Counter's ordering guarantees are not worth relying on for the tie-break. + """ + values = [v for v in values if v is not None] + if not values: + return None + counts = {} + order = [] + for v in values: + if v not in counts: + counts[v] = 0 + order.append(v) + counts[v] += 1 + best = order[0] + best_n = counts[best] + for v in order[1:]: + if counts[v] > best_n: + best = v + best_n = counts[v] + return best + + +def _fmt_dia(dia): + try: + return '{0:g}'.format(float(dia)) + except (TypeError, ValueError): + return None + + +def strand_prefill(prestress, thickness_in, fpu_ksi=None): + """The `ps_*` fields off Panel Lift's own strand read, or {}. + + `prestress` is `panel.prestress` from `liftexport.export()` / the return + of `liftexport.read_prestress`: `{'diameter_in', 'loss_pct', 'strands': + [{'area_in2', 'initial_pull_lb', 'y', 'z_in', ...}, ...]}`. None, {}, or a + prestress dict with no strands all mean "nothing to prefill" -> {}, the + same "no prestress" the form itself treats as valid (all `ps_*` blank). + + `thickness_in` is accepted for parity with the caller (see the module + docstring for why it is not used to convert `z_in`) and may be None. + + `ps_pull_lb` is the MODAL `initial_pull_lb` across all strands -- the + pull most of them share -- so it stands in as the form's one default pull + while `ps_rows` calls out only the groups that differ from it. + + `ps_rows` is one line per distinct depth, DEPTHS ROUNDED TO THE NEAREST + 1/8 in. before grouping (real strand beds are not measured to the + thousandth, and without rounding, floating-point noise in the geometry + read would split one physical row of strands into several). A group's + line carries ' @ {pull_pct:.0f}' only when that group's own pull differs + from the modal pull -- matching, so that the common case (every strand + pulled the same) reads as plain 'count @ depth' lines with no percentage + noise, exactly the shape a hand-typed strand schedule uses. + + `strand_rows` is the STRUCTURED alternative to `ps_rows` -- one dict per + depth group, `{'count', 'depth_in', 'dia_in', 'pull_lb'}` -- for + `panelanalysisform.prefill` to seed the `strand_row` table directly, so a + real per-row diameter/pull rides in from the start instead of a text + line that gets re-parsed. Same depth grouping (rounded to 1/8 in.) as + `ps_rows`; `dia_in` / `pull_lb` are the SAME value on every row -- Panel + Lift's own `read_prestress` stamps one diameter and one `it_lb` pull onto + every strand it finds (it refuses outright when the design summary lists + more than one pulled group), so there is only ever one design value of + each to put on a row, never a per-row measurement. + + `strand_x_in` is the flat per-strand horizontal position list + `pa_sketch_reinf._explicit_strand_x` reads (one entry per strand, in + `strand_rows` order -- depth-group order, then the order `read_prestress` + already sorted its strands in, ascending `y`). Each value is that + strand's `y` (from `read_prestress`'s own `to_y`, feet from the WIDTH + axis' own low end in the piece's as-drawn/erected frame -- the identical + convention `job['panel']['lifters']`' own `x`/`y` and this piece's `y` + already use) converted to inches. That is NOT necessarily "from the rail + `strand_rail_location` names" -- which rail the dimension chain is really + drawn from is separate plant knowledge this function has no access to + (it takes no `know`) -- so the position is reported honestly as + model-frame-relative, and only emitted at all when every strand in + `strand_rows` produced one (a partial list would silently misalign + against the rows it is supposed to match). + """ + if not prestress: + return {} + strands = prestress.get('strands') or [] + if not strands: + return {} + + out = {'ps_count': len(strands)} + + dia_text = _fmt_dia(prestress.get('diameter_in')) + if dia_text is not None: + out['ps_dia_in'] = dia_text + + loss = prestress.get('loss_pct') + if loss is not None: + out['ps_loss_pct'] = loss + + fpu = fpu_ksi + if fpu is None: + fpu = prestress.get('fpu_ksi') + if fpu is None: + fpu = _DEFAULT_FPU_KSI + + pulls = [s.get('initial_pull_lb') for s in strands] + modal_pull = _mode(pulls) + if modal_pull is not None: + out['ps_pull_lb'] = modal_pull + + groups = {} + for s in strands: + z = s.get('z_in') + if z is None: + continue + depth = round(z * 8.0) / 8.0 + groups.setdefault(depth, []).append(s) + + lines = [] + rows = [] + xs = [] + for depth in sorted(groups.keys()): + members = groups[depth] + line = '{0} @ {1:g}'.format(len(members), depth) + group_pull = _mode([m.get('initial_pull_lb') for m in members]) + if (group_pull is not None and modal_pull is not None + and abs(group_pull - modal_pull) > 1e-6): + area = _mode([m.get('area_in2') for m in members]) + if area: + pull_pct = group_pull / (area * fpu * 1000.0) * 100.0 + line += ' @ {0:.0f}'.format(pull_pct) + lines.append(line) + rows.append({'count': len(members), 'depth_in': depth, + 'dia_in': dia_text, 'pull_lb': modal_pull}) + for m in sorted(members, key=lambda s: s.get('y') or 0.0): + y_ft = m.get('y') + if y_ft is not None: + xs.append(round(y_ft * 12.0, 3)) + if lines: + out['ps_rows'] = '\n'.join(lines) + if rows: + out['strand_rows'] = rows + out['strand_rows_basis'] = ( + '{0} strand row(s) grouped by depth (rounded to the nearest ' + '1/8 in., matching ps_rows) off the assembly\'s strand members: ' + 'depth is z_in, up from the FORM FACE -- the low end of the ' + 'thickness axis, since a panel is cast face down on the bed; ' + 'diameter and pull are the design spec\'s one group value ' + '(it_lb), the same value every strand on this piece already ' + 'carries.'.format(len(rows))) + strand_count = sum(r['count'] for r in rows) + if xs and len(xs) == strand_count: + out['strand_x_in'] = xs + out['strand_x_in_basis'] = ( + 'per-strand horizontal position, in inches, off ' + 'read_prestress\'s own to_y (feet from the width axis\' low ' + 'end in the as-drawn/erected frame -- the same convention ' + "job['panel']['lifters'] x/y already use), one entry per " + 'strand in strand_rows order. This is NOT necessarily ' + 'measured from the rail strand_rail_location names -- which ' + 'rail the dimension chain is drawn from on the drawing is ' + 'separate plant knowledge this read has no access to.') + + return out + + +def _type_name_of(box): + """The member's TYPE name, or None. The box itself first, the element + second. + + `ticketqc.member_boxes` does not put a `type` key on the box at all -- + confirmed by reading its source (`ticketqc.py`, `member_boxes`: the dict + it appends carries `id, family, category, mark, lo, hi, element`, no + `type`) -- and a probe run against a real assembly came back with every + box's `type` key present but `None`. So the type name is read off + `box['element']` instead: for a `FamilyInstance`, `.Name` (the base + `Element.Name`, not `.Symbol.Name`) IS the type name, a well-known Revit + API quirk. Wrapped in `try` because `box['element']` may be missing + entirely (a hand-built test box) or the API call may fail for a reason + that has nothing to do with this function. + """ + t = box.get('type') + if t: + return str(t) + element = box.get('element') + if element is not None: + try: + name = element.Name + except Exception: + name = None + if name: + return str(name) + return None + + +def _bar_size_from_type(type_name, pattern): + if not type_name: + return None + try: + compiled = re.compile(pattern) + except Exception: + compiled = re.compile(DEFAULT_BAR_SIZE_PATTERN) + m = compiled.search(type_name) + if not m: + return None + try: + return int(m.group(1)) + except (IndexError, ValueError, TypeError): + return None + + +def rebar_prefill(boxes, know): + """`{'mild_as', 'mild_as_in2', 'mesh', 'reinf_basis'}` off assembly boxes. + + `boxes` is the shape `ticketqc.member_boxes` returns (a list of `{family, + category, mark, lo, hi, element}` dicts, `type` optional). Classification + matches `_assembly_reinforcement` in the PanelAnalysis button script + exactly: `K.is_strand` first (strands are `strand_prefill`'s job, not + this one's), then the `families.wwf_prefix` test for mesh, then + `K.reinforcing_verdict(know, family)[0] is True` for rebar -- `None` + (cannot judge) and `False` are both left OUT of the count, the same + "never invent a verdict" rule `reinforcing_verdict` itself documents. + + Bars are summarised by (bar size, type name), not by member mark: a + 52-bar wall is 52 marks and one useful sentence, not 52 useless ones. + Size comes from the plant's `panelanalysis.bar_size_pattern` (default + `r'(\\d{2})$'`) applied to the TYPE name (see `_type_name_of`). A bar + whose type name does not match the pattern (or has no type name at all) + is still counted and shown -- as `'{n} x ? ({type})'` -- and named again + in `reinf_basis`, rather than silently dropped, because a bar this + function cannot size is still a bar somebody has to notice is missing an + area. + + `mild_as_in2` totals only the bars whose size WAS parsed, using the + standard ASTM areas in `_BAR_AREA_IN2` -- an unparsed bar contributes no + area, matching `panelanalysisform.strand_area_in2`'s refusal to guess. + + Returns {} when the boxes carry neither mesh nor rebar (an all-hardware + assembly, or none supplied). + """ + if not boxes: + return {} + from vibe import knowledge as K + + try: + wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF') + except Exception: + wwf_prefix = 'WWF' + wwf_prefix = wwf_prefix.upper() + + try: + pattern = K.get(know, 'panelanalysis.bar_size_pattern', + DEFAULT_BAR_SIZE_PATTERN) + except Exception: + pattern = DEFAULT_BAR_SIZE_PATTERN + if not pattern: + pattern = DEFAULT_BAR_SIZE_PATTERN + + mesh_names = [] + rebar_groups = {} # (size or None, type_name or None) -> count + # Every family whose verdict came back other than True -- False + # ("confirmed not a bar", the ordinary case for connection hardware) and + # None ("cannot judge", the ambiguous case) are NOT told apart here, on + # purpose: `reinforcing_verdict`'s own "list" mode returns a confident + # False for a family nobody has EXPLICITLY ruled out, same as one that + # really is just hardware (2026-09-07 finding on WPA655 -- the bare + # `S_(SCHEDULED)` family, still an open plant-knowledge question, comes + # back False and reads exactly like `DB_PATCH` would). So this function + # cannot tell "safely excluded" from "quietly dropped a real bar group" + # any better than the caller could -- it just stops hiding the count. + excluded = {} # family -> count + + for b in boxes: + family = b.get('family') or '' + try: + if K.is_strand(know, family): + continue + except Exception: + pass + if family.upper().startswith(wwf_prefix): + if family not in mesh_names: + mesh_names.append(family) + continue + try: + verdict = K.reinforcing_verdict(know, family)[0] + except Exception: + verdict = None + if verdict is not True: + excluded[family] = excluded.get(family, 0) + 1 + continue + type_name = _type_name_of(b) + size = _bar_size_from_type(type_name, pattern) + key = (size, type_name) + rebar_groups[key] = rebar_groups.get(key, 0) + 1 + + if not mesh_names and not rebar_groups: + return {} + + out = {} + if mesh_names: + out['mesh'] = ', '.join(mesh_names) + + if rebar_groups: + def _sort_key(item): + (size, type_name), _count = item + return (size if size is not None else float('inf'), + type_name or '') + + parts = [] + basis = [] + total_area = 0.0 + for (size, type_name), count in sorted(rebar_groups.items(), + key=_sort_key): + label = type_name if type_name else 'unknown type' + if size is None: + parts.append('{0} x ? ({1})'.format(count, label)) + basis.append( + "{0} x {1} -- no bar size parsed from the type name " + "(panelanalysis.bar_size_pattern {2!r})" + .format(count, label, pattern)) + else: + parts.append('{0} x #{1} ({2})'.format(count, size, label)) + area = _BAR_AREA_IN2.get(size) + if area is not None: + total_area += area * count + out['mild_as'] = ', '.join(parts) + out['mild_as_in2'] = '{0:.2f}'.format(total_area) + if excluded: + n_members = sum(excluded.values()) + by_family = ', '.join( + '{0} {1}'.format(excluded[f], f) for f in sorted(excluded)) + basis.append( + '{0} other member(s) across {1} famil{2} ({3}) did not ' + 'verdict as reinforcing under the current knowledge -- ' + 'False here can mean "confirmed hardware" or "an ' + 'unresolved plant-knowledge question" equally, see ' + 'drawings.reinforcing_family_patterns / ' + '_family_overrides for which.' + .format(n_members, len(excluded), + 'y' if len(excluded) == 1 else 'ies', by_family)) + if basis: + out['reinf_basis'] = basis + + return out + + +# Bars closer together than this (rounded) are the same depth group -- the +# same discipline `strand_prefill` uses on strand depth (1/8 in.), loosened +# to 1/4 in. because rebar is not measured to strand tolerance. +_MILD_DEPTH_GROUP_IN = 0.25 + + +def _member_axes(el, asm, know): + """(to_x, to_y, lo, hi, L, W, T, basis) for `el`'s own panel frame, or + None on any failure. + + The SAME (length, width, thickness) axis assignment and up-end flip + `liftexport.export` uses for everything else Panel Lift measures -- + `liftexport._axes` on the panel's own `ticketqc.concrete_extents` (built + off the SAME `ticketqc._panel_frame` the strand/rebar boxes are already + read in), then `liftexport.erected_orientation` for which end reads as + x=0. Reusing both rather than re-deriving them is the point: a mild bar's + `from_ft`/`to_ft` has to land in the identical as-drawn frame + `job['panel']['lifters']`' x/y and `strand_x_in` already use, or the + three would each describe "the same piece" in a different, disagreeing + coordinate system. + + When `asm` is None, or the origin rule cannot say which end is up, the + axes still resolve (thickness/length/width are pure geometry) but x/y + keep the model's own order -- `basis` names that explicitly rather than + silently asserting an orientation nobody established. + """ + try: + from vibe import ticketqc as tq + from vibe import liftexport as LX + except Exception: + return None + try: + frame = tq._panel_frame(el) + extents = tq.concrete_extents(el, frame) + except Exception: + return None + if frame is None or extents is None: + return None + lo = [v * 12.0 for v in extents[0]] + hi = [v * 12.0 for v in extents[1]] + try: + L, W, T = LX._axes(lo, hi) + except Exception: + return None + length_ft = (hi[L] - lo[L]) / 12.0 + width_ft = (hi[W] - lo[W]) / 12.0 + + x_ascends, y_ascends = True, True + basis = ('the model\'s own axis order -- no assembly was given, so the ' + 'erected up-end rule could not run') + if asm is not None: + try: + vertical_axis, up_is_max, shares = LX.erected_orientation( + asm, el, frame, (L, W, T), know, (length_ft, width_ft)) + except Exception: + vertical_axis, up_is_max, shares = None, None, {} + if vertical_axis is not None: + if vertical_axis == 'length': + x_ascends, y_ascends = up_is_max, True + else: + x_ascends, y_ascends = True, up_is_max + basis = ('the erected orientation (basis: {0})' + .format(shares.get('basis') or 'unknown')) + else: + basis = ('the model\'s own axis order -- the erected up-end ' + 'rule could not tell which end of this piece is up') + + def to_x(value_in): + along = (value_in - lo[L]) / 12.0 + return along if x_ascends else length_ft - along + + def to_y(value_in): + across = (value_in - lo[W]) / 12.0 + return across if y_ascends else width_ft - across + + return {'to_x': to_x, 'to_y': to_y, 'lo': lo, 'hi': hi, + 'L': L, 'W': W, 'T': T, 'basis': basis} + + +def _median_gap(values): + values = sorted(values) + gaps = sorted(values[i + 1] - values[i] + for i in range(len(values) - 1)) + if not gaps: + return None + mid = len(gaps) // 2 + if len(gaps) % 2: + return gaps[mid] + return (gaps[mid - 1] + gaps[mid]) / 2.0 + + +def cluster_split(members, spread_key, gap_ratio=5.0, min_gap_in=6.0): + """[[member, ...], ...] -- `members` (each a dict carrying `spread_key`) + split into CLUSTERS along that key wherever consecutive members are far + enough apart to be a different physical feature, not one spaced-out + run. PUBLIC (module level, not nested in `mild_bar_rows_prefill`) so it + is directly host-testable with synthetic data -- the function it serves + needs a live `el`/`asm` (through `_member_axes`) and cannot run at all + on plain CPython, which is exactly why this fix needed a test of its + own reachable a different way. + + 2026-09-05, WPA655 finding: a (size, depth, direction) GROUP can + legitimately hold bars from two unrelated locations (e.g. two separate + openings' own diagonal edge bars) that happen to share a size/depth/ + direction -- grouping them into ONE row reported a `spacing_in` (the + median gap across ALL members) fooled by exactly this shape: two tight + within-location pairs far apart read as "spacing 3in" (the median of + [3in, 3in, ~37ft]) while `from_ft`/`to_ft` correctly spanned the whole + panel -- an internally INCONSISTENT row (a reader sees '4 bars, 3in + apart, 0 to 37 ft' and the arithmetic does not close: 4 bars at 3in + apart span 9in, not 37ft). + + Sorts by `spread_key` and starts a new cluster whenever a gap is more + than `gap_ratio` times the SMALLEST positive gap seen in the whole run + -- a within-cluster gap sits close to that minimum for a genuinely + evenly-spaced pattern; a gap to the next physical feature does not. + `min_gap_in` floors the 'smallest gap' so two bars only an inch or two + apart (an ordinary production tolerance) never reads as its own tiny + cluster unit that then flags every larger, still-legitimate spacing as + a break. Fewer than 3 members is returned as one cluster + unconditionally -- there is no spacing pattern that small to protect + anyway. + """ + if len(members) < 3: + return [members] + ordered = sorted(members, key=lambda m: m[spread_key]) + gaps = [ordered[i + 1][spread_key] - ordered[i][spread_key] + for i in range(len(ordered) - 1)] + positive = [g for g in gaps if g > 1e-6] + if not positive: + return [ordered] + threshold = max(min(positive), min_gap_in) * gap_ratio + clusters = [[ordered[0]]] + for i, gap in enumerate(gaps): + if gap > threshold: + clusters.append([]) + clusters[-1].append(ordered[i + 1]) + return clusters + + +def mild_bar_rows_prefill(el, asm, know, boxes): + """`{'mild_bar_rows', 'mild_bar_rows_basis'}` off assembly rebar boxes, + or {} -- the STRUCTURED alternative to `rebar_prefill`'s `mild_as` + summary text, for `panelanalysisform.prefill` to seed the `mild_bar` + table directly (`[{'size', 'count', 'depth_in', 'direction', 'from_ft', + 'to_ft', 'spacing_in'}, ...]`). + + Classification is identical to `rebar_prefill` (strand/mesh/rebar, + `K.is_strand` -> `families.wwf_prefix` -> `K.reinforcing_verdict`), so + the two functions always agree on WHICH boxes are rebar; this one just + also places them. `boxes` needs `el`/`asm` (through `_member_axes`) for + the length/width/thickness axes and the erected x/y flip -- `rebar_ + prefill` never needed either, so it keeps its own simpler signature. + + One row per (bar size, depth rounded to the nearest 1/4 in., direction) + group. `direction` is 'Longitudinal' when a bar's own box runs longer + along the LENGTH axis than the WIDTH axis, 'Transverse' otherwise -- + ties go to Longitudinal. `depth_in` is the group's boxes' average box + centre on the THICKNESS axis, measured from the FORM FACE -- same + convention `strand_prefill`'s `z_in` uses (the low end of the thickness + axis; a panel is cast face down on the bed). `from_ft`/`to_ft` are + always the group's extreme LENGTH-axis positions (`to_x` at each + member's own two ends, min and max across the group) -- for a + longitudinal group that is each bar's own run; for a transverse group + (each bar short along the length, spread out along it) it is the span + from the first bar's position to the last. `spacing_in` is the median + gap between members along whichever axis they are actually spread over + -- WIDTH for a longitudinal group (parallel bars set apart across the + panel), LENGTH for a transverse group -- and is left `None` for a group + of fewer than 3 members, where "the gap" is not a well-formed spacing. + + A box missing `lo`/`hi` (a hand-built test box, or a geometry solve that + failed upstream) is skipped, not guessed at. Returns {} when there is + nothing to place (`boxes` empty, no box classifies as rebar, or the + panel's own axes could not be solved) or on any exception -- never + raises. + """ + if not boxes: + return {} + try: + from vibe import knowledge as K + except Exception: + return {} + axes = _member_axes(el, asm, know) + if axes is None: + return {} + to_x = axes['to_x'] + lo, L, W, T = axes['lo'], axes['L'], axes['W'], axes['T'] + + try: + wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF') + except Exception: + wwf_prefix = 'WWF' + wwf_prefix = wwf_prefix.upper() + try: + pattern = K.get(know, 'panelanalysis.bar_size_pattern', + DEFAULT_BAR_SIZE_PATTERN) + except Exception: + pattern = DEFAULT_BAR_SIZE_PATTERN + if not pattern: + pattern = DEFAULT_BAR_SIZE_PATTERN + + groups = {} # (size, depth_key, direction) -> [member dict, ...] + # Same tracking, and the same caveat, as `rebar_prefill`'s `excluded`: + # a False verdict here can mean "confirmed hardware" or "an unresolved + # plant-knowledge question" (2026-09-07, WPA655: the bare `S_(SCHEDULED)` + # family -- 42 real mild-bar members, #3/#4/#5 -- verdicted False and + # vanished from this table with no trace, because nothing distinguished + # it from an ordinary connection embed). Never silently drop the count. + excluded = {} # family -> count + for b in boxes: + family = b.get('family') or '' + try: + if K.is_strand(know, family): + continue + except Exception: + pass + if family.upper().startswith(wwf_prefix): + continue + try: + verdict = K.reinforcing_verdict(know, family)[0] + except Exception: + verdict = None + if verdict is not True: + excluded[family] = excluded.get(family, 0) + 1 + continue + blo, bhi = b.get('lo'), b.get('hi') + if blo is None or bhi is None: + continue + type_name = _type_name_of(b) + size = _bar_size_from_type(type_name, pattern) + span_L = bhi[L] - blo[L] + span_W = bhi[W] - blo[W] + direction = 'Longitudinal' if span_L >= span_W else 'Transverse' + depth_in = ((blo[T] + bhi[T]) / 2.0) - lo[T] + depth_key = round(depth_in / _MILD_DEPTH_GROUP_IN) * _MILD_DEPTH_GROUP_IN + x0, x1 = sorted((to_x(blo[L]), to_x(bhi[L]))) + centre_L_in = (blo[L] + bhi[L]) / 2.0 + centre_W_in = (blo[W] + bhi[W]) / 2.0 + groups.setdefault((size, depth_key, direction), []).append({ + 'depth_in': depth_in, 'x0': x0, 'x1': x1, + 'along_length_in': centre_L_in, + 'along_width_in': centre_W_in, + }) + + if not groups: + return {} + + def _sort_key(item): + (size, depth_key, direction), _members = item + return (size if size is not None else float('inf'), depth_key, + direction) + + rows = [] + unsized = 0 + n_split = 0 + for (size, depth_key, direction), members in sorted(groups.items(), + key=_sort_key): + spread_key = ('along_width_in' if direction == 'Longitudinal' + else 'along_length_in') + clusters = cluster_split(members, spread_key) + if len(clusters) > 1: + n_split += 1 + for cluster in clusters: + count = len(cluster) + if size is None: + unsized += count + depth_avg = sum(m['depth_in'] for m in cluster) / float(count) + from_ft = min(m['x0'] for m in cluster) + to_ft = max(m['x1'] for m in cluster) + spacing_in = None + if count >= 3: + spacing_in = _median_gap([m[spread_key] for m in cluster]) + rows.append({ + 'size': ('#{0}'.format(size) if size is not None else None), + 'count': count, 'depth_in': round(depth_avg, 3), + 'direction': direction, 'from_ft': round(from_ft, 3), + 'to_ft': round(to_ft, 3), + 'spacing_in': (round(spacing_in, 3) + if spacing_in is not None else None), + }) + + basis = ( + '{0} rebar group(s) read off the assembly members, grouped by (bar ' + 'size, depth rounded to the nearest 1/4 in., direction), each ' + 'further split into separate rows wherever the members are not one ' + 'evenly-spaced run (see the 2026-09-05 clustering note). Size from ' + 'the type name (panelanalysis.bar_size_pattern {1!r}); depth is the ' + 'box centre on the thickness axis, up from the FORM FACE (the low ' + 'end of the thickness axis -- a panel is cast face down on the ' + 'bed, the same convention strand depth uses); direction is ' + "Longitudinal when a bar's own box runs longer along the panel's " + 'length than its width, Transverse otherwise; from/to are the ' + "row's extreme positions along the length ({2}); spacing is the " + 'median gap across the perpendicular axis for a row of 3 or more ' + 'bars, left blank otherwise.'.format(len(rows), pattern, + axes['basis'])) + if n_split: + basis += (' {0} bar-size/depth/direction group(s) were not one ' + 'evenly-spaced run and were split into separate rows so ' + 'each row\'s spacing matches its own extent.' + .format(n_split)) + if unsized: + basis += (' {0} bar(s) carried with no size parsed from the type ' + 'name.'.format(unsized)) + if excluded: + n_members = sum(excluded.values()) + by_family = ', '.join( + '{0} {1}'.format(excluded[f], f) for f in sorted(excluded)) + basis += ( + ' {0} other member(s) across {1} famil{2} ({3}) did not ' + 'verdict as reinforcing under the current knowledge and are NOT ' + 'in this table -- False there can mean "confirmed hardware" or ' + '"an unresolved plant-knowledge question" equally, see ' + 'drawings.reinforcing_family_patterns / _family_overrides for ' + 'which.'.format(n_members, len(excluded), + 'y' if len(excluded) == 1 else 'ies', by_family)) + + return {'mild_bar_rows': rows, 'mild_bar_rows_basis': basis} + + +def read_reinforcement_prefill(el, asm, know, prestress=None, fpu_ksi=None): + """Both halves merged: strands off `prestress`, rebar off the assembly. + + Defensive by construction, not by a wrapping try/except around the whole + thing: the two halves run in SEPARATE try blocks, so a rebar-side failure + (no assembly, `ticketqc` unavailable -- true on host CPython, where + `vibe.ticketqc` cannot even import) never takes the strand half down with + it, and vice versa. Never raises. + """ + out = {} + + try: + out.update(strand_prefill(prestress, None, fpu_ksi)) + except Exception: + pass + + try: + from vibe import ticketqc as tq + frame = tq._panel_frame(el) + boxes = tq.member_boxes(el.Document, asm, el, frame, know) or [] + out.update(rebar_prefill(boxes, know)) + out.update(mild_bar_rows_prefill(el, asm, know, boxes)) + except Exception: + pass + + return out diff --git a/WillTools.extension/lib/vibe/pa_reinforcement.py b/WillTools.extension/lib/vibe/pa_reinforcement.py new file mode 100644 index 0000000..57db5e2 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_reinforcement.py @@ -0,0 +1,1436 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis input form -- the Reinforcement section's block builder. + +WHY THIS EXISTS: the Panel Analysis input form used to show reinforcement as +one raw eccentricity number in an unlabelled box, and the owner has now said +three times it is unreadable ("太丑了", "你这么输入用户上哪儿能看懂", and +finally "全部给我做成普通人能看懂的样子" -- make every part of it something an +ordinary person can understand). This module rebuilds the Reinforcement half +of that form to read like Eriksson Wall's own printed page (strands, rebar, +mesh, interrupts, then the drawn cross-section), while staying an honest INPUT +form rather than a finished report: every optional block that is empty says so +in a sentence, exactly the way Eriksson prints "Design contains no rebar" / +"no mesh" / "no prestressing" rather than leaving a blank space that could +mean either "nothing here" or "nobody has typed it yet". + +WHY REBAR STOPPED BEING FREE TEXT (owner, 2026-09-04): "rebar 怎么可能是打字 +上去? 而不是进入 model 的? 你这样计算模型怎么接收这个数据?" -- typed prose can +never feed a calculation, so this page no longer renders `ps_rows` / `mild_as` +as text boxes. Strands and mild bars are each a `strand_row` / `mild_bar` +TABLE now, one row per strand group / bar callout, each cell its own value +under `
..` with `
..present` == 'yes' marking a row as +real (see `row_ids` / `row_key` below) -- the same shape +`panelanalysisform.load_combo_row_ids` already uses for the load-combination +tables, generalised to a name (`table`) this module chooses rather than one +baked into that function. Rendered as an `editable_table` block per table: +schema only (the table name, its column specs, an add-row label) -- no `rows` +in the block itself, because the renderer reads the live rows straight out of +the form's `values` dict the same way it reads any other field, and a second +copy of the same data here would just be a second place for it to go stale. + +`page(values, reads=None, know=None)` returns `(blocks, problems)` and never +raises. +`blocks` is a flat list of dicts drawn from a fixed, generic vocabulary (see +the button's WPF renderer) -- 'heading', 'columns', 'field', 'editable_table', +'kv', 'sentence', 'drawing', 'rule', 'space'. An unrecognised `kind` renders as +nothing, so adding a new one here without also teaching the renderer is a +silent no-op, not a crash -- worth remembering while extending this file. +Every 'sentence' block carries `'quiet': True` (owner, 2026-09-04: "Helper +prose becomes a quiet sentence") -- the renderer routes those into the one +collapsed Notes panel instead of printing them inline, so this page can be +honest about every empty/degraded state without turning into a wall of grey +text. +`problems` is the sentence list the button shows above the section: every +field key this module could not find in `vibe.panelanalysisform`, every +strand size with no known area, and every summary quantity (cgx, ex, ey) that +this module refuses to compute rather than invent. + +WHAT THIS MODULE WILL NOT DO, on purpose, following `panelanalysisform`'s own +rule for `strand_area_in2` (a strand size it does not recognise is refused BY +NAME, never guessed, because an invented area is a fabricated capacity): + + * A strand size with no published area never gets a made-up one -- Aps + for that row is left out of the summary and `problems` names the size. + * Strand horizontal position (cgx) and both eccentricities (ex, ey) are + never printed IN THIS PAGE'S OWN SUMMARY -- cgx stays "not shown". A + count-weighted cgx over rows with no horizontal data would dress up an + assumption as a fact. Depth (cgy) IS printed, because depth-to-CG is + exactly what the user types per strand row -- a count-weighted average + of real entered numbers, not an invented one. What the summary DOES + show, per row, is the strand positions themselves -- `Strand x + positions`, one feet-inches value per strand -- but ONLY when that + row's own `chain` cell resolves them (issue #23 item 9a, 2026-09-07); + a row with no chain reads 'not entered'. Those are real typed numbers, + not an invented cgx, so showing them does not break the rule above. + (The DRAWING, `pa_sketch_reinf.section`, is a separate module with its + own separate contract -- it draws a real cgx/CG marker when the form + supplies actual per-strand x positions via a resolved `chain` cell or + the legacy `strand_x_in` / `strand_columns_in` lists; that is real data + this page's own summary table has no cgx COLUMN for, not a change to + the rule above.) + * Horizontal strand spacing is not a column on the `strand_row` table, so + nothing here implies it is known. (Owner, 2026-09-07, issue #23 item + 9a: it was to become one -- a dimension chain from the strand rail, one + cell per strand -- which needs a row whose cell count follows its own + `count`, a shape no block kind rendered then. DONE 2026-09-07: the + `strand_row` table now carries a `chain` column, one `;`/`,`-separated + length per cell (`2";2";2";2"` == four strands at 2, 4, 6, 8 in from + the rail). `strand_positions()` below is the parse/validate/convert + rule; a row whose chain does not resolve is named in `problems`, never + guessed, and `pa_sketch_reinf` consumes the resolved positions.) + +WHY `row_ids` / `row_key` ARE READ DEFENSIVELY: a sibling agent owns +`vibe.panelanalysisform` and is restructuring it, at the same time this file +was written, to add generic row-table helpers under exactly these two names +(the same `
..` key shape `load_combo_row_ids` already uses, +generalised). Until that lands, `row_ids` / `row_key` below fall back to a +LOCAL implementation of the identical key arithmetic -- guarded, not +required, so this page works before, during and after that sibling's change +lands. Likewise every other read off `panelanalysisform` (field specs, the +strand-area table) goes through a guarded accessor that turns "not there yet" +into a `problems` sentence, never a traceback. + +No Revit import, no WPF import, no `panelsketch` import. This module DOES +import `vibe.pa_sketch_reinf` (guarded) for exactly one thing -- the +`aspect` clamp formula on the drawing block below -- so the ratio the drawing +block asks for and the ratio the drawing module actually letterboxes to can +never drift apart; that import is one-directional (`pa_sketch_reinf` does not +know this module exists) and carries no Revit/WPF of its own, so it costs +nothing on either Python. Must run on IronPython 2.7 in the button (`vibe. +helpers` never reaches this module) AND on plain CPython under +`WillTools.extension/tests/conftest.py`'s import trick: no f-strings, no +typing, no dataclasses. +""" + +try: + from vibe import panelanalysisform as pf +except ImportError: # sibling mid-restructure + pf = None + +try: + from vibe import pa_sketch_reinf as psr +except ImportError: + psr = None + + +# --------------------------------------------------------------- constants + +_STRAND_TABLE = 'strand_row' +_MILD_TABLE = 'mild_bar' + +# The strand-size vocabulary is `panelanalysisform.STRAND_DIA_CHOICES` -- +# ONE list shared with the scalar `ps_dia_in` field, so the two dropdowns +# can never offer different sizes. '1/2 SP' (the 1/2" special, 0.167 in²) +# is its own entry beside plain '1/2' (0.153 in²) -- owner, 2026-09-07, +# issue #23 item 9d: a different product, never a spelling of the other. +# The local list is the fallback for the sibling-mid-restructure case the +# module docstring describes, kept identical (a guard test pins that). +_STRAND_DIA_CHOICES_FALLBACK = ['3/8', '7/16', '1/2', '1/2 SP', '9/16', + '0.6', '0.7'] + + +def _strand_dia_choices(): + if pf is not None and hasattr(pf, 'STRAND_DIA_CHOICES'): + try: + return list(pf.STRAND_DIA_CHOICES) + except Exception: + pass + return list(_STRAND_DIA_CHOICES_FALLBACK) + + +_STRAND_COLUMNS = [ + {'col': 'count', 'label': 'Qty', 'editor': 'int'}, + # No '(in)' suffix on this label: pa_pagespec splits a trailing + # '(unit)' into the column's `unit`, and app.js then formats the cell + # as ONE length -- '2;2;2;2' rendered as 2" (first headless render, + # 2026-09-07). The chain is text with several lengths in it. + {'col': 'chain', + 'label': 'Location chain from rail, inches: 2;2;2;2', + 'editor': 'text'}, + {'col': 'depth_in', 'label': 'Depth to CG (in)', 'editor': 'num'}, + {'col': 'dia_in', 'label': 'Dia (in)', 'editor': 'choice', + 'choices': _strand_dia_choices()}, + {'col': 'pull_lb', 'label': 'Initial pull (lb)', 'editor': 'num'}, +] + +# Owner ruling, 2026-09-07 (issue #23, item 9h): one row per interrupt -- +# a station along the length where reinforcement is cut and development +# restarts. `x_ft` is measured from the panel's start, the same datum the +# rebar rows' From/To and the opening table use. +_INTERRUPT_TABLE = 'interrupt' +_INTERRUPT_COLUMNS = [ + {'col': 'x_ft', 'label': 'Position along length (ft)', 'editor': 'num'}, + {'col': 'cuts', 'label': 'Cuts', 'editor': 'choice', + 'choices': ['All', 'Strands', 'Rebar', 'Mesh']}, +] + +_MILD_COLUMNS = [ + {'col': 'size', 'label': 'Bar size', 'editor': 'choice', + 'choices': ['#3', '#4', '#5', '#6', '#7', '#8', '#9', '#10', '#11']}, + {'col': 'count', 'label': 'Qty', 'editor': 'int'}, + {'col': 'depth_in', 'label': 'Depth to CG (in)', 'editor': 'num'}, + {'col': 'direction', 'label': 'Direction', 'editor': 'choice', + 'choices': ['Longitudinal', 'Transverse']}, + {'col': 'from_ft', 'label': 'From (ft)', 'editor': 'num'}, + {'col': 'to_ft', 'label': 'To (ft)', 'editor': 'num'}, + {'col': 'spacing_in', 'label': 'Spacing (in)', 'editor': 'num'}, +] + +# Scalar fields kept on the page beside the two tables (owner, 2026-09-04: +# name every one of these explicitly, so a future edit here cannot quietly +# drop one the way a re-derived list could). +_STRAND_RAIL_KEYS = ('strand_rail_location', 'strand_rail_offset_in') +# `ps_pull_lb` ("Initial pull per strand (default)") left this page on +# 2026-09-07 (issue #23, item 9e): every strand row carries its own pull +# and the rows are the only source of it. `ps_count` is still listed but +# renders as a read-only kv row (the rows' sum) as soon as any strand row +# exists -- item 9b of the same ruling -- and as an editable field only +# while the table is empty. +_STRAND_SCALAR_KEYS = ('ps_count', 'ps_dia_in', 'ps_loss_pct') +_MESH_FIELD_KEYS = ('mesh', 'mesh_layers', 'mesh_depth_in') + +# Issue #23 item 9g (2026-09-07): the plant knowledge tree's `mesh:` section +# (the sample_plant layer -- its standard mesh sheet M9, dated 2023-07-18) +# carries one dict per stocked WWF style, grouped under five named sections: +# four structural homes plus the "not in the standard sheet but still +# stocked" list, whose entries name the stocking plant in `plant_only`. The +# order here is the dropdown's grouping order; the section's own metadata +# keys (`dated`, `d_number_meaning`, ...) are scalars, not dicts, so the +# isinstance guard in `plant_mesh_styles` never mistakes one for a section. +_MESH_KNOW_SECTIONS = ('flat_sheets', 'dt_stem', 'metrodeck_stem', 'beam', + 'not_in_standard_but_stocked') + +# panelanalysisform.FIELDS' 'kind' -> the generic 'editor' vocabulary this +# page speaks. Nothing here is 'check' -- every Reinforcement scalar field is +# free text, a number, or (mesh_layers, once declared) an int. +_EDITOR_OF_KIND = {'text': 'text', 'num': 'num', 'int': 'num'} + + +def _sentence(text): + """A quiet 'sentence' block -- see the module docstring: every sentence + this page emits is helper prose, routed into the collapsed Notes panel + rather than printed inline.""" + return {'kind': 'sentence', 'text': text, 'quiet': True} + + +def _spec_of(key): + """`panelanalysisform.FIELDS`' raw dict for `key`, or None. + + None covers every way this can fail to have the answer: `pf` failed to + import, `_spec` is not there (mid-restructure), or `key` genuinely is not + declared -- all three are "not present", never an error. + """ + if pf is None: + return None + try: + return pf._spec(key) + except Exception: + return None + + +def _field_block(key, problems): + """A 'field' block for a panelanalysisform key, or None + a MISSING note. + + Reads the label/symbol/hint/required-ness straight off panelanalysisform + so this page can never drift from the form it is rendering -- a rename in + that module shows up here as a MISSING problem instead of a stale label. + """ + spec = _spec_of(key) + if spec is None: + problems.append( + "field {0!r} is not declared in panelanalysisform -- this page " + "cannot show it (MISSING)".format(key)) + return None + editor = _EDITOR_OF_KIND.get(spec['kind'], 'text') + return { + 'kind': 'field', 'key': key, 'label': spec['label'], + 'symbol': spec['symbol'] or None, 'hint': spec['help'] or None, + 'required': spec['required'], 'editor': editor, 'choices': None, + } + + +def _text_of(values, key): + """The current string in `values` for `key`, '' if absent/None. + + `values` is meant to be string-valued, but this page must never raise on + a caller's mistake -- a non-string value (an int typed straight into a + dict in a test, a stray object) is coerced rather than crashed on. + """ + raw = values.get(key) + if raw is None: + return '' + if not hasattr(raw, 'strip'): + try: + raw = str(raw) + except Exception: + return '' + return raw.strip() + + +def _num_of(values, key): + """(float_or_None, error_or_None) for a numeric field's current text.""" + text = _text_of(values, key) + if not text: + return None, None + try: + return float(text), None + except (TypeError, ValueError): + return None, 'is not a number ({0!r})'.format(text) + + +# --------------------------------------------------------- row-table helpers + +def _local_row_key(table, n, col): + return '{0}.{1}.{2}'.format(table, n, col) + + +def _local_row_ids(table, values): + """Every row id present for `table` in `values`, ascending, as ints -- + matching `panelanalysisform.row_ids`'s own return shape exactly, so a + caller sees the same type whether this delegated or fell back. + + A row exists when `
..present` == 'yes' -- the form adds a row + by writing its keys, so the keys ARE the record of what rows exist (the + same discipline `panelanalysisform.load_combo_row_ids` documents for its + own, table-specific version of this). + """ + values = values or {} + prefix = '{0}.'.format(table) + suffix = '.present' + ids = [] + for key in values: + if not hasattr(key, 'startswith') or not hasattr(key, 'endswith'): + continue # a garbage/non-str key + if not key.startswith(prefix) or not key.endswith(suffix): + continue + n_txt = key[len(prefix):-len(suffix)] + if not n_txt or '.' in n_txt: + continue + if values.get(key) not in ('yes', True): + continue + try: + n = int(n_txt) + except (TypeError, ValueError): + continue + ids.append(n) + ids.sort() + return ids + + +def row_key(table, n, col): + """The values-dict key for one row's cell -- delegates to + `panelanalysisform.row_key` once the sibling restructure adds it, falls + back to the identical local arithmetic until then (see module docstring). + """ + if pf is not None and hasattr(pf, 'row_key'): + try: + return pf.row_key(table, n, col) + except Exception: + pass + return _local_row_key(table, n, col) + + +def row_ids(table, values): + """Every row id for `table` in `values` -- see `row_key`'s docstring for + the same delegate-then-fallback discipline.""" + if pf is not None and hasattr(pf, 'row_ids'): + try: + return pf.row_ids(table, values) + except Exception: + pass + return _local_row_ids(table, values) + + +def _cell(values, table, n, col): + return (values or {}).get(row_key(table, n, col)) + + +def _num_cell(values, table, n, col): + """(float_or_None, error_or_None) for one row's numeric cell.""" + raw = _cell(values, table, n, col) + if raw is None: + return None, None + if isinstance(raw, (int, float)): + return float(raw), None + text = raw.strip() if hasattr(raw, 'strip') else str(raw).strip() + if not text: + return None, None + try: + return float(text), None + except (TypeError, ValueError): + return None, 'is not a number ({0!r})'.format(text) + + +def _int_cell(values, table, n, col): + val, err = _num_cell(values, table, n, col) + if err: + return None, err + if val is None: + return None, None + return int(val), None + + +def _text_cell(values, table, n, col): + raw = _cell(values, table, n, col) + if raw is None: + return '' + if not hasattr(raw, 'strip'): + try: + raw = str(raw) + except Exception: + return '' + return raw.strip() + + +def _strand_area(dia_text): + """(area_in2_or_None, error_or_None) -- guarded `strand_area_in2`.""" + if not dia_text: + return None, None + if pf is None or not hasattr(pf, 'strand_area_in2'): + return None, ( + "strand size {0!r}: panelanalysisform is not available -- its " + "area cannot be looked up".format(dia_text)) + try: + return pf.strand_area_in2(dia_text) + except Exception as exc: + return None, 'strand size {0!r}: lookup failed ({1})'.format( + dia_text, exc) + + +def _strand_dia_area_map(): + """{choice_text: area_in2} for EVERY `_strand_dia_choices()` entry -- + the `dia_in` map a live Aps kv row ships to the page (docs/pa-web-spec + .md §2, "kv"; issue #23, 2026-09-07) so the browser can re-sum Aps when + a row's diameter changes to ANY size the dropdown offers. A choice + whose area cannot be looked up maps to 0.0 -- that only happens when + panelanalysisform itself is unreachable (the guarded path above), and + then the summary kv carrying this map is never emitted anyway, because + the same lookup failure on a present row made `any_unknown_area`.""" + area_map = {} + for dia in _strand_dia_choices(): + area, _area_err = _strand_area(dia) + area_map[dia] = area if area is not None else 0.0 + return area_map + + +def _fmt_in(value): + """A decimal-inch value -> the short feet-inches text the summary and + geometry use (`2.0` -> `2"`, `6.5` -> `6 1/2"`, `18.0` -> `1'-6"`). + Matches how `pa_units.feet_str` reads a decimal-feet value (that helper + is in inches here, so it is not reused directly).""" + negative = value < 0.0 + value = abs(value) + whole_in = int(value) + frac = value - whole_in + frac_text = '' + if frac > 1e-9: + eighths = int(round(frac * 8.0)) + if eighths >= 8: + whole_in += eighths // 8 + eighths = eighths % 8 + if eighths: + halves = {1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', + 5: '5/8', 6: '3/4', 7: '7/8'} + frac_text = ' ' + halves[eighths] + if whole_in >= 12: + whole_ft = whole_in // 12 + whole_in = whole_in % 12 + text = u"{0}'-{1}{2}\"".format(whole_ft, whole_in, frac_text) + else: + text = u'{0}{1}"'.format(whole_in, frac_text) + return u'-' + text if negative else text + + +# ------------------------------------------------------------------ strand x +# +# Issue #23 item 9a (owner, 2026-09-07): a strand row's horizontal position +# is entered as a DIMENSION CHAIN from the strand rail -- one `;`/`-separated +# length per strand, each measured rail-to-strand, so `2";2";2";2"` puts +# four strands at 2, 4, 6, 8 in from the rail ("位置就是累加-offset"). The +# parse/validate/mirror rule lives HERE (the page) because the same numbers +# feed the page's own summary; `pa_sketch_reinf` imports this one helper and +# its shared length parser rather than duplicating either. + +def _chain_split(text): + """['2"', '2"', '2"', '2"'] from '2";2",2"; 2"'. Splits on `;` or `,`, + strips each cell -- anything else (a stray `x`) stays inside one cell + so the length parser can refuse it BY NAME instead of it vanishing.""" + out = [] + for raw in (text or '').replace(',', ';').split(';'): + cell = raw.strip() + if cell: + out.append(cell) + return out + + +def _decimal_of(text): + """A plain non-negative decimal number -> float, or None.""" + t = (text or '').strip() + if not t: + return None + try: + v = float(t) + except (TypeError, ValueError): + return None + if v < 0: + return None + return v + + +def _fraction_of(text): + """'3/4' -> 0.75, or None when it is not a clean fraction.""" + t = (text or '').strip() + if not t or '/' not in t: + return None + num, _sep, den = t.partition('/') + num = num.strip() + den = den.strip() + try: + n = float(num) + d = float(den) + except (TypeError, ValueError): + return None + if n < 0 or d <= 0: + return None + return n / d + + +def _inches_of(text): + """'2', '2.5', '1/4', '2 1/4' -> inches as a float, or None.""" + t = (text or '').strip() + if not t: + return 0.0 + if ' ' in t: + parts = t.split() + if len(parts) != 2: + return None + whole = _decimal_of(parts[0]) + frac = _fraction_of(parts[1]) + if whole is None or frac is None: + return None + return whole + frac + if '/' in t: + return _fraction_of(t) + return _decimal_of(t) + + +def parse_length_in(text): + """A single typed length -> inches, or None if it cannot be read. + + The shared parser for the `strand_row.chain` cells: a plain number + ('2' -> 2.0, read as inches, the way every other inch cell on this + page reads a bare number), an inches spelling ('2"', '2in', '2 1/2"', + '1/4"'), or feet-inches ('0\\'-2"' -> 2.0, "1'-6\"" -> 18.0). Typed + quotes/primes are normalised first. An empty string is None -- a chain + never contains blanks, so a blank cell is an error upstream. Never + raises; anything not read is refused (None), never guessed. + """ + if text is None: + return None + s = str(text).strip() + if not s: + return None + s = (s.replace(u'\u2019', "'").replace(u'\u201c', '"') + .replace(u'\u201d', '"').replace(u'\u2032', "'") + .replace(u'\u2033', '"').replace(u'\u00a0', ' ') + .replace('IN', 'in').replace('In', 'in').lower()) + if "'" in s: + feet_text, _sep, rest = s.partition("'") + feet = _decimal_of(feet_text) + if feet is None: + return None + rest = rest.strip() + if rest.startswith('-'): + rest = rest[1:].strip() + if not rest: + return None + if rest.endswith('"'): + rest = rest[:-1].strip() + elif rest.endswith('in'): + rest = rest[:-2].strip() + if not rest: + return None + inches = _inches_of(rest) + if inches is None: + return None + return feet * 12.0 + inches + t = s + if t.endswith('"'): + t = t[:-1].strip() + elif t.endswith('in'): + t = t[:-2].strip() + if not t: + return None + return _inches_of(t) + + +def parse_chain_lengths(chain_text, row_count, row_label='strand row'): + """([length_in, ...] | None, problem | None) for a `chain` cell. + + Splits `chain_text` on `;`/`,` and parses every cell with the shared + `parse_length_in`; validates the cell count against `row_count`. A + blank chain is `([], None)` -- an empty chain is not an error (the + caller decides a blank means "unknown position"). Each length is the + rail-to-strand gap for that one cell (NOT cumulative) -- the parsed + list `pa_sketch_reinf` carries per row as `chain_in` so a future + drawing (issue #23 item 9i) can draw the chain itself. Never raises; + anything not read is refused by name. + """ + cells = _chain_split(chain_text or '') + if row_count is not None and len(cells) != int(row_count): + return None, ('{0}: chain has {1} cells for {2} strands' + .format(row_label, len(cells), int(row_count))) + lengths = [] + for i, cell in enumerate(cells): + v = parse_length_in(cell) + if v is None or v < 0: + return None, ('{0}: chain cell {1} ({2!r}) is not a readable ' + 'length'.format(row_label, i + 1, cell)) + lengths.append(v) + return lengths, None + + +def strand_positions(row_count, chain_text, rail_location, + rail_offset_in, width_in, row_label='strand row'): + """(list_of_x_in_from_panel_left | None, problem | None) for one strand + row's `chain` cell -- issue #23 item 9a (owner, 2026-09-07). + + `chain_text` is `count` `;`/`,`-separated lengths, each the gap from + the strand rail to that strand ("位置就是累加-offset"). Returns the + cumulative sums -- `2";2";2";2"` -> [2, 4, 6, 8] measured from the + rail -- minus `rail_offset_in` (0 when blank), expressed from the + PANEL'S LEFT edge: direct when the rail is 'left', mirrored + (`width_in - x`) when the rail is 'right'. An unreadable/blank chain + is not this helper's failure -- the caller treats a blank chain as + "unknown position" BEFORE calling (see `_strand_table_rows`). + `row_label` names the row in every problem sentence ('strand row', + 'strand row 3', ...). + + Never guesses: a chain that does not resolve returns `(None, problem)` + -- entry count != row count, a cell that will not parse, or a position + that lands outside the panel (`< 0` or `> width_in`; the strand index + and the offending value are named). `width_in` missing or <= 0 is a + problem (there is nothing honest to measure against); `rail_location` + is matched the same way the drawing's own rail label matches it + ('left'/'right' substring, case-insensitive; anything else is not a + rail side and is reported). + """ + if row_count is None: + return None, '{0}: no count to match a chain against'.format( + row_label) + count = int(row_count) if row_count else 0 + if count <= 0: + return None, '{0}: count is not a positive number'.format(row_label) + if not width_in or width_in <= 0: + return None, ('{0}: the panel width is not entered -- a chain ' + 'cannot be placed'.format(row_label)) + lengths, err = parse_chain_lengths(chain_text or '', count, + row_label=row_label) + if err: + return None, err + rail = (rail_location or '').strip().lower() + if 'left' in rail: + left_datum = True + elif 'right' in rail: + left_datum = False + else: + return None, ("{0}: the strand rail location is not 'left' or " + "'right' -- a chain cannot be placed".format( + row_label)) + try: + offset = float(rail_offset_in) if rail_offset_in not in (None, '') \ + else 0.0 + except (TypeError, ValueError): + offset = 0.0 + if offset < 0 or offset > width_in: + return None, ('{0}: the rail offset ({1!r}) is outside the panel ' + 'width'.format(row_label, rail_offset_in)) + positions = [] + running = 0.0 + for i, v in enumerate(lengths): + running += v + x = running - offset + if not left_datum: + x = width_in - x + if x < 0 or x > width_in: + return None, ('{0}: strand {1} would sit at {2:.3f} in from ' + 'the left, outside the panel width of {3:.3f} ' + 'in'.format(row_label, i + 1, x, width_in)) + positions.append(x) + return positions, None + + +# ------------------------------------------------------------------ strands + +def _strand_table_rows(values, problems): + """[{'id', 'count', 'chain', 'depth_in', 'dia_in', 'pull_lb'}, ...] + read off the `strand_row` table. A cell that will not parse as a + number is reported and treated as blank -- never dropped, never + guessed. `chain` is the RAW cell text ('' when blank); resolving it to + positions is the summary block's job (`strand_positions`), so the same + rows can also be handed to the drawing, which reports its own chain + problems.""" + rows = [] + for n in row_ids(_STRAND_TABLE, values): + count, cerr = _int_cell(values, _STRAND_TABLE, n, 'count') + if cerr: + problems.append('strand row {0}: count {1}'.format(n, cerr)) + depth, derr = _num_cell(values, _STRAND_TABLE, n, 'depth_in') + if derr: + problems.append('strand row {0}: depth to CG {1}'.format(n, derr)) + dia = _text_cell(values, _STRAND_TABLE, n, 'dia_in') or None + pull, perr = _num_cell(values, _STRAND_TABLE, n, 'pull_lb') + if perr: + problems.append('strand row {0}: initial pull {1}'.format(n, perr)) + chain = _text_cell(values, _STRAND_TABLE, n, 'chain') + rows.append({'id': n, 'count': count, 'chain': chain, + 'depth_in': depth, 'dia_in': dia, 'pull_lb': pull}) + return rows + + +def _strand_block(values, problems): + """The whole Strands sub-block: rail fields, the editable table, the + scalar fields, then the Aps/cgy summary or the empty-case sentence. + """ + blocks = [{'kind': 'heading', 'text': 'Strands', 'level': 2}] + + rail_fields = [] + for key in _STRAND_RAIL_KEYS: + fb = _field_block(key, problems) + if fb is not None: + rail_fields.append([fb]) + if rail_fields: + blocks.append({'kind': 'columns', 'columns': rail_fields}) + + blocks.append({ + 'kind': 'editable_table', 'table': _STRAND_TABLE, + 'columns': _STRAND_COLUMNS, 'add_label': 'Add strand row', + }) + + rows = _strand_table_rows(values, problems) + + for key in _STRAND_SCALAR_KEYS: + if key == 'ps_count' and rows: + # Owner, 2026-09-07 (issue #23, 9b): with rows present the + # count is the rows' sum, shown, never typed -- a kv row, not + # a field, so nothing can be entered out of step with the + # table. The row carries a `live` spec (item 9 of the same + # issue: the summary must follow the rows IN THE BROWSER as + # they are edited, not only on the next build/Run) -- the + # server value here is only the initial display. + total = sum((r['count'] or 0) for r in rows) + blocks.append({'kind': 'kv', 'rows': [ + ('Strand count', '{0} (sum of the strand rows above)' + .format(total), + {'table': _STRAND_TABLE, 'terms': ['count'], + 'format': '%d'})]}) + continue + fb = _field_block(key, problems) + if fb is None: + continue + blocks.append(fb) + + if not rows: + blocks.append(_sentence('Design contains no prestressing')) + return blocks + + default_dia = _text_of(values, 'ps_dia_in') + aps = 0.0 + total_count = 0 + depth_weighted = 0.0 + depth_known_count = 0 + any_unknown_area = False + # Total IP (owner, 2026-09-07, issue #23 item 9c): the sum of every + # row's count x its own initial pull -- the same shape Aps is summed + # in below, and the same figure `panellift.model.Prestress. + # jacking_stress_ksi` sums for its f_pi audit. A row with no pull of + # its own leaves the total unknown rather than under-counting. + total_pull = 0.0 + any_unknown_pull = False + for row in rows: + dia_text = row['dia_in'] or default_dia + count = row['count'] or 0 + if row['pull_lb'] is None: + any_unknown_pull = True + else: + total_pull += count * row['pull_lb'] + if not dia_text: + any_unknown_area = True + problems.append( + 'strand row {0} has no diameter entered (per-row or the ' + 'default above) -- its area could not be found' + .format(row['id'])) + else: + area, area_err = _strand_area(dia_text) + if area_err: + problems.append(area_err) + if area is None: + any_unknown_area = True + else: + aps += count * area + total_count += count + if row['depth_in'] is not None: + depth_weighted += count * row['depth_in'] + depth_known_count += count + + # Issue #23 item 9a (2026-09-07): every row's own `chain` cell is a + # dimension chain from the strand rail -- one length per strand + # ("位置就是累加-offset"). Validate each ENTERED chain here (the same + # `strand_positions` rule `pa_sketch_reinf.section_geometry` runs) so a + # chain that does not resolve is named in `problems` on the page, never + # guessed; the resolved positions feed the 'Strand x positions' kv row + # below. A row with a blank chain is "unknown position" -- today's + # even-spread fallback -- and gets no problem here (that is the quiet + # fallback sentence's job, further down). + width_ft, _ = _num_of(values, 'width_ft') + width_in = width_ft * 12.0 if width_ft and width_ft > 0 else None + rail_loc = _text_of(values, 'strand_rail_location') + rail_off = _text_of(values, 'strand_rail_offset_in') + per_row_x = [] # parallel to `rows` + for row in rows: + if not row['chain']: + per_row_x.append(None) + continue + label = 'strand row {0}'.format(row['id']) + positions, perr = strand_positions( + row['count'], row['chain'], rail_loc, rail_off, width_in, + row_label=label) + if perr: + problems.append(perr) + per_row_x.append(None) + else: + per_row_x.append(positions) + + if total_count > 0 and not any_unknown_area: + cgy = (depth_weighted / float(depth_known_count) + if depth_known_count > 0 else None) + # `Strand x positions` -- the per-row resolved chain positions + # (inches from the panel's LEFT edge), formatted feet-inches; + # 'not entered' when no row carries a resolvable chain. A row whose + # chain failed to resolve is already named in `problems` above, so + # it is simply left out of this list, never shown as a guess. + fmt_rows = [] + for i, positions in enumerate(per_row_x): + if positions is None: + continue + r = rows[i] + fmt_rows.append('row {0}: {1}'.format( + r['id'], ', '.join(_fmt_in(x) for x in positions))) + strand_x_value = '; '.join(fmt_rows) if fmt_rows \ + else 'not entered' + # Aps and Total IP are the two numbers a user edits into being row + # by row, so both rows carry a `live` spec (docs/pa-web-spec.md §2, + # "kv"; issue #23, 2026-09-07): the browser re-sums them over the + # strand_row table as rows are edited/added/removed. These server + # values are the initial display; the kv rows without a 3rd element + # (cgy and the rest) stay plain two-column readouts. + kv_rows = [ + ('Aps, in²', ('%.4f' % aps).rstrip('0').rstrip('.'), + {'table': _STRAND_TABLE, + 'terms': ['count', + {'col': 'dia_in', 'map': _strand_dia_area_map()}], + 'format': '%.4f', + 'unknown': 'not shown -- a strand row\'s diameter has no ' + 'known area'}), + ('Total IP, lb (sum of initial pull)', + ('%.1f' % total_pull).rstrip('0').rstrip('.') + if not any_unknown_pull + else 'not shown -- a strand row is missing its initial pull', + {'table': _STRAND_TABLE, 'terms': ['count', 'pull_lb'], + 'format': '%.1f', + 'unknown': 'not shown -- a strand row is missing its initial ' + 'pull'}), + ('cgy, in (depth to CG)', + ('%.4f' % cgy).rstrip('0').rstrip('.') if cgy is not None + else 'not shown -- a strand row is missing its depth'), + ('Strand x positions', strand_x_value), + ('cgx, in', 'not shown -- horizontal strand position is not ' + 'a column on the strand table, only an assumption ' + 'made for the solve'), + ('ex, in', 'not shown -- see cgx above'), + ('ey, in', 'not shown -- needs a defined section centroid, not ' + 'derived by this page'), + ] + blocks.append({'kind': 'kv', 'rows': kv_rows}) + problems.append( + 'strand summary: cgx and both eccentricities (ex, ey) are not ' + 'computed -- a count-weighted cgx would need every strand\'s ' + 'horizontal position collected here, and per-strand positions ' + 'are only entered per row when that row\'s chain is filled') + elif total_count > 0: + problems.append( + 'strand summary (Aps, cgy) could not be computed for every row ' + '-- see the per-row problems above') + + # The drawing spreads some strand dots evenly across the width for + # want of a real per-strand x -- an honest, expected degradation + # (2026-09-04), so it is a QUIET sentence here rather than a + # `problems` entry (`pa_sketch_reinf.section`'s own `problems` is + # reserved for genuine failures now; see that module's docstring). + # `used_fallback_strand_x` is the guarded delegate so this page asks + # the drawing's OWN question rather than re-deriving the length-match / + # chain-resolution rule -- absent/broken `psr` degrades to silence, + # never a crash. + if psr is not None and hasattr(psr, 'used_fallback_strand_x'): + try: + if psr.used_fallback_strand_x(values): + blocks.append(_sentence( + 'The cross-section below spreads at least one row of ' + 'these strands evenly across the width -- a strand row ' + 'with no location chain from the rail (and no ' + 'strand_x_in read off the model) has no honest ' + 'horizontal position, so it cannot show those strands\' ' + 'real positions or a true cgx.')) + except Exception: + pass + + return blocks + + +# --------------------------------------------------------------- rebar/mesh + +def _mild_table_rows(values, problems): + """[{'id','size','count','depth_in','direction','from_ft','to_ft', + 'spacing_in'}, ...] read off the `mild_bar` table.""" + rows = [] + for n in row_ids(_MILD_TABLE, values): + size = _text_cell(values, _MILD_TABLE, n, 'size') or None + count, cerr = _int_cell(values, _MILD_TABLE, n, 'count') + if cerr: + problems.append('rebar row {0}: count {1}'.format(n, cerr)) + depth, derr = _num_cell(values, _MILD_TABLE, n, 'depth_in') + if derr: + problems.append('rebar row {0}: depth to CG {1}'.format(n, derr)) + direction = _text_cell(values, _MILD_TABLE, n, 'direction') or None + from_ft, ferr = _num_cell(values, _MILD_TABLE, n, 'from_ft') + if ferr: + problems.append('rebar row {0}: from (ft) {1}'.format(n, ferr)) + to_ft, terr = _num_cell(values, _MILD_TABLE, n, 'to_ft') + if terr: + problems.append('rebar row {0}: to (ft) {1}'.format(n, terr)) + spacing, serr = _num_cell(values, _MILD_TABLE, n, 'spacing_in') + if serr: + problems.append('rebar row {0}: spacing (in) {1}'.format(n, serr)) + rows.append({'id': n, 'size': size, 'count': count, + 'depth_in': depth, 'direction': direction, + 'from_ft': from_ft, 'to_ft': to_ft, + 'spacing_in': spacing}) + return rows + + +def _rebar_block(values, problems): + blocks = [{'kind': 'heading', 'text': 'Rebar', 'level': 2}] + blocks.append({ + 'kind': 'editable_table', 'table': _MILD_TABLE, + 'columns': _MILD_COLUMNS, 'add_label': 'Add rebar row', + }) + rows = _mild_table_rows(values, problems) + if not rows: + blocks.append(_sentence('Design contains no rebar')) + return blocks + # A row with no depth to CG is not drawn on the cross-section -- an + # honest, expected gap (the drawing never guesses a cover), so it is a + # QUIET sentence here rather than red text inside the canvas or a + # `problems` entry (2026-09-04; `pa_sketch_reinf.section`'s own + # `problems` is reserved for genuine failures now). + for row in rows: + if row['depth_in'] is None: + blocks.append(_sentence( + 'Rebar row {0} ({1}): no depth to CG entered -- not drawn ' + 'on the cross-section.'.format(row['id'], + row['size'] or 'bar'))) + return blocks + + +def _wwf(text): + """(resolved_or_None, error_or_None) -- guarded + `panelanalysisform.wwf_designation`.""" + if pf is None or not hasattr(pf, 'wwf_designation'): + return None, ('mesh designation {0!r}: panelanalysisform cannot ' + 'resolve it (no wwf_designation)'.format(text)) + try: + return pf.wwf_designation(text) + except Exception as exc: + return None, 'mesh designation {0!r}: lookup failed ({1})'.format( + text, exc) + + +def _fmt4(v): + return ('%.4f' % v).rstrip('0').rstrip('.') + + +# ------------------------------------------------- plant's own mesh styles +# +# Issue #23 item 9g, second half (2026-09-07): the owner asked "9g 需要的厂知 +# 识现在没有嘛?" -- it exists: the sample_plant knowledge layer carries a `mesh:` +# section (its standard mesh sheet M9, dated 2023-07-18) listing the styles +# the plant itself stocks. This page turns those into the designation +# field's dropdown, on top of the free text that was always there. + +def _mesh_is_var(spacing): + """Whether one `` / `` token means 'variable' -- the + sheet writes 'Var' when the spacing is not a fixed number.""" + try: + return spacing.strip().lower() == 'var' + except Exception: + return False + + +def _mesh_size_parts(size_text): + """`(long_sp, trans_sp, long_wire, trans_wire, sheet_size)` parsed from + one stocked `size` string, or None when it does not parse. + + The sheet's grammar (per the sample_plant layer, mesh sheet M9) is + ' x - / + (+a,+b) x (c,d)' -- e.g. '4 x 4 - D3 / D3 96" + (+2,+2) x 11\'-10" (1,1)'. The two spaces between the wire pair and the + sheet size are the only thing that separates the parseable head from + the human sheet description; the head is all this page ever parses. + """ + if not hasattr(size_text, 'partition'): + return None + spacing, dash, wire = size_text.partition(' - ') + if not dash: + return None + sp = [tok.strip() for tok in spacing.split('x')] + if len(sp) != 2 or not sp[0] or not sp[1]: + return None + long_wire, slash, rest = wire.partition('/') + long_wire = long_wire.strip() + rest = rest.strip() + if not slash or not long_wire or not rest: + return None + trans_wire, sheet = rest, None + if ' ' in rest: + trans_wire, _, sheet = rest.partition(' ') + trans_wire = trans_wire.strip() + sheet = sheet.strip() or None + if not trans_wire: + return None + return (sp[0], sp[1], long_wire, trans_wire, sheet) + + +def _mesh_designation_of(size_text): + """`(designation_or_None, convertible)` for one stocked `size` string. + + `designation` rebuilds the parseable short form the resolver accepts, + 'x-x' (e.g. '4x4-D3xD3', + where the D-number is hundredths of a square inch -- D3 = 0.03 in², + the public rule in knowledge/catalogue.yaml `catalogue.wwf`, applied + by the existing parser). `convertible` is False when either spacing is + 'Var' (variable -- not a number, so no area can be derived) or the + size does not parse at all; the caller keeps such a code in the list, + marked, rather than dropping it.""" + parts = _mesh_size_parts(size_text) + if parts is None: + return None, False + long_sp, trans_sp, long_wire, trans_wire, _sheet = parts + designation = '{0}x{1}-{2}x{3}'.format(long_sp, trans_sp, long_wire, + trans_wire) + return designation, not (_mesh_is_var(long_sp) or _mesh_is_var(trans_sp)) + + +def _mesh_sheet_size(size_text): + """The human sheet description one stocked `size` string carries after + its wire pair (e.g. '96" (+2,+2) x 11\'-10" (1,1)'), or None. Dropdown + label prose only -- never parsed.""" + parts = _mesh_size_parts(size_text) + if parts is None: + return None + return parts[4] + + +def plant_mesh_styles(know): + """Every WWF style the plant's own `mesh:` knowledge section stocks, as + a list of one dict per designation code: + `{code, designation, size_text, used_in, section, plant_only, + convertible}`. `designation` is the code's short parseable form built + from its stocked `size` string (see `_mesh_designation_of`); + `convertible` is False when a spacing is 'Var' or the size does not + parse -- such styles STAY in the list, marked, never dropped. `used_in` + is the section's stock-use sentence (or None); `plant_only` names the + plant that alone stocks a style in `not_in_standard_but_stocked` (or + None). Pure function of `know`: `know` None, or without a `mesh` dict + section, answers [] -- the page then keeps the plain text box it always + had. Never raises.""" + styles = [] + if not isinstance(know, dict): + return styles + mesh = know.get('mesh') + if not isinstance(mesh, dict): + return styles + for section in _MESH_KNOW_SECTIONS: + bucket = mesh.get(section) + if not isinstance(bucket, dict): + continue + # Codes sorted so the dropdown is identical on every interpreter + # (IronPython 2.7 dicts do not preserve insertion order). + for code in sorted(bucket): + entry = bucket[code] + if not isinstance(entry, dict): + continue + size_text = entry.get('size') + designation, convertible = _mesh_designation_of(size_text) + styles.append({ + 'code': code, 'designation': designation, + 'size_text': size_text, 'used_in': entry.get('used_in'), + 'section': section, + 'plant_only': entry.get('plant_only'), + 'convertible': convertible, + }) + return styles + + +def _mesh_choices(styles): + """One dropdown option dict per stocked style, then the trailing custom + option. Each option's VALUE is the plant's designation code -- the plain + string kept in `values['mesh']` -- and its LABEL is human prose: + ' -- (; )', plus + ' [ only]' when the style is stocked by one plant alone. The last + option, '(custom -- type a designation)' (value ''), is the dropdown + saying it is a shortcut, not a straitjacket: any text that is not a + plant code still parses as a free designation.""" + options = [] + for style in styles: + head = style['designation'] or style['size_text'] or 'unreadable' + label = '{0} -- {1}'.format(style['code'], head) + bits = [] + sheet = _mesh_sheet_size(style['size_text']) + if sheet: + bits.append(sheet) + if style['used_in']: + bits.append(style['used_in']) + if bits: + label += ' ({0})'.format('; '.join(bits)) + if style['plant_only']: + label += ' [{0} only]'.format(style['plant_only']) + options.append({'value': style['code'], 'label': label}) + options.append({'value': '', 'label': '(custom -- type a designation)'}) + return options + + +def _mesh_plant_text(style): + """The 'Plant style' kv value for a chosen code: ' ()', + or just the code when the section carries no use sentence.""" + if style['used_in'] is None: + return style['code'] + return '{0} ({1})'.format(style['code'], style['used_in']) + + +def _mesh_style_problem(style): + """The `problems` sentence for a chosen stocked style that cannot be + converted to an area -- names the code and the reason.""" + if style['designation'] is None: + reason = 'its size cannot be read as spacing and wire sizes' + else: + reason = 'its spacing is variable (Var), not a fixed size' + return ('Mesh designation {0}: the plant stocks this style, but {1} -- ' + 'its spacing and wire area are not shown (never a guessed ' + 'area).').format(style['code'], reason) + + +def _mesh_summary_rows(resolved): + """The three resolved kv rows under the 'Plant style' row: wire spacing, + wire area and As per foot each way -- all real parsed numbers, never a + guess.""" + return [ + ('Wire spacing, in (long x trans)', + '{0} x {1}'.format(_fmt4(resolved['spacing_long_in']), + _fmt4(resolved['spacing_trans_in']))), + ('Wire area, in² each (long / trans)', + '{0} ({1}) / {2} ({3})'.format( + _fmt4(resolved['wire_area_long_in2']), resolved['wire_long'], + _fmt4(resolved['wire_area_trans_in2']), resolved['wire_trans'])), + ('As per ft, in²/ft (long / trans), one layer', + '{0} / {1}'.format(_fmt4(resolved['as_long_in2_per_ft']), + _fmt4(resolved['as_trans_in2_per_ft']))), + ] + + +def _mesh_block(values, problems, know): + """Mesh as a STRUCTURED group (owner, 2026-09-07, issue #23 item 9g): + the designation field, the layer count, the depth to CG, then a kv + summary that resolves the designation to wire spacing, wire area and + area per foot each way. A designation that does not parse is named in + `problems` and the summary is withheld -- never a guessed area. + + When `know` carries the plant's own `mesh:` section (the sample_plant + layer's stocked WWF styles, standard mesh sheet M9, dated 2023-07-18 -- + the answer to the owner's "9g 需要的厂知识现在没有嘛?"), the designation + field becomes a DROPDOWN of those stocked styles while free text + remains: an option's VALUE is the plant's designation code, which stays + in `values['mesh']` as the plain string it has always been, and a + stored code is resolved through its style's short designation before + parsing -- any other text parses exactly as before. A chosen style that + cannot be converted ('Var' spacing, or an unreadable size) is named in + `problems` with the reason and only the 'Plant style' row is shown. + `know` None, or without a `mesh` section, keeps the field exactly the + plain text box it was before -- no choices, no 'Plant style' row.""" + blocks = [{'kind': 'heading', 'text': 'Mesh', 'level': 2}] + styles = plant_mesh_styles(know) + by_code = dict((s['code'], s) for s in styles) + fields = [] + for key in _MESH_FIELD_KEYS: + fb = _field_block(key, problems) + if fb is not None: + if key == 'mesh' and styles: + fb['editor'] = 'choice' + fb['choices'] = _mesh_choices(styles) + fields.append([fb]) + if fields: + blocks.append({'kind': 'columns', 'columns': fields}) + text = _text_of(values, 'mesh') + if not text: + blocks.append(_sentence('Design contains no mesh')) + return blocks + + style = by_code.get(text) + if style is not None: + # A stocked plant style was chosen: resolve through its short + # designation. A non-convertible one is named and withheld -- the + # spacing/area rows are never guessed from a 'Var' spacing. + rows = [('Plant style', _mesh_plant_text(style))] + if not style['convertible']: + problems.append(_mesh_style_problem(style)) + blocks.append({'kind': 'kv', 'rows': rows}) + return blocks + resolved, err = _wwf(style['designation']) + if err: + problems.append(err) + return blocks + if resolved is None: + return blocks + rows.extend(_mesh_summary_rows(resolved)) + else: + # Free text -- a custom designation. With the plant's style list in + # front of the user, the summary says so; without it (no `mesh` in + # `know`) this is exactly the pre-dropdown behaviour. + resolved, err = _wwf(text) + if err: + problems.append(err) + return blocks + if resolved is None: + return blocks + rows = [('Plant style', 'custom designation')] if styles else [] + rows.extend(_mesh_summary_rows(resolved)) + blocks.append({'kind': 'kv', 'rows': rows}) + if _text_of(values, 'mesh_depth_in') == '': + blocks.append(_sentence( + 'Mesh depth to CG not entered -- the cross-section draws the ' + 'mesh at mid-depth (or mid-wythe) until it is.')) + return blocks + + +def _interrupt_rows(values, problems): + """[{'id', 'x_ft', 'cuts'}, ...] read off the `interrupt` table.""" + rows = [] + for n in row_ids(_INTERRUPT_TABLE, values): + x_ft, xerr = _num_cell(values, _INTERRUPT_TABLE, n, 'x_ft') + if xerr: + problems.append('interrupt row {0}: position {1}'.format(n, xerr)) + cuts = _text_cell(values, _INTERRUPT_TABLE, n, 'cuts') or None + rows.append({'id': n, 'x_ft': x_ft, 'cuts': cuts}) + return rows + + +def _interrupts_block(values, problems): + """The Interrupts sub-block: an editable table, one row per interrupt + (owner, 2026-09-07, issue #23 item 9h) -- a position along the length + where strands / rebar / mesh are cut and development restarts. + + With no rows the sentence is 'Design contains no interrupts': the + table exists and is empty, which is a judged answer now, unlike the + could-not-judge placeholder this replaced (before 2026-09-07 the form + had no field at all). What an interrupt DOES to the development + length is the companion engine issue's job; this page only collects + where they are and what they cut, and says so.""" + blocks = [{'kind': 'heading', 'text': 'Interrupts', 'level': 2}] + blocks.append({ + 'kind': 'editable_table', 'table': _INTERRUPT_TABLE, + 'columns': _INTERRUPT_COLUMNS, 'add_label': 'Add interrupt', + }) + rows = _interrupt_rows(values, problems) + if not rows: + blocks.append(_sentence('Design contains no interrupts')) + return blocks + for row in rows: + if row['x_ft'] is None: + blocks.append(_sentence( + 'Interrupt row {0}: no position entered.'.format(row['id']))) + if not row['cuts']: + blocks.append(_sentence( + 'Interrupt row {0}: does not say what it cuts -- pick ' + 'All, Strands, Rebar or Mesh.'.format(row['id']))) + blocks.append(_sentence( + 'Interrupts are collected here only; the solve does not yet ' + 'restart development length at them.')) + return blocks + + +# ------------------------------------------------------------------ drawing + +_ASPECT_MIN = 0.12 +_ASPECT_MAX = 0.4 +_ASPECT_DEFAULT = 0.2 + +# A TYPICAL "MORE aspect" figure -- the LOCAL FALLBACK's own extra, used +# only on the rare path where `pa_sketch_reinf.aspect_for` cannot be +# reached at all (module missing, or it raised). The real, per-`values` +# number is `psr.aspect_for(values)` itself -- see `_section_aspect` -- +# which folds in THIS schedule's own legend size rather than guessing a +# flat constant (2026-09-04); this fallback constant matches that module's +# own `ASPECT_EXTRA` (a guard test keeps the two from drifting apart). +_ASPECT_EXTRA_DEFAULT = 0.22 + +# Outline plan aspect clamps -- the reinforcement plan is a plan (length x +# width), the same family the Concrete Extents outline uses; the extra band +# is reserved for the plan's own dimension-chain labels below the rect. +_OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, _OUTLINE_ASPECT_DEFAULT = \ + 0.12, 0.5, 0.3 +_OUTLINE_ASPECT_EXTRA = 0.10 + + +def _ratio_clamp(numerator, denominator, lo, hi, default): + """`numerator/denominator` clamped to `[lo, hi]`, or `default` when + either is missing / non-positive (nothing to divide against).""" + if not numerator or numerator <= 0 or not denominator or denominator <= 0: + return default + ratio = numerator / float(denominator) + if ratio < lo: + return lo + if ratio > hi: + return hi + return ratio + + +def _outline_aspect(values): + """The FULL aspect the reinforcement PLAN block asks for: the panel's + plan proportion (width / length), clamped, PLUS the fixed label-band + extra (issue #23 item 9i). The plan has no legend like the section's, so + no `aspect_for`-style per-`values` band is needed here -- a flat extra + is the honest ceiling, matching how the Concrete Extents outline folds + its own label rows in. Never raises.""" + width, _ = _num_of(values, 'width_ft') + length, _ = _num_of(values, 'length_ft') + return _ratio_clamp( + width, length, _OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, + _OUTLINE_ASPECT_DEFAULT) + _OUTLINE_ASPECT_EXTRA + + +def _section_aspect(values): + """The FULL aspect the drawing block asks for -- delegates to + `pa_sketch_reinf.aspect_for(values)`, which folds in the section's own + thickness/width ratio AND this particular `values`' own fixed + title/bottom/legend bands (a busy schedule gets a correspondingly + taller canvas instead of a flat guess -- see that module's own + docstring's LAYOUT section, amendment 3). Falls back to `clamp_aspect() + + ASPECT_EXTRA` (a flat, legend-blind estimate) when `aspect_for` is + not reachable, and to an identical local formula when `pa_sketch_reinf` + cannot be imported at all -- never raises.""" + if psr is not None and hasattr(psr, 'aspect_for'): + try: + return psr.aspect_for(values) + except Exception: + pass + extra = _ASPECT_EXTRA_DEFAULT + if psr is not None: + extra = getattr(psr, 'ASPECT_EXTRA', _ASPECT_EXTRA_DEFAULT) + if psr is not None and hasattr(psr, 'clamp_aspect'): + try: + return psr.clamp_aspect(values.get('width_ft'), + values.get('thickness_in')) + extra + except Exception: + pass + width, _ = _num_of(values, 'width_ft') + thickness, _ = _num_of(values, 'thickness_in') + if not width or width <= 0 or not thickness or thickness <= 0: + return _ASPECT_DEFAULT + extra + aspect = (thickness / 12.0) / width + if aspect < _ASPECT_MIN: + aspect = _ASPECT_MIN + if aspect > _ASPECT_MAX: + aspect = _ASPECT_MAX + return aspect + extra + + +def _drawing_block(values): + """TWO stacked `drawing` blocks (issue #23 item 9i, owner ruling): the + reinforcement page now carries a PAIR of drawings like Concrete Extents' + own outline+section pair -- the plan view (`source: 'outline'`) first, + full width, then the existing cross-section (`source: 'section'`) + BELOW it, also full width, exactly the stacked shape + `pa_extents._extents_drawing_blocks()` uses (never side by side). The + plan gives every reinforcement type its own group with its own + continuous dimension chain from its own reference (strands from the + rail, bars, mesh -- see `pa_sketch_reinf.outline_geometry`); the + section stays the cross-section it always was. + + Both name `module: 'pa_sketch_reinf'` (the sibling module that owns the + actual geometry functions `outline_geometry` / `section_geometry`) -- + this function only builds the two block dicts the renderer reads to size + each canvas and decide what to redraw on it, never draws anything + itself. Both `depends_on` watch every input either drawing reads + (strand/mild row tables, the strand rail datum, mesh, and the panel's + real length/width/thickness), so an edit anywhere re-triggers both. + """ + return [ + { + 'kind': 'drawing', 'module': 'pa_sketch_reinf', 'source': 'outline', + 'full_width': True, 'aspect': _outline_aspect(values), + 'caption': 'Reinforcement plan -- strands from the rail, bars, ' + 'mesh', + 'depends_on': ['strand_row', 'mild_bar', + 'strand_rail_location', 'strand_rail_offset_in', + 'mesh', 'mesh_layers', 'mesh_depth_in', + 'thickness_in', 'width_ft', 'length_ft'], + }, + { + 'kind': 'drawing', 'module': 'pa_sketch_reinf', 'source': 'section', + 'full_width': True, 'aspect': _section_aspect(values), + 'caption': 'Reinforcement cross-section', + 'depends_on': ['strand_row', 'mild_bar', + 'strand_rail_location', 'strand_rail_offset_in', + 'mesh', 'mesh_layers', 'mesh_depth_in', + 'thickness_in', 'width_ft', 'length_ft', + 'section_type'], + }, + ] + + +# ------------------------------------------------------------------- public + +def page(values, reads=None, know=None): + """(blocks, problems) for the Reinforcement section of the input form. + + `values` is the form's current string-valued dict, plus the `strand_row` + / `mild_bar` table cells under `
..` (see the module + docstring). `reads` is accepted for signature parity with the rest of + this form's per-section builders; nothing under Reinforcement is + prefilled from the model (`panelanalysisform.PREFILLABLE` does not + include any of these keys), so it is not read here. `know` is the + optional merged knowledge tree (`vibe.knowledge.load()`'s own shape), + used only by the mesh group: its `mesh:` section (the plant's OWN + stocked WWF styles -- sample_plant layer, standard sheet M9, dated + 2023-07-18) turns the designation field into a dropdown of those styles + while free text remains (`_mesh_block`); `None`, or a tree without that + section, leaves the field the plain text box it always was. + `vibe.pa_pagespec._load_page` passes `know` through only when it detects + this function declares the parameter, so a two-argument caller keeps + working unchanged. Never raises -- every failure becomes a sentence in + `problems` instead. + """ + values = values or {} + problems = [] + + blocks = [{'kind': 'heading', 'text': 'Reinforcement', 'level': 1}] + blocks.extend(_strand_block(values, problems)) + blocks.append({'kind': 'rule'}) + blocks.extend(_rebar_block(values, problems)) + blocks.append({'kind': 'rule'}) + blocks.extend(_mesh_block(values, problems, know)) + blocks.append({'kind': 'rule'}) + blocks.extend(_interrupts_block(values, problems)) + blocks.append({'kind': 'space', 'h': 8}) + blocks.extend(_drawing_block(values)) + + return blocks, problems diff --git a/WillTools.extension/lib/vibe/pa_sketch_extents.py b/WillTools.extension/lib/vibe/pa_sketch_extents.py new file mode 100644 index 0000000..29f0605 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_sketch_extents.py @@ -0,0 +1,994 @@ +# -*- coding: utf-8 -*- +"""The Concrete Extents drawings -- pure geometry, both Pythons, no Revit, +no WPF. Two entry points, `outline()` and `section()`, are what +`vibe.pa_extents.page()`'s two `drawing` blocks name (`module: +'pa_sketch_extents'`) -- see that module's "WHY THE TWO DRAWINGS ARE NOW +STACKED" docstring note for the owner ruling (2026-09-04) this exists to +satisfy: the outline drawn bigger, the section moved BELOW it instead of +beside it, and a cut line on the outline that the section redraws around -- +"跟做核磁一样" (like an MRI, scrubbing through slices). + +THE SEAM is the same one `panelsketch.py` draws for itself: the WPF side +(the `Canvas`, the brushes, and the actual DRAGGING of the cut line) is the +renderer's job, not this module's. This module's job stops at a flat list of +drawing PRIMITIVES in a normalised, already-in-pixels coordinate space +(origin top-left, y down, the same vocabulary `panelsketch.py`'s own module +docstring documents), plus a `problems` list of plain-English sentences for +anything it could not draw -- the caller only walks the list and paints each +primitive, or reads a `problems` sentence and shows it. That keeps this file +importable and testable on plain CPython (`tests/conftest.py`) and runnable +under IronPython 2.7 inside Revit: no f-strings, no `pathlib`, no +`dataclasses`, no typing, no `enum`, standard library only. + +WHY `outline()` NO LONGER DELEGATES TO `panelsketch.extents_outline()` +(owner headless-render finding, 2026-09-04, on a 37.58 x 8.375 ft x 8 in +panel): that function LETTERBOXES the panel rectangle -- it fits the +picture to whichever of width/height is tighter, preserving aspect ratio -- +which is exactly wrong for this drawing's own container. The container's +height comes from a small `aspect` ratio (`pa_extents._ratio_clamp`, roughly +`width_ft / length_ft`) applied to whatever width the renderer's `Canvas` +ends up with, so the box handed to the drawing function is ALREADY close to +the panel's own true proportions; `extents_outline()`'s own margin + +`dim_room` accounting (subtracted from an already-short `avail_h` a second +time) was enough to flip which axis is "tighter" and letterbox to HEIGHT +instead, leaving the panel at roughly half the canvas's width -- precisely +the "面板只占了显示窗口宽度的一半" the owner found. `panelsketch.py` is not +being changed to fix this (`extents_outline()` still letterboxes, and other +callers may want that) -- `outline()` below draws its own picture instead, +following the OWNER'S GENERAL RULE that already governs every OTHER +full-width drawing in this codebase (`pa_sketch_handling.lifter_dunnage()`, +`pa_sketch_reinf`'s `full_width` sections): "画图的时候,panel 的长度总要占满 +整个显示窗口的宽度" -- the drawable width is ALWAYS the panel's full width, +never letterboxed down for height's sake. `section()` below is UNCHANGED -- +it already fills its own width; see its own docstring note. + +Because that means `outline()` no longer needs `extents_outline()`'s several +hundred lines of layout math, it draws directly: the panel rectangle spans +the full available width (inside a left/right margin -- see +`_outline_margins()`), the overall W dimension on the left, the overall L +label at the panel's bottom-LEFT (not centred -- see "WHERE THE CUT AND L +LABELS SIT" below), and openings/reveals/solid-zones to scale with their own +locating dimension chains (along-length from the top, across-width from the +left). It still REUSES, rather than re-derives, the two pieces of +`panelsketch` that are pure row-geometry with no letterboxing opinion of +their own: `_parse_opening_row()` (via `_openings_rows()`) resolves one +opening row's axes, the same axis-assignment judgement call documented on +that function itself; `_opening_rect_primitives()` / `_gap_dimension_chain()` +turn resolved rows into primitives given an already-placed `(ox, oy, draw_w, +draw_h)` box -- exactly the "do not re-derive" discipline this module +already followed for `section()`. `_title_primitive()` is reused the same +way. None of the three carries a letterboxing opinion; only +`extents_outline()` itself does, and that is the one function `outline()` no +longer calls. + +WHERE THE CUT AND L LABELS SIT (owner finding, same headless render): the +station label ("cut @ 18.8 ft") and the overall length label ("L = 37.58 +ft") both used to render CENTRED on the cut line's own x position -- which, +at the cut's default midpoint, is the panel's horizontal centre, so both +labels sat directly ON the red cut line they were meant to be read beside. +Fixed two ways, permanently rather than only for the default midpoint: +`_cut_marker_primitives()` anchors the station label `'start'` and offsets +it `_CUT_LABEL_OFFSET_PX` (14px, comfortably past the 12px the tests check) +to the RIGHT of the tick, never centred on it; and the L label is drawn at +the panel's bottom-LEFT corner (`anchor: 'start'`, `x: ox`), not centred +under the panel, so it does not sit under the cut line at its most common +resting place (the midpoint) either. A full-width leader line is still +drawn under the panel so the label still reads as dimensioning the whole +span, only the TEXT moved. + +OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER for the cut line's own +pixel position -- read this before touching the WPF half. `outline()` places +the draggable line's tick at `x = geo['x0'] + cut_frac * (geo['x1'] - +geo['x0'])`, where `geo = outline_geometry(values, width_px, height_px)` +(the same call `outline()` itself makes, so the static tick this module +draws and the live draggable line the renderer draws can never disagree). +`outline_geometry()` needs no Revit, no WPF, and -- unlike every primitive +function in this module -- no `panelsketch` either: the box it returns +(`{'x0': left_px, 'x1': right_px, 'y0': top_px, 'y1': bottom_px}`, the +on-canvas panel rectangle's own left/right/top/bottom in pixels) depends +only on `width_px` / `height_px` and, for the vertical axis only, +`length_ft` / `width_ft` (falling back to a stand-in ratio, same as +`pa_extents._OUTLINE_ASPECT_DEFAULT`, when those are not entered yet -- see +`outline_geometry()`'s own docstring). **REPORT TO THE RENDERER OWNER:** +`PanelAnalysis.pushbutton/script.py`'s `_add_cutline()` currently computes +the draggable line's x as `x = frac * w` -- the bare canvas width, with NO +margin offset at all (and the drag handler's `_move()` reads it back the +same way, `frac2 = nx / entry['w']`). That was already wrong before this +change (the old `extents_outline()` picture was drawn inset from the +canvas edges too, just by different, letterboxed amounts), and it is wrong +after this change as well: the panel rectangle's left edge is at +`geo['x0']`, not `0`, and its right edge is at `geo['x1']`, not `w`. The fix +on that side is `x = geo['x0'] + frac * (geo['x1'] - geo['x0'])` for drawing +and `frac2 = (nx - geo['x0']) / (geo['x1'] - geo['x0'])` for the drag +readback, both calling `pa_sketch_extents.outline_geometry(values, w, h)` to +get `geo` -- this module exposes exactly that pair (`x0`, `x1`) for this +reason. Not fixed here: that file is WPF, outside this agent's ownership, +and the rule for this checkout is "no bridge, don't touch the renderer" -- +this docstring note IS the report. + +THE IMPORT IS GUARDED because `panelsketch.py` is a SIBLING agent's file. +`outline()` still needs three of its row/primitive helpers for openings +(`_openings_rows`, `_opening_rect_primitives`, `_gap_dimension_chain`) and +one for the title (`_title_primitive`) -- an import failure, or any of those +four missing, is "not available yet", never a crash: `outline()` still draws +the panel rectangle, the L/W labels and the cut marker (none of which need +`panelsketch` at all any more) and reports, in `problems`, that openings +could not be drawn. `section()` is unchanged and still hard-degrades to a +single centred note when `panelsketch` (or one of ITS several helpers) is +missing -- see that function's own docstring, carried over from before this +rewrite. + +MISSING GEOMETRY IS NEVER A BLANK CANVAS HERE, unlike `panelsketch`'s own +`sketch()` / `elevation()` / `extents_outline()` / `extents_section()`, +which return `([], problems)` when `length_ft` / `width_ft` / `thickness_in` +is missing and leave the caller to decide what an empty list means. This +module's two entry points are always on screen (the block contract has no +"don't show this yet" option), so a hard stop here is a single `text` +primitive that SAYS what is missing, in the middle of the canvas, rather +than nothing at all -- `_center_note()` below is the one place that shape is +built, so every caller of it looks the same. + +THE CUT FRACTION, `values['_cut_frac']`, is a plain string (or a number, +either is accepted) between 0 and 1 -- the fraction of `length_ft` the cut +line sits at, `0.5` (the panel's midpoint) when the form has never carried +one. It is form data like anything else the renderer's `current_values()` +reads back off the page -- this module never invents a default other than +0.5, and never rejects a value outside `[0, 1]`; it clamps, the same +"present but flagged" discipline `panelsketch.py`'s own support-clamping +uses, minus the flag: a cut line has no "outside the panel" meaning to flag +(dragging it past an end is simply dragging it to that end). +""" + +try: + from vibe import panelsketch +except ImportError: + panelsketch = None + + +# --------------------------------------------------------------- utilities + +def _cut_fraction(values): + """The cut line's position as a fraction of the length, in `[0, 1]`. + `values['_cut_frac']` missing, blank, or unreadable -> the midpoint, + 0.5 -- the same "waiting to be dragged" starting point the block + contract's own `cutline: {'default': 0.5}` declares to the renderer, so + the very first draw (before the user has ever touched the line) matches + what this function falls back to on every later bad value too.""" + values = values or {} + raw = values.get('_cut_frac') + if raw is None: + return 0.5 + try: + frac = float(raw) + except (TypeError, ValueError): + return 0.5 + if frac < 0.0: + return 0.0 + if frac > 1.0: + return 1.0 + return frac + + +def _center_note(width_px, height_px, text): + """The one-primitive fallback canvas: a centred `text` note, nothing + else. Used whenever this module cannot draw the real picture yet -- + see the module docstring's "MISSING GEOMETRY IS NEVER A BLANK CANVAS" + note. `width_px` / `height_px` of 0 (or unreadable) still produces a + primitive at (0, 0) rather than raising -- there is no invariant this + function needs to protect by refusing to run.""" + try: + w = float(width_px) + except (TypeError, ValueError): + w = 0.0 + try: + h = float(height_px) + except (TypeError, ValueError): + h = 0.0 + return [{'kind': 'text', 'x': w / 2.0, 'y': h / 2.0, 'text': text, + 'role': 'note', 'anchor': 'middle'}] + + +# ------------------------------------------------------- outline geometry +# +# Pure pixel math, no `panelsketch` -- see the module docstring's +# "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER" note. This is the +# ONE place the outline's on-canvas box is computed; `outline()` calls it +# for its own primitives so the static picture and the live draggable line +# can never disagree about where the panel rectangle actually is. + +_OUTLINE_MARGIN_FRACTION = 0.12 # of the shorter canvas side, same + # formula `panelsketch._margin()` / + # `pa_sketch_handling._margin()` use +_OUTLINE_MARGIN_MIN_PX = 8.0 +_OUTLINE_MARGIN_MAX_PX = 40.0 +_OUTLINE_LEFT_EXTRA_PX = 40.0 # on top of the base margin, left only -- + # room for the W dimension chain's line + # AND its rotated/stacked label (owner: + # "~60 px"; base margin (8-40) + this 40 + # lands in that neighbourhood for every + # canvas size this drawing actually gets) + +# Live-render finding, 2026-09-04 (coordinator): the panel rectangle's +# bottom edge sat on the canvas bottom and the "L = ..." label was clipped +# away entirely -- the container's height (from `pa_extents`'s `aspect`) had +# no room reserved for the title/cut-station label ABOVE the panel or the L +# label/dimension chain BELOW it. Top and bottom now carry their own FIXED +# pixel floors, independent of the shared base margin `m`: `_outline_margins` +# still shrinks them together with everything else only if the canvas is too +# small to hold even these floors (the tiny-canvas test cases), never +# otherwise. `pa_extents._OUTLINE_LABEL_ROWS_ASPECT` is the matching change +# on the container-sizing side -- the two are meant to be read together. +_OUTLINE_TOP_MIN_PX = 26.0 # title row + cut-station label +_OUTLINE_BOTTOM_MIN_PX = 34.0 # L label + its dimension/leader chain +_OUTLINE_ASPECT_DEFAULT = 0.3 # width_ft / length_ft stand-in before + # real geometry exists -- mirrors + # `pa_extents._OUTLINE_ASPECT_DEFAULT`, + # so the box this returns before the + # user has typed length/width is still a + # reasonable shape, not a fresh guess + # this module owns independently of that + # one. + + +def _outline_margins(width_px, height_px): + """(left, right, top, bottom) px margins around the outline's panel + rectangle. `left` is the widest -- it alone has to hold the W dimension + chain's line and its rotated/stacked label (see + `_OUTLINE_LEFT_EXTRA_PX`); `right` / `top` / `bottom` are the same base + margin `panelsketch._margin()` computes for every OTHER drawing in this + codebase (12% of the shorter canvas side, clamped 8..40px), holding one + thing each -- the cut station label and, when there are openings, their + along-length chain (top); the L label and its leader line (bottom); the + end of an opening's across-width chain (right). + + All four SHRINK TOGETHER, proportionally, whenever the canvas itself is + smaller than their combined budget -- capped at 90% of each axis so a + tiny canvas (the `(20, 15)` case + `tests/test_pa_sketch_extents.py::test_outline_stays_inside_box` + exercises) still leaves the panel a positive, drawable size instead of + the margins alone consuming the whole box.""" + width_px = float(width_px) + height_px = float(height_px) + + m = min(width_px, height_px) * _OUTLINE_MARGIN_FRACTION + if m < _OUTLINE_MARGIN_MIN_PX: + m = _OUTLINE_MARGIN_MIN_PX + if m > _OUTLINE_MARGIN_MAX_PX: + m = _OUTLINE_MARGIN_MAX_PX + + left, right = m + _OUTLINE_LEFT_EXTRA_PX, m + top = max(m, _OUTLINE_TOP_MIN_PX) + bottom = max(m, _OUTLINE_BOTTOM_MIN_PX) + + h_budget = max(width_px * 0.9, 4.0) + if left + right > h_budget: + scale = h_budget / (left + right) + left *= scale + right *= scale + + v_budget = max(height_px * 0.9, 4.0) + if top + bottom > v_budget: + scale = v_budget / (top + bottom) + top *= scale + bottom *= scale + + return left, right, top, bottom + + +def outline_geometry(values, width_px, height_px): + """`{'x0': left_px, 'x1': right_px, 'y0': top_px, 'y1': bottom_px}` -- + the pixel box the outline's panel rectangle occupies (or WILL occupy, + even before `length_ft` / `width_ft` are entered -- see below). This is + the renderer's half of the cut-line contract: the draggable tick's x is + `x0 + cut_frac * (x1 - x0)`, exactly what `outline()`'s own + `_cut_marker_primitives()` computes, so the two can never disagree. See + the module docstring's "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE + RENDERER" note for the exact formula the WPF side needs and the report + on what it currently does instead. + + `x0` / `x1` depend only on `width_px` and the margins `_outline_margins` + computes from `width_px` / `height_px` -- the panel ALWAYS spans the + full available width (see the module docstring's delegation note), so + they need no `length_ft` / `width_ft` at all and are just as valid + before the user has entered any geometry as after. `y0` / `y1` are + scale-true to `width_ft / length_ft` when both are entered and + positive; otherwise `_OUTLINE_ASPECT_DEFAULT` stands in, the same + "still a reasonable shape, never a guess this function owns" rule + `_center_note()`'s callers use for the rest of this module. + + THE DRAWN HEIGHT NEVER OVERFLOWS THE CANVAS (2026-09-04 live-render + fix): `draw_h` is `min(avail_v, draw_w * width_ft / length_ft)`, where + `avail_v` is whatever is left of `height_px` after `_outline_margins`'s + top/bottom floors -- so the panel rectangle's bottom edge can never be + pinned to the canvas bottom (which used to crop the L label off the + canvas entirely; see `pa_extents._OUTLINE_LABEL_ROWS_ASPECT` for the + matching container-sizing fix, meant to keep the cap from actually + biting in the normal case). When the true-to-scale height DOES fit + (`avail_v` has slack), the panel is still centred vertically inside + that slack, same as before this fix. This is the one function that + computes the box; `outline()` calls it for its own primitives, so a + caller can trust this return matches what actually got drawn. + + Never raises -- a `width_px` / `height_px` that will not parse as a + number is read as `0.0`, the same as `_center_note()` does.""" + values = values or {} + try: + width_px = float(width_px) + except (TypeError, ValueError): + width_px = 0.0 + try: + height_px = float(height_px) + except (TypeError, ValueError): + height_px = 0.0 + + left, right, top, bottom = _outline_margins(width_px, height_px) + x0 = left + x1 = max(width_px - right, x0) + draw_w = max(x1 - x0, 0.0) + + length_ft = _safe_num(values.get('length_ft')) + width_ft = _safe_num(values.get('width_ft')) + if length_ft is not None and length_ft > 0 and width_ft is not None \ + and width_ft > 0: + ratio = width_ft / length_ft + else: + ratio = _OUTLINE_ASPECT_DEFAULT + + avail_v = max(height_px - top - bottom, 0.0) + draw_h = min(avail_v, draw_w * ratio) + y0 = top + max(0.0, (avail_v - draw_h) / 2.0) + y1 = y0 + draw_h + + return {'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1} + + +_CUT_TICK_PX = 8.0 +_CUT_LABEL_OFFSET_PX = 14.0 # > the 12px the tests check, to the RIGHT of + # the tick -- see the module docstring's + # "WHERE THE CUT AND L LABELS SIT" note. + + +def _cut_marker_primitives(geo, length_ft, cut_frac): + """The tick + station-label primitives for the cut line, at its true + proportional position along the outline's own on-canvas box (`geo`, + from `outline_geometry()`). The renderer draws the DRAGGABLE line itself + (the block contract's `cutline` key is what tells it to); these three + primitives are what make the static PNG/host picture -- and this + module's own tests -- show the same cut position without a live drag + handler. `role: 'cutline-tick'` / `'cutline-label'` names it for the + renderer to colour red (small red tick, owner ruling, 2026-09-04) the + same way `role: 'flagged'` primitives elsewhere in `panelsketch` are + coloured by role rather than by an explicit colour key on the + primitive. The label is anchored `'start'` and offset + `_CUT_LABEL_OFFSET_PX` to the right of the tick -- NEVER centred on it, + see the module docstring.""" + ox, oy = geo['x0'], geo['y0'] + draw_w = geo['x1'] - geo['x0'] + draw_h = geo['y1'] - geo['y0'] + cx = ox + cut_frac * draw_w + station_ft = cut_frac * length_ft + tick = _CUT_TICK_PX + # Floor at 4.0, not 0.0 -- the coordinator's clearance rule (every + # primitive's y within [0, height_px] with >= 4px of daylight from + # either edge), same reasoning `_outline_margins`'s `_OUTLINE_TOP_MIN_PX` + # exists for. `oy` is never less than that margin, so this floor is a + # backstop, not the normal path. + label_y = max(oy - tick - 14.0, 4.0) + return [ + {'kind': 'line', 'x1': cx, 'y1': max(oy - tick, 0.0), 'x2': cx, + 'y2': oy, 'role': 'cutline-tick'}, + {'kind': 'line', 'x1': cx, 'y1': oy + draw_h, 'x2': cx, + 'y2': oy + draw_h + tick, 'role': 'cutline-tick'}, + {'kind': 'text', 'x': cx + _CUT_LABEL_OFFSET_PX, 'y': label_y, + 'text': 'cut @ {0:.1f} ft'.format(station_ft), + 'role': 'cutline-label', 'anchor': 'start', 'font_size': 11}, + ] + + +# --------------------------------------------------------------------- API + +def outline(values, width_px, height_px): + """(primitives, problems) -- the panel outline: the panel rectangle to + scale (spanning the FULL available drawing width -- see the module + docstring's delegation note), the overall W dimension on the left, the + overall L label at the panel's bottom-left, openings/reveals/solid-zones + to scale with their own locating dimension chains (along-length from the + top, across-width from the left), and the cut line's tick + station + label on top of it. Never raises, and never returns an empty canvas. + `values` is the whole form's dict, read the same way every other drawing + function in this codebase reads it (`.get()` only, never mutated).""" + values = values or {} + problems = [] + + length_ft = _safe_num(values.get('length_ft')) + width_ft = _safe_num(values.get('width_ft')) + if length_ft is None or length_ft <= 0: + problems.append( + 'Length (ft) is missing or not a positive number; the outline ' + 'is waiting on it.') + if width_ft is None or width_ft <= 0: + problems.append( + 'Width (ft) is missing or not a positive number; the outline ' + 'is waiting on it.') + if problems: + return _center_note(width_px, height_px, problems[0]), problems + + geo = outline_geometry(values, width_px, height_px) + ox, oy = geo['x0'], geo['y0'] + draw_w = geo['x1'] - geo['x0'] + draw_h = geo['y1'] - geo['y0'] + + primitives = [{'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, + 'h': draw_h, 'role': 'panel'}] + + if panelsketch is not None: + title_fn = getattr(panelsketch, '_title_primitive', None) + if title_fn is not None: + try: + title = title_fn(values.get('mark'), values.get('design_number')) + except Exception: + title = None + if title: + primitives.append(title) + + # ---- W dimension chain, left of the panel, rotated label (font >= 12) + dim_x = max(ox - 20.0, 2.0) + primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy, + 'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'}) + primitives.append({'kind': 'text', 'x': max(dim_x - 14.0, 2.0), + 'y': oy + draw_h / 2.0, + 'text': 'W = {0:.2f} ft'.format(width_ft), + 'role': 'dimension', 'anchor': 'middle', + 'orientation': 'vertical', 'font_size': 12}) + + # ---- L label, bottom-LEFT under the panel (never centred -- see the + # module docstring's "WHERE THE CUT AND L LABELS SIT" note), with a + # full-width leader line so it still reads as dimensioning the whole + # span even though the text itself sits at the left corner. + leader_y = oy + draw_h + 6.0 + primitives.append({'kind': 'line', 'x1': ox, 'y1': leader_y, + 'x2': ox + draw_w, 'y2': leader_y, + 'role': 'dimension'}) + primitives.append({'kind': 'text', 'x': ox, 'y': leader_y + 8.0, + 'text': 'L = {0:.2f} ft'.format(length_ft), + 'role': 'dimension', 'anchor': 'start', + 'font_size': 12}) + + # ---- openings/reveals/solid-zones, to scale, plus locating chains -- + # reused row-geometry from `panelsketch`, see the module docstring. + if panelsketch is None: + problems.append( + 'vibe.panelsketch could not be imported; openings/reveals/' + 'solid zones cannot be drawn on the outline.') + else: + rows_fn = getattr(panelsketch, '_openings_rows', None) + rect_fn = getattr(panelsketch, '_opening_rect_primitives', None) + chain_fn = getattr(panelsketch, '_gap_dimension_chain', None) + if None in (rows_fn, rect_fn, chain_fn): + problems.append( + 'vibe.panelsketch is missing one of _openings_rows / ' + '_opening_rect_primitives / _gap_dimension_chain; openings ' + 'cannot be drawn on the outline.') + else: + try: + rows, row_problems = rows_fn(values, length_ft, width_ft, None) + except Exception as exc: # never take the page down + rows, row_problems = None, [ + 'panelsketch openings/reveals/solid zones raised: ' + '{0}'.format(exc)] + problems.extend(row_problems) + if rows: + try: + opening_prims, opening_problems = rect_fn( + rows, length_ft, width_ft, ox, oy, draw_w, draw_h) + except Exception as exc: + opening_prims, opening_problems = [], [ + 'panelsketch opening rectangles raised: ' + '{0}'.format(exc)] + primitives.extend(opening_prims) + problems.extend(opening_problems) + + along_ranges = [r['along'] for r in rows + if r['vert'] is not None] + vert_ranges = [r['vert'] for r in rows + if r['vert'] is not None] + if along_ranges: + # from the START, along the TOP -- above the panel, not + # below it, so the bottom stays clear for the L label. + # Sits BELOW the cut-station label (closer to the panel + # edge) with its own 4px floor, same reasoning as + # `_cut_marker_primitives`'s `label_y`. + top_chain_y = max(oy - 18.0, 4.0) + primitives.extend(chain_fn( + along_ranges, length_ft, + lambda lo: (ox + (lo / length_ft) * draw_w, + top_chain_y), + lambda hi: (ox + (hi / length_ft) * draw_w, + top_chain_y), + '{0:.2f} ft')) + if vert_ranges: + # from the LEFT, along the left side -- further out than + # the W dimension chain itself. + chain_x = max(dim_x - 30.0, 2.0) + primitives.extend(chain_fn( + vert_ranges, width_ft, + lambda lo: (chain_x, + oy + draw_h - (lo / width_ft) * draw_h), + lambda hi: (chain_x, + oy + draw_h - (hi / width_ft) * draw_h), + '{0:.2f} ft')) + elif rows is None: + openings_text = values.get('openings') + if openings_text is not None and str(openings_text).strip(): + problems.append( + 'Openings/reveals were entered as plain text, not ' + 'the structured rows this drawing needs to locate ' + 'them: {0!r}'.format(str(openings_text).strip())) + + # ---- the cut line's tick + station label, on top of everything else + primitives = primitives + _cut_marker_primitives( + geo, length_ft, _cut_fraction(values)) + + clamp = getattr(panelsketch, '_clamp_primitive', None) \ + if panelsketch is not None else None + if clamp is not None: + primitives = [clamp(p, float(width_px), float(height_px)) + for p in primitives] + return primitives, problems + + +def section(values, width_px, height_px): + """(primitives, problems) -- the cross-section AT the cut line's current + station: a full width_ft x thickness_in rectangle, a Sandwich section's + wythe/insulation layers (same layering `panelsketch.extents_section()` + draws), any `Opening` row whose along-length extent contains the cut + station drawn as a full-depth gap at its across-width position, any + `Reveal` row at the station drawn as a partial-depth notch cut in from + its recorded face, dimension labels (`t = ... in`, `W = ... ft`), the + station itself (below the section, not on it), and the net concrete area + at that station in square inches. `Solid Zone` rows are named in + `problems` when they fall at the station but never drawn as a gap -- a + solid zone removes nothing, by definition, the same rule `panelsketch.py`'s + own cross-section and plan functions already follow. UNCHANGED by the + 2026-09-04 outline rewrite (see the module docstring) -- this drawing + already fills its own width and its station label already sits below the + section, not on it. Never raises, never returns an empty canvas.""" + values = values or {} + + if panelsketch is None: + return (_center_note( + width_px, height_px, + 'panelsketch is not available; the cross-section cannot be ' + 'drawn.'), + ["vibe.panelsketch could not be imported; the cross-section " + "is waiting on it."]) + + width_ft = _safe_num(values.get('width_ft')) + thickness_in = _safe_num(values.get('thickness_in')) + problems = [] + if width_ft is None or width_ft <= 0: + problems.append( + 'Width (ft) is missing or not a positive number; the ' + 'cross-section is waiting on it.') + if thickness_in is None or thickness_in <= 0: + problems.append( + 'Thickness (in) is missing or not a positive number; the ' + 'cross-section is waiting on it.') + if problems: + return _center_note(width_px, height_px, problems[0]), problems + + thickness_ft = thickness_in / 12.0 + length_ft = _safe_num(values.get('length_ft')) + cut_frac = _cut_fraction(values) + station_ft = cut_frac * length_ft if length_ft is not None \ + and length_ft > 0 else None + + margin_fn = getattr(panelsketch, '_margin', None) + letterbox_fn = getattr(panelsketch, '_letterbox', None) + clamp_val = getattr(panelsketch, '_clamp', None) + clamp_prim = getattr(panelsketch, '_clamp_primitive', None) + parse_row = getattr(panelsketch, '_parse_opening_row', None) + if None in (margin_fn, letterbox_fn, clamp_val, clamp_prim, parse_row): + return (_center_note( + width_px, height_px, + 'panelsketch is missing a helper this drawing needs; the ' + 'cross-section cannot be drawn.'), + ['vibe.panelsketch is missing one of _margin / _letterbox / ' + '_clamp / _clamp_primitive / _parse_opening_row.']) + + primitives = [] + margin = margin_fn(width_px, height_px) + avail_w = max(float(width_px) - 2.0 * margin, 1.0) + avail_h = max(float(height_px) - 2.0 * margin, 1.0) + + # Room outside the outline: the thickness dimension to the left, a + # Sandwich section's wythe/insulation labels to the right -- the same + # split `panelsketch.extents_section()` uses for this identical picture. + left_room = 40.0 + right_room = 90.0 + draw_w, draw_h = letterbox_fn( + width_ft, thickness_ft, + max(avail_w - left_room - right_room, 1.0), avail_h) + ox = margin + left_room + (avail_w - left_room - right_room - draw_w) / 2.0 + oy = margin + (avail_h - draw_h) / 2.0 + + primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, + 'h': draw_h, 'role': 'panel-section'}) + + # ---- Sandwich wythes / insulation -- same rule as `panelsketch. + # extents_section()` / `panelsketch.section()`: only drawn as layers + # when all three thicknesses are entered AND add up to the panel + # thickness within 1/8 in; otherwise reported and left as one outline. + section_type = str(values.get('section_type') or '').strip().lower() + if section_type == 'sandwich': + top_in = _safe_num(values.get('wythe_top_in')) + ins_in = _safe_num(values.get('insulation_in')) + bot_in = _safe_num(values.get('wythe_bottom_in')) + layers = (top_in, ins_in, bot_in) + if any(v is None or v <= 0 for v in layers): + problems.append( + 'Sandwich section: the wythe / insulation / wythe ' + 'thicknesses are not all entered; the cross-section is ' + 'drawn as one outline.') + elif abs(sum(layers) - thickness_in) > 0.125: + problems.append( + 'Sandwich section: {0:.2f} + {1:.2f} + {2:.2f} in do not ' + 'add up to the {3:.2f} in thickness; drawn as one outline.' + .format(top_in, ins_in, bot_in, thickness_in)) + else: + px_per_in = draw_h / thickness_in + y_cursor = oy + draw_h + for name, layer_in, role in (('wythe', bot_in, 'wythe'), + ('insulation', ins_in, 'insulation'), + ('wythe', top_in, 'wythe')): + layer_h = layer_in * px_per_in + y_cursor -= layer_h + primitives.append({'kind': 'rect', 'x': ox, 'y': y_cursor, + 'w': draw_w, 'h': layer_h, 'role': role}) + primitives.append({'kind': 'text', 'x': ox + draw_w + 6.0, + 'y': y_cursor + layer_h / 2.0 - 6.0, + 'text': '{0} {1:.2f} in' + .format(name, layer_in), + 'role': 'layer-label'}) + + # ---- openings / reveals AT the cut station + removed_in2 = 0.0 + if station_ft is None: + problems.append( + 'Length (ft) is not entered; openings and reveals cannot be ' + 'located along the panel to know which fall at this cut ' + 'station, so none are drawn here.') + else: + raw_rows = values.get('openings_rows') + if isinstance(raw_rows, list): + for i, item in enumerate(raw_rows): + row, row_problems = parse_row( + item, i + 1, length_ft, width_ft, thickness_ft) + problems.extend(row_problems) + if row is None: + continue + a0, a1 = row['along'] + if not (a0 <= station_ft <= a1): + continue # this row does not reach the cut station + if row['type'] == 'Solid Zone': + problems.append( + 'Solid Zone row {0} ({1}) is at the cut station but ' + 'removes nothing, by definition; not drawn as a ' + 'gap.'.format(row['index'], row['label'] or + 'unlabeled')) + continue + if row['vert'] is None: + problems.append( + '{0} row {1} ({2}) is at the cut station but has ' + 'no across-width position/extent entered; not ' + 'drawn on the cross-section.' + .format(row['type'], row['index'], + row['label'] or 'unlabeled')) + continue + v0, v1 = row['vert'] + cv0 = clamp_val(v0, 0.0, width_ft) + cv1 = clamp_val(v1, 0.0, width_ft) + if cv1 <= cv0: + continue + gx = ox + (cv0 / width_ft) * draw_w + gw = ((cv1 - cv0) / width_ft) * draw_w + + if row['type'] == 'Opening': + # through -- the full thickness is gone over this width + primitives.append({'kind': 'rect', 'x': gx, 'y': oy, + 'w': gw, 'h': draw_h, + 'role': 'opening'}) + removed_in2 += (cv1 - cv0) * 12.0 * thickness_in + else: # Reveal -- partial-depth, from the recorded face. + # `row['cross']` is the through-thickness axis + # `panelsketch._parse_opening_row()` resolves from + # `pos_cross` / `width` / `pos_cross_from` -- ALWAYS as + # an absolute (lo, hi) measured from the near/"left" + # face at 0 up to the far/"right" face at thickness_ft, + # regardless of which face it was typed from (that is + # what `_resolve_axis_range()` normalises). This module + # draws cross=0 at the TOP of the section rectangle and + # cross=thickness_ft at the bottom -- an arbitrary but + # consistent choice, inherited from that function's own + # "medium confidence" axis call, not re-litigated here. + if row['cross'] is None: + problems.append( + '{0} row {1} ({2}) is at the cut station but ' + 'has no through-thickness position/depth ' + 'entered; not drawn on the cross-section.' + .format(row['type'], row['index'], + row['label'] or 'unlabeled')) + continue + c0, c1 = row['cross'] + cc0 = clamp_val(c0, 0.0, thickness_ft) + cc1 = clamp_val(c1, 0.0, thickness_ft) + if cc1 <= cc0: + continue + gy = oy + (cc0 / thickness_ft) * draw_h + gh = ((cc1 - cc0) / thickness_ft) * draw_h + primitives.append({'kind': 'rect', 'x': gx, 'y': gy, + 'w': gw, 'h': gh, 'role': 'reveal'}) + removed_in2 += (cv1 - cv0) * 12.0 * (cc1 - cc0) * 12.0 + else: + openings_text = values.get('openings') + if openings_text is not None and str(openings_text).strip(): + problems.append( + 'Openings/reveals were entered as plain text, not the ' + 'structured rows this drawing needs to locate them at ' + 'the cut station.') + + # ---- dimensions and labels, OUTSIDE the outline + dim_x = ox - 20.0 + primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy, + 'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'}) + primitives.append({'kind': 'text', 'x': dim_x - 12.0, + 'y': oy + draw_h / 2.0, + 'text': 't = {0:.2f} in'.format(thickness_in), + 'role': 'dimension', 'anchor': 'middle', + 'orientation': 'vertical', 'font_size': 12}) + primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, + 'y': max(oy - 16.0, 0.0), + 'text': 'W = {0:.2f} ft'.format(width_ft), + 'role': 'dimension', 'anchor': 'middle', + 'font_size': 12}) + + # ---- the cut station label, BELOW the section -- never on it, and + # never on the outline's own cut line either (this is a different + # drawing, drawn once per station rather than dragged). + station_text = ('cut @ {0:.1f} ft'.format(station_ft) + if station_ft is not None + else 'cut station: length (ft) not entered') + primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, + 'y': oy + draw_h + 16.0, 'text': station_text, + 'role': 'cutline-label', 'anchor': 'middle', + 'font_size': 11}) + + gross_in2 = width_ft * 12.0 * thickness_in + net_in2 = gross_in2 - removed_in2 + primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, + 'y': oy + draw_h + 32.0, + 'text': u'Net area = {0:.1f} in²' + .format(net_in2), + 'role': 'annotation', 'anchor': 'middle', + 'font_size': 11}) + + primitives = [clamp_prim(p, float(width_px), float(height_px)) + for p in primitives] + return primitives, problems + + +# `panelsketch._num` is the same "a float, or None" reader this module would +# otherwise have to duplicate -- reused rather than re-derived, same as +# `_parse_opening_row` above, but resolved lazily (`_safe_num`, not a bare +# `panelsketch._num` alias) so a `panelsketch` import that is present but +# missing `_num` (mid-rewrite) degrades to a local copy instead of an +# `AttributeError` neither `outline()` nor `section()` would otherwise catch +# before it reached the caller. + +def _safe_num(value): + fn = getattr(panelsketch, '_num', None) if panelsketch is not None \ + else None + if fn is not None: + return fn(value) + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------- real-unit geometry (ft) +# +# Added for the HTML-page rewrite (`vibe.pa_pagespec`): the two functions +# below are UNRELATED to `outline_geometry()` above despite the similar name +# -- that one returns a PIXEL box for one canvas size, purely so the WPF +# cutline and `outline()`'s own static tick agree. These return real feet / +# inches, independent of any canvas size, Revit, or WPF, so a page with +# neither can lay out its own picture and intersect its own cut line exactly +# the way `section()` does today. `outline_geometry_ft()` reuses +# `panelsketch._openings_rows()` for the openings list -- the same row parse +# `outline()` itself draws from (see the module docstring's "THE IMPORT IS +# GUARDED" note for why every call into `panelsketch` here is via `getattr`, +# never a bare attribute access) -- so the JSON and the still-live WPF +# picture can never disagree about what an opening row means. + +_FALLBACK_LENGTH_FT = 1.0 +_FALLBACK_WIDTH_FT = 0.3 + + +def outline_geometry_ft(values): + """Real-world panel outline + openings, in feet -- independent of any + canvas size. Never raises, and -- matching this module's own "MISSING + GEOMETRY IS NEVER A BLANK CANVAS" rule, applied to DATA rather than a + drawing -- never returns a zero-area outline: `length_ft` / `width_ft` + missing or non-positive falls back to a small stand-in rectangle, + flagged `'degraded': True` and named in `problems`, so a page opened + before either is typed still has a real (if placeholder) shape to draw. + + {'length_ft': float, 'width_ft': float, 'thickness_in': float|None, + 'degraded': bool, + 'outline': [[0,0], [L,0], [L,W], [0,W]], # ft, closed implied + 'openings': [{'type', 'role', 'label', 'index', + 'along': [lo, hi] | None, # ft, length axis + 'vert': [lo, hi] | None, # ft, width axis + 'cross': [lo, hi] | None}, # ft, thru-thickness + ...], + 'problems': [str, ...]} + + `values` is read only, via `.get(...)`, and never mutated. + """ + values = values or {} + problems = [] + length_ft = _safe_num(values.get('length_ft')) + width_ft = _safe_num(values.get('width_ft')) + thickness_in = _safe_num(values.get('thickness_in')) + degraded = False + if length_ft is None or length_ft <= 0: + problems.append( + "'length_ft' is missing or not a positive number; showing a " + 'placeholder outline.') + length_ft = _FALLBACK_LENGTH_FT + degraded = True + if width_ft is None or width_ft <= 0: + problems.append( + "'width_ft' is missing or not a positive number; showing a " + 'placeholder outline.') + width_ft = _FALLBACK_WIDTH_FT + degraded = True + + rows = [] + if panelsketch is None: + if isinstance(values.get('openings_rows'), list) and \ + values.get('openings_rows'): + problems.append( + 'vibe.panelsketch could not be imported; openings could ' + 'not be resolved into geometry.') + else: + rows_fn = getattr(panelsketch, '_openings_rows', None) + if rows_fn is None: + problems.append( + 'vibe.panelsketch is missing _openings_rows; openings ' + 'could not be resolved into geometry.') + else: + try: + raw_rows, row_problems = rows_fn(values, length_ft, + width_ft, None) + except Exception as exc: # never take the page down + raw_rows, row_problems = None, [ + 'openings could not be read: {0!r}'.format(exc)] + problems.extend(row_problems) + for r in (raw_rows or []): + rows.append({ + 'type': r.get('type'), 'role': r.get('role'), + 'label': r.get('label'), 'index': r.get('index'), + 'along': list(r['along']) if r.get('along') else None, + 'vert': list(r['vert']) if r.get('vert') else None, + 'cross': list(r['cross']) if r.get('cross') else None, + }) + + return { + 'length_ft': length_ft, 'width_ft': width_ft, + 'thickness_in': thickness_in, 'degraded': degraded, + 'outline': [[0.0, 0.0], [length_ft, 0.0], + [length_ft, width_ft], [0.0, width_ft]], + 'openings': rows, 'problems': problems, + } + + +def _clamp_num(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + + +def _merge_ranges(ranges): + """Sorted, overlap-merged `[lo, hi)` pairs -- shared by `section_at()` + so two opening rows that overlap at one station read as one void.""" + clean = sorted((lo, hi) for lo, hi in ranges if hi > lo) + merged = [] + for lo, hi in clean: + if merged and lo <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], hi)) + else: + merged.append((lo, hi)) + return merged + + +def section_at(geometry, x_ft): + """The cross-section profile at along-length station `x_ft`, computed + from an `outline_geometry_ft()`-shaped dict ALONE -- no Revit, no + canvas, no `values`. THIS IS THE ALGORITHM THE HTML PAGE REIMPLEMENTS IN + JAVASCRIPT (`docs/pa-web-spec.md`'s "section-at-cut" section carries the + same steps in prose, and the two must be kept in step -- not just + agreeing on the panels this module's own tests happen to cover): + + 1. Clamp `x_ft` to `[0, geometry['length_ft']]`. + 2. `voids` = the WIDTH-axis ranges (`[lo, hi]`, ft from the left) open + at this station: every opening row whose `type` is 'Opening' (a + Reveal or Solid Zone does not go through -- matches `section()`'s + own rule and `panelanalysisform`'s "'Opening' rows always go full + depth" note) whose `along` range covers `x_ft`, AND whose `vert` + range is known -- a row with `vert: None` cannot be localised + across the width and is reported in `problems` instead of guessed. + 3. Merge overlapping/touching ranges and sort ascending, so two + openings that overlap at this station read as one void, never a + double-counted pair. + + Returns `{'x_ft', 'width_ft', 'thickness_in', 'voids': [[lo, hi], ...], + 'problems': [...]}`. Never raises: a malformed `geometry` (missing keys, + wrong types) degrades to the widest reasonable stand-in -- this runs on + every cutline drag, and a redrawn slice is not the place to surface a + Python-shaped error to a viewer. + """ + problems = [] + geometry = geometry if isinstance(geometry, dict) else {} + length_ft = _safe_num(geometry.get('length_ft')) + if length_ft is None or length_ft <= 0: + length_ft = _FALLBACK_LENGTH_FT + width_ft = _safe_num(geometry.get('width_ft')) + if width_ft is None or width_ft <= 0: + width_ft = _FALLBACK_WIDTH_FT + thickness_in = _safe_num(geometry.get('thickness_in')) + + x = _safe_num(x_ft) + if x is None: + problems.append('x_ft is not a number; using the panel midpoint.') + x = length_ft / 2.0 + x = _clamp_num(x, 0.0, length_ft) + + raw_ranges = [] + for row in (geometry.get('openings') or []): + if not isinstance(row, dict): + continue + type_ = str(row.get('type') or 'opening').strip().lower() + if type_ not in ('opening', ''): + continue + along = row.get('along') + if not along or len(along) != 2 or along[0] is None or \ + along[1] is None: + continue + lo, hi = along + if not (lo <= x <= hi): + continue + vert = row.get('vert') + if not vert or len(vert) != 2 or vert[0] is None or vert[1] is None: + problems.append( + "opening {0!r} crosses this station but has no across-" + 'width position entered; not punched into the section.' + .format(row.get('label') or row.get('index'))) + continue + raw_ranges.append((float(vert[0]), float(vert[1]))) + + voids = _merge_ranges(raw_ranges) + return {'x_ft': x, 'width_ft': width_ft, 'thickness_in': thickness_in, + 'voids': [list(v) for v in voids], 'problems': problems} diff --git a/WillTools.extension/lib/vibe/pa_sketch_freebody.py b/WillTools.extension/lib/vibe/pa_sketch_freebody.py new file mode 100644 index 0000000..922b78c --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_sketch_freebody.py @@ -0,0 +1,920 @@ +# -*- coding: utf-8 -*- +"""The Panel Analysis free-body sketch -- pure geometry, both Pythons, no +Revit, no WPF. + +WHY THIS EXISTS: the owner's ruling, 2026-09-04, on the Structural Model +tab's headless render: "restrain & support 的那个示意图,本质上是一个 +freebody,用一条直线来代替 member,同时显示 restrain 的位置,以及 loading" -- +the diagram this section needs is a FREE-BODY, not the four-panel elevation +`panelsketch.elevation()` used to draw here (front + side + section +properties + cross section, three tiny sub-drawings and a block of text +fighting for the same canvas -- the owner's other complaint the same day). +One horizontal line stands in for the member; the panel's elevation range +fills the page width; restraints sit at their true positions along it; +loads are drawn ON it, not beside it. + +THE SEAM, same discipline as `panelsketch.py`: this module's job stops at a +flat list of drawing PRIMITIVES in a normalised, already-in-pixels +coordinate space. The WPF `Canvas` painting is the button's job, not this +module's -- see `pa_structural.py`'s `drawing` block, which only NAMES this +module and its `freebody` entry point; the renderer looks the function up +by name and calls it. Standard library only (`re` is not even needed here), +runnable under IronPython 2.7 inside Revit and plain CPython on the host +(see `tests/conftest.py`): no f-strings, no `pathlib`, no `dataclasses`, no +typing, no `enum`. + +THE INPUT is `values`, the SAME dict `pa_structural.page()` reads from the +form. Two plain fields anchor the horizontal axis: + + bottom_elevation_ft / top_elevation_ft + the elevation range the free-body's full width represents -- x maps + elevation bottom -> top, left -> right. NOT a hard stop the way + `panelsketch.sketch()`'s length/width are: a free-body with no + elevation range typed in yet still has to draw a member line (this + is the diagram restraints and loads get ADDED to as the form fills + in, not a diagram that appears once the form is finished), so a + missing or unreadable range falls back to whatever span the + restraint/load/wind rows already cover, and finally to a bare + 0.0-1.0 ft placeholder -- reported in `problems` either way, never + silently accepted as "0 to 1 is the real answer". + +The three row-tables read here are the CONTRACT `pa_structural.py`'s module +docstring documents: one row lives at `'
..'`, and +`'
..present' == 'yes'` marks the row as existing (`row_ids` / +`row_key` below implement the same key arithmetic `pa_structural.py` does, +independently -- this module does not import `pa_structural` or +`panelanalysisform`, the same leaf-module discipline `panelsketch.py`'s own +docstring explains: a module three layers from Revit should not be breakable +by a change to a sibling's parsing rules). + + restraint -> elevation_ft (num), type (one of 'Pin' / 'Fixed' / + 'Free' / 'Floor tie', case-insensitive, blank treated as + unrecognised), gravity / thermal (unused here -- nothing + in the free-body content the owner specified draws them), + stiffness_kin (unused here). + conc_load -> label (text, defaults to 'P' by draw order when + blank), elevation_ft (num, required to place the row -- + a row missing it is skipped and reported), offset_in + (num; sign picks which face the arrow is drawn on -- + >= 0 or blank draws on the outside face, above the + member line; < 0 draws on the inside face, below), + dead_k / live_k (num, shown as '--' when blank -- never + defaulted to 0, which is a real, asserted value). + wind_zone -> label (as above, defaults to 'W'), from_elev_ft / + to_elev_ft (num, both required to place the row; drawn + swapped-and-flagged if `from` sits above `to`), + width_ft (num, shown as typed, '--' when blank), + pressure_psf / suction_psf (num, '--' when blank). + +MISSING/UNREADABLE INPUT IS REPORTED, NEVER GUESSED. A row missing what it +needs to be PLACED (an elevation, a from/to pair) is skipped and named in +`problems`; a row missing what it needs to be LABELLED (dead/live/pressure/ +suction/width) is still drawn, with '--' standing in for the missing +number -- inventing a load magnitude nobody typed would be lying with a +picture instead of a number, which `panelsketch.py`'s own docstring already +rules out for openings, and the same rule applies here. + +PRIMITIVES are plain dicts, coordinates already in pixels, origin top-left, +y increasing downward (WPF's `Canvas` convention), every one of them clamped +so it lies inside `[0, width_px] x [0, height_px]`. The vocabulary is the +same one `panelsketch.py`'s `_clamp_primitive` and the button's +`_draw_primitive` already speak, restricted here to the five kinds this +drawing actually needs: + + {'kind': 'line', 'x1','y1','x2','y2', 'role': ...} + the member line itself (role 'member', carrying a 'width': 3.0 hint + -- see MEMBER LINE below); its two end ticks (role + 'member-end-tick'); restraint hatch ticks; load and wind-zone arrow + shafts; the dimension chain between restraints; the wind-zone + extent bracket. + {'kind': 'rect', 'x','y','w','h', 'role': 'wind-zone-band', 'flagged': bool} + a thin band marking a wind zone's elevation extent, behind its row + of small arrows -- only drawn when the zone has a pressure or a + suction typed in (see WIND below). + {'kind': 'text', 'x','y', 'text', 'role': ..., 'anchor': 'middle'|'start'} + every label -- elevation end labels, restraint elevation/type, + load/wind-zone labels, dimension-chain spacings, and the one + "waiting on input" note this module ever draws on the canvas + itself (see NEVER RAISES below). `anchor` is 'middle' for labels + centred on a point (restraints, wind zones, dimension spacings, + elevation ends) and 'start' for a load label, which prints to the + RIGHT of its arrow rather than centred on it. + {'kind': 'circle', 'cx','cy','r', 'role': ..., 'flagged': bool} + a restraint whose type is 'Free' or unrecognised -- there is no + restraint SYMBOL to draw (a Free restraint restrains nothing), but + its position is still real data, marked with a small dot rather + than invented as a triangle or hatch it is not. + {'kind': 'polygon', 'points': [(x,y), ...], 'role': ..., 'flagged': bool} + the Pin / Floor-tie restraint triangle, and every arrowhead (loads, + wind-zone arrows). + +MEMBER LINE: full canvas width less the margin (see `_margin` -- floored at +70 px specifically so the elevation end labels have room and never clip), +drawn 3 px dark with a short tick at each end. The end labels sit just +above those ticks, at the member's own end x-coordinates rather than +centred on the canvas edge -- centring "Bottom: -2.00 ft" ON the margin +boundary used to push half the text off the canvas (reported as "Bottom:" +rendering as "ottom:"); anchoring it at the member end and trusting the +70 px margin to hold the whole label is what fixes it. + +`flagged` marks a primitive drawn from data that needed clamping or +correcting to be drawn at all (a restraint outside the elevation range, a +wind zone with `from` above `to`) -- present and visibly wrong, never +silently dropped, the same convention `panelsketch.py` uses for an +out-of-range support. + +RESTRAINT SYMBOLS, coordinator follow-up on the live render, 2026-09-04: +16 px tall (`_RESTRAINT_TRI_DROP` / `_RESTRAINT_FREE_R`), apex/top touching +the member line, dropping below it. Every one of the three symbol kinds +(Pin/Floor-tie `polygon`, Fixed hatch `line`s, Free/unrecognised `circle`) +now carries `'stroke': '#333'`, `'fill': '#888'` and `'width': 2.5` -- +CONFIRMED against the button's `_draw_primitive`, 2026-09-04: it reads +`'width'` on a `line` primitive already (the Fixed hatch's thickened tick +marks are real today), but does not handle `kind == 'circle'` or +`kind == 'polygon'` AT ALL -- not "ignores fill/stroke", the whole branch +is absent, so the Pin/Floor-tie triangle and the Free/unrecognised dot do +not draw regardless of what hints they carry. `'stroke'`/`'fill'`/`'width'` +on those two kinds are written now so nothing has to change here the day +the button's renderer is taught them; until then only the Fixed hatch's +extra thickness is visible. This is a renderer-side gap (the WPF button's +`script.py`, out of this module's ownership), reported rather than worked +around by changing what kind of primitive a restraint symbol is. + +`_RESTRAINT_STROKE` / `_RESTRAINT_FILL` / `_RESTRAINT_WIDTH` are shared by +all three symbol kinds so "equally dark" (the coordinator's own phrase for +the Fixed block and Free circle) is one constant, not three copies that +can drift. + +WIND, drawn in its own row above the loads (see LAYOUT ROWS below): a zone +with neither `pressure_psf` nor `suction_psf` entered draws no band and no +arrows -- there is nothing to point at yet -- just one small label reading +": no pressure entered yet". Never the em-dash form ("W1: +— / -— +psf...") that used to print when both were blank; a single em-dash for +just one of the two (one entered, one not) is still the ordinary +MISSING/UNREADABLE handling above and prints fine. + +LAYOUT ROWS, top to bottom, packed in fixed pixel amounts rather than a +fraction of `height_px` (see the `_TOP_PAD`.. constants): wind band, loads, +the member with its restraints on it, restraint labels, the dimension +chain. Packing tight from y=0 is the fix for the owner's headless-render +finding, 2026-09-04, that the old fraction-of-height midpoint left roughly +40% of the canvas blank above the member -- nothing was actually drawn in +that reserved space. The restraint-label row and the dimension-chain row +below the member are packed the same way: `_RESTRAINT_LABEL_GAP` (4 px) +puts the label directly under the 16 px symbol, `_DIM_ROW_GAP` (22 px) +puts the dimension chain that far below the label row -- a same-day +follow-up on the SAME render, which found roughly 45% of the canvas empty +below those two rows once the top rows were fixed. The canvas HEIGHT +itself is not this module's decision -- `pa_structural._freebody_aspect` +shrinks the `aspect` it passes in when a diagram has few or no +loads/wind zones, rather than this module stretching a fixed row spacing +to fill whatever height it is handed. + +LABEL STACKING: two labels within 40 px of each other along x, in the same +channel (restraints below the line, loads/wind above it), would otherwise +print on top of each other -- `_destack` alternates every other close-by +label further from the line so both stay legible. See its docstring. + +NEVER RAISES. `freebody()` is wrapped end to end: anything that gets past +the row-parsing degrades (a bad row is skipped and reported, never a +crash), and the one path left that could still raise -- a genuinely +unexpected exception -- is caught at the top level and turned into a single +`text` primitive on the canvas plus a `problems` sentence naming what broke, +so the page never goes blank and never takes the rest of the form down with +it. `values` is never mutated -- every access below is `.get(...)`. +""" + + +_MARGIN_FRACTION = 0.14 +_MARGIN_MIN_PX = 70.0 # end labels must fit inside this, never clipped +_MARGIN_MAX_PX = 110.0 + +_ARROW_LEN = 32.0 # px, concentrated-load arrow shaft (>= 30 px) +_ARROW_HEAD = 5.0 # px, arrowhead half-width/length +_WIND_ARROW_LEN = 16.0 # px, wind-zone arrow shaft (shorter, banded) +_WIND_ARROWS_MIN = 3 +_WIND_ARROWS_MAX = 7 +_RESTRAINT_TRI_HALF = 7.0 +_RESTRAINT_TRI_DROP = 16.0 # restraint symbol height -- 16 px tall +_RESTRAINT_FREE_R = 8.0 # free/unrecognised dot radius -- 16 px across +_RESTRAINT_STROKE = '#333' # dark outline hint -- see RESTRAINT SYMBOL +_RESTRAINT_FILL = '#888' # mid-grey fill hint, same primitives +_RESTRAINT_WIDTH = 2.5 # px -- outline-thickness hint; also what + # actually renders today (see below) +_STACK_THRESHOLD = 40.0 +_STACK_STEP = 13.0 + +# Vertical layout, stacked from the top -- see `_freebody`'s LAYOUT ROWS +# note: wind band, then loads, then the member (with restraints on it), +# then restraint labels, then the dimension chain. Fixed pixel amounts, not +# a fraction of `height_px`: the owner's headless render, 2026-09-04, found +# the old fraction-of-height midpoint left ~40% of the canvas blank above +# the member because nothing was actually drawn up there -- packing the +# rows tight from y=0 uses that space instead of reserving it and leaving +# it empty. +_TOP_PAD = 6.0 # px above the wind row's own label +_WIND_LABEL_H = 8.0 # px reserved for the wind row's text baseline +_WIND_ROW_GAP = 6.0 # px between the wind label and its arrow row +_LOAD_ROW_GAP = 6.0 # px between the wind arrows and the load arrows +_LOAD_LABEL_GAP = 6.0 # px between a load arrow's shaft and its label +_END_TICK_LEN = 6.0 # px, the member's end ticks above/below the line +_MEMBER_WIDTH = 3.0 # px -- "3-px dark line"; a hint for the WPF + # renderer (not this module's job), same + # convention as `flagged` +_RESTRAINT_LABEL_GAP = 4.0 # px, symbol bottom -> restraint-label row +_DIM_ROW_GAP = 22.0 # px, restraint-label row -> dimension row + + +# ------------------------------------------------------------------ parsing + +def _num(value): + """A float, or None. Accepts a number already, or text; blank -> None.""" + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +def _text_of(value): + if value is None: + return '' + try: + value.strip + except AttributeError: + try: + value = str(value) + except Exception: + return '' + return value.strip() + + +def _fmt_num(value, fmt='{0:.2f}'): + """A formatted number, or the em-dash stand-in for "not entered" -- + never a fabricated 0.""" + if value is None: + return u'—' + return fmt.format(value) + + +def _clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + + +# -------------------------------------------------------------------------- +# row_ids / row_key -- the same contract `pa_structural.py` implements, +# independently (see the module docstring for why this module does not +# import that one). Kept identical on purpose. +# -------------------------------------------------------------------------- + +def row_key(table, n, col): + try: + from vibe import panelanalysisform as paf + return paf.row_key(table, n, col) + except (ImportError, AttributeError): + return '{0}.{1}.{2}'.format(table, n, col) + + +def row_ids(table, values): + """Every PRESENT row id, ascending, as INT ids -- mirrors + `vibe.panelanalysisform.row_ids` exactly (see `pa_structural.row_ids`'s + docstring for why the fallback below matches it id-type and all).""" + try: + from vibe import panelanalysisform as paf + return list(paf.row_ids(table, values)) + except (ImportError, AttributeError): + pass + + values = values or {} + prefix = '{0}.'.format(table) + candidates = set() + for key in values: + if not isinstance(key, str) and not isinstance(key, type(u'')): + continue + if not key.startswith(prefix): + continue + n_txt = key[len(prefix):].split('.', 1)[0] + try: + n = int(n_txt) + except (TypeError, ValueError): + continue + if n > 0: + candidates.add(n) + out = [n for n in candidates + if str(values.get(row_key(table, n, 'present')) or '') + .strip().lower() == 'yes'] + out.sort() + return out + + +def _row(table, n, values, cols): + """One row's raw cell values, `{col: text}` -- `cols` names which + columns to read; every value comes back a stripped string (possibly + empty), never parsed here.""" + out = {} + for col in cols: + out[col] = _text_of(values.get(row_key(table, n, col))) + return out + + +def _support_rows(values, problems): + """[{'id', 'x_ft', 'elevation_ft'}, ...] -- the `support` row table + (owner ruling, 2026-09-05: bearing positions along the length, one + editable row each), converted to an ELEVATION the same way restraints + already are on this drawing (`bottom_elevation_ft + x_ft`) so a support + triangle sits on the same axis as everything else here. Falls back to + the legacy `supports_ft` comma-separated text field when the table has + no present rows at all -- a panel saved before this table existed. + `bottom_elevation_ft` missing/unreadable reports once and skips every + support (there is no axis to place them on), never a guessed 0. + """ + rows = row_ids('support', values) + xs = [] + if rows: + for n in rows: + raw = _row('support', n, values, ('x_ft',)) + x = _num(raw['x_ft']) + if x is None: + problems.append( + 'Support row {0} has no readable position; not drawn ' + 'on the free body.'.format(n)) + continue + xs.append(x) + else: + text = _text_of(values.get('supports_ft')) + if text: + for i, tok in enumerate(text.replace(',', ' ').split(), 1): + x = _num(tok) + if x is None: + problems.append( + 'Support position {0!r} is not a number; not drawn ' + 'on the free body.'.format(tok)) + continue + xs.append(x) + if not xs: + return [] + bottom = _num(values.get('bottom_elevation_ft')) + if bottom is None: + problems.append( + "'bottom_elevation_ft' is missing or not a number; support " + 'positions cannot be converted to an elevation and are not ' + 'drawn on the free body.') + return [] + out = [] + for i, x in enumerate(xs, 1): + out.append({'id': i, 'x_ft': x, 'elevation_ft': bottom + x}) + return out + + +def _restraint_rows(values, problems): + rows = [] + for n in row_ids('restraint', values): + raw = _row('restraint', n, values, + ('elevation_ft', 'type')) + elev = _num(raw['elevation_ft']) + if elev is None: + problems.append( + 'Restraint row {0} has no readable elevation; not drawn ' + 'on the free body.'.format(n)) + continue + type_ = raw['type'].strip() + rows.append({'id': n, 'elevation_ft': elev, 'type': type_}) + return rows + + +def _conc_load_rows(values, problems): + rows = [] + for n in row_ids('conc_load', values): + raw = _row('conc_load', n, values, + ('label', 'elevation_ft', 'offset_in', 'dead_k', 'live_k')) + elev = _num(raw['elevation_ft']) + if elev is None: + problems.append( + 'Concentrated load row {0} has no readable elevation; not ' + 'drawn on the free body.'.format(n)) + continue + offset = _num(raw['offset_in']) + rows.append({'id': n, 'label': raw['label'].strip(), + 'elevation_ft': elev, 'offset_in': offset, + 'dead_k': _num(raw['dead_k']), + 'live_k': _num(raw['live_k'])}) + return rows + + +def _wind_zone_rows(values, problems): + rows = [] + for n in row_ids('wind_zone', values): + raw = _row('wind_zone', n, values, + ('label', 'from_elev_ft', 'to_elev_ft', 'width_ft', + 'pressure_psf', 'suction_psf')) + frm = _num(raw['from_elev_ft']) + to = _num(raw['to_elev_ft']) + if frm is None or to is None: + problems.append( + 'Wind zone row {0} has no readable from/to elevation; not ' + 'drawn on the free body.'.format(n)) + continue + flagged = frm > to + if flagged: + frm, to = to, frm + problems.append( + 'Wind zone row {0} has its from-elevation above its ' + 'to-elevation; shown swapped.'.format(n)) + rows.append({'id': n, 'label': raw['label'].strip(), + 'from_elev_ft': frm, 'to_elev_ft': to, + 'width_ft': _num(raw['width_ft']), + 'pressure_psf': _num(raw['pressure_psf']), + 'suction_psf': _num(raw['suction_psf']), + 'flagged': flagged}) + return rows + + +# ------------------------------------------------------------------ layout + +def _margin(width_px, height_px): + m = min(width_px, height_px) * _MARGIN_FRACTION + if m < _MARGIN_MIN_PX: + m = _MARGIN_MIN_PX + if m > _MARGIN_MAX_PX: + m = _MARGIN_MAX_PX + return m + + +def _elevation_span(values, restraint_rows, load_rows, wind_rows, problems): + """(bottom, top) ft. Prefers `bottom_elevation_ft`/`top_elevation_ft`; + falls back to the span the rows already cover, and finally to a bare + placeholder -- see the module docstring's MISSING/UNREADABLE section.""" + bottom = _num(values.get('bottom_elevation_ft')) + top = _num(values.get('top_elevation_ft')) + if bottom is None: + problems.append( + "'bottom_elevation_ft' is missing or not a number; the free " + 'body is estimating its range from whatever rows are entered.') + if top is None: + problems.append( + "'top_elevation_ft' is missing or not a number; the free body " + 'is estimating its range from whatever rows are entered.') + if bottom is not None and top is not None and top > bottom: + return bottom, top + if bottom is not None and top is not None and top <= bottom: + problems.append( + 'Top elevation is not above bottom elevation; the free body ' + 'is estimating its range from whatever rows are entered ' + 'instead.') + + elevs = [] + for r in restraint_rows: + elevs.append(r['elevation_ft']) + for r in load_rows: + elevs.append(r['elevation_ft']) + for r in wind_rows: + elevs.append(r['from_elev_ft']) + elevs.append(r['to_elev_ft']) + if elevs: + lo, hi = min(elevs), max(elevs) + if hi <= lo: + hi = lo + 1.0 + return lo, hi + + problems.append( + 'No elevation range and no rows entered yet; the free body shows ' + 'a placeholder 0.00-1.00 ft span.') + return 0.0, 1.0 + + +def _destack(items, direction, threshold=_STACK_THRESHOLD, + step=_STACK_STEP): + """Mutates each item's `'y'` in place so two labels within `threshold` + px of each other along x do not print on top of each other. + + `items` is a list of dicts each already carrying `'x'` and a base + `'y'`. Walked in x order (a copy is sorted; the caller's own list order + is untouched); whenever one label sits closer than `threshold` to the + PREVIOUS one in that x order, it is pushed an extra `step` px further + from the member line (`direction` is +1 to push down, -1 to push up), + alternating so a run of three tight labels reads low/high/low rather + than climbing forever. Isolated labels (nothing within `threshold`) are + left at their base `y`. + """ + order = sorted(range(len(items)), key=lambda i: items[i]['x']) + toggle = False + prev_x = None + for idx in order: + item = items[idx] + if prev_x is not None and abs(item['x'] - prev_x) < threshold: + toggle = not toggle + if toggle: + item['y'] = item['y'] + direction * step + else: + toggle = False + prev_x = item['x'] + return items + + +# --------------------------------------------------------------------- API + +def freebody(values, width_px, height_px): + """(primitives, problems) -- the free body: one line for the member, + restraints at their true positions, loads drawn on it. See the module + docstring for the full content and vocabulary. Never raises; always + returns at least the member line (see NEVER RAISES above).""" + try: + return _freebody(values, width_px, height_px) + except Exception as exc: # the backstop of last resort + w = float(width_px) if width_px else 300.0 + h = float(height_px) if height_px else 100.0 + prim = {'kind': 'text', 'x': _clamp(w / 2.0, 0.0, w), + 'y': _clamp(h / 2.0, 0.0, h), + 'text': 'Free-body drawing could not be produced: {0}' + .format(exc), + 'role': 'note', 'anchor': 'middle'} + return [prim], ['free-body drawing failed: {0}'.format(exc)] + + +def _freebody(values, width_px, height_px): + values = values or {} + problems = [] + + try: + width_px = float(width_px) + except (TypeError, ValueError): + width_px = 300.0 + try: + height_px = float(height_px) + except (TypeError, ValueError): + height_px = 120.0 + if width_px <= 0.0: + width_px = 1.0 + if height_px <= 0.0: + height_px = 1.0 + + restraint_rows = _restraint_rows(values, problems) + load_rows = _conc_load_rows(values, problems) + wind_rows = _wind_zone_rows(values, problems) + + bottom, top = _elevation_span(values, restraint_rows, load_rows, + wind_rows, problems) + span = top - bottom + if span <= 0.0: + span = 1.0 + top = bottom + 1.0 + + margin = _margin(width_px, height_px) + x_left = margin + x_right = width_px - margin + if x_right - x_left < 1.0: + x_left = width_px * 0.1 + x_right = width_px * 0.9 + if x_right <= x_left: + x_right = x_left + 1.0 + + def elev_x(elev_ft): + frac = (elev_ft - bottom) / span + frac = _clamp(frac, 0.0, 1.0) + return x_left + frac * (x_right - x_left) + + # LAYOUT ROWS, stacked from the top (see the module docstring and the + # `_TOP_PAD`.. constants above): [wind band] [loads] [member+restraints] + # [labels] [dimension chain]. Each row's own top-left constant chains + # into the next, so `line_y` falls out of what actually has to be drawn + # above it rather than a fixed fraction of `height_px`. + wind_label_y = _TOP_PAD + _WIND_LABEL_H + wind_arrow_top_y = wind_label_y + _WIND_ROW_GAP + wind_arrow_bottom_y = wind_arrow_top_y + _WIND_ARROW_LEN + load_arrow_top_y = wind_arrow_bottom_y + _LOAD_ROW_GAP + line_y = load_arrow_top_y + _ARROW_LEN + + load_base_y = load_arrow_top_y # load arrow-tail row + wind_base_y = wind_arrow_top_y # wind arrow-shaft top + # Restraint labels sit directly under the symbols (a small fixed gap, + # not a large one), and the dimension chain follows ~22 px below that + # -- coordinator, live headless render: with the old wider gaps + # (dim_y = restraint_label_y + 20 off of a smaller symbol) roughly the + # bottom 45% of the canvas sat empty below these two rows. See also + # `pa_structural._freebody_aspect`, which now shrinks the canvas + # itself for a diagram with few/no loads or wind zones instead of + # relying on this module to stretch content to fill a fixed height. + restraint_label_y = line_y + _RESTRAINT_TRI_DROP + _RESTRAINT_LABEL_GAP + dim_y = restraint_label_y + _DIM_ROW_GAP + + primitives = [] + + # ---- the member line itself, full width less margin -- a 3-px dark + # line ('width' is a hint the renderer may not read yet, same + # convention as 'flagged') with a small tick at each end marking where + # the modelled member actually starts and stops. + primitives.append({'kind': 'line', 'x1': x_left, 'y1': line_y, + 'x2': x_right, 'y2': line_y, 'role': 'member', + 'width': _MEMBER_WIDTH}) + for tick_x in (x_left, x_right): + primitives.append({'kind': 'line', 'x1': tick_x, + 'y1': line_y - _END_TICK_LEN, 'x2': tick_x, + 'y2': line_y + _END_TICK_LEN, + 'role': 'member-end-tick'}) + # End labels sit just above the ticks, positioned at the member's own + # ends and kept inside the margin reserved for them (`_margin`'s >= 70 + # px floor) -- not centred exactly on the canvas edge, which is what + # used to clip "Bottom: -2.00 ft" down to "ottom:". + primitives.append({'kind': 'text', 'x': x_left, + 'y': line_y - _END_TICK_LEN - 12.0, + 'text': 'Bottom: {0:.2f} ft'.format(bottom), + 'role': 'elevation-end', 'anchor': 'middle'}) + primitives.append({'kind': 'text', 'x': x_right, + 'y': line_y - _END_TICK_LEN - 12.0, + 'text': 'Top: {0:.2f} ft'.format(top), + 'role': 'elevation-end', 'anchor': 'middle'}) + + # ---- restraints: symbol at the line, label below + restraint_labels = [] + for r in restraint_rows: + elev = r['elevation_ft'] + flagged = elev < bottom or elev > top + if flagged: + problems.append( + 'Restraint at {0:.2f} ft is outside the {1:.2f}-{2:.2f} ft ' + 'elevation range; shown clamped to the nearest end.' + .format(elev, bottom, top)) + x = elev_x(elev) + type_lower = r['type'].strip().lower() + + if type_lower in ('pin', 'floor tie'): + role = 'restraint-pin' if type_lower == 'pin' else \ + 'restraint-floortie' + points = [(x, line_y), + (x - _RESTRAINT_TRI_HALF, line_y + _RESTRAINT_TRI_DROP), + (x + _RESTRAINT_TRI_HALF, line_y + _RESTRAINT_TRI_DROP)] + # 'stroke'/'fill'/'width' are rendering hints, same convention + # as the member line's 'width' -- see RESTRAINT SYMBOLS in the + # module docstring for which of these the current WPF button + # actually reads today. + primitives.append({'kind': 'polygon', 'points': points, + 'role': role, 'flagged': flagged, + 'stroke': _RESTRAINT_STROKE, + 'fill': _RESTRAINT_FILL, + 'width': _RESTRAINT_WIDTH}) + elif type_lower == 'fixed': + # a fixed-end hatch: three short ticks angled off the line, + # thickened ('width') so it reads as a dark hatched block + # rather than a hairline -- the renderer already honours + # 'width' on a 'line' primitive. + for dx in (-6.0, 0.0, 6.0): + primitives.append({ + 'kind': 'line', 'x1': x + dx, 'y1': line_y, + 'x2': x + dx - 4.0, 'y2': line_y + _RESTRAINT_TRI_DROP, + 'role': 'restraint-fixed', 'flagged': flagged, + 'stroke': _RESTRAINT_STROKE, 'width': _RESTRAINT_WIDTH}) + else: + # 'Free', blank, or unrecognised -- no restraint SYMBOL (there + # is nothing restraining here), just a location dot. + role = 'restraint-free' if type_lower == 'free' else \ + 'restraint-unknown' + # radius = half of _RESTRAINT_TRI_DROP, so the dot's top edge + # sits ON the line (same "on the line" placement as the + # triangle/hatch symbols) and its full height matches theirs. + primitives.append({'kind': 'circle', 'cx': x, + 'cy': line_y + _RESTRAINT_FREE_R, + 'r': _RESTRAINT_FREE_R, 'role': role, + 'flagged': flagged, + 'stroke': _RESTRAINT_STROKE, + 'fill': _RESTRAINT_FILL, + 'width': _RESTRAINT_WIDTH}) + + label_text = '{0:.2f} ft'.format(elev) + if r['type']: + label_text += u' — {0}'.format(r['type']) + restraint_labels.append({'x': x, 'y': restraint_label_y, + 'text': label_text, + 'role': 'restraint-label'}) + + _destack(restraint_labels, direction=1) + for lab in restraint_labels: + primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'], + 'text': lab['text'], 'role': lab['role'], + 'anchor': 'middle'}) + + # ---- dimension chain of restraint spacings, under the labels + placed = sorted(restraint_rows, key=lambda r: r['elevation_ft']) + for a, b in zip(placed, placed[1:]): + xa, xb = elev_x(a['elevation_ft']), elev_x(b['elevation_ft']) + primitives.append({'kind': 'line', 'x1': xa, 'y1': dim_y, + 'x2': xb, 'y2': dim_y, 'role': 'dimension'}) + spacing = b['elevation_ft'] - a['elevation_ft'] + primitives.append({'kind': 'text', 'x': (xa + xb) / 2.0, + 'y': dim_y + 4.0, + 'text': '{0:.2f} ft'.format(spacing), + 'role': 'dimension', 'anchor': 'middle'}) + + # ---- concentrated loads: an arrow onto the line, label at the tail + load_labels = [] + for i, ld in enumerate(load_rows, 1): + x = elev_x(ld['elevation_ft']) + offset = ld['offset_in'] + above = offset is None or offset >= 0.0 + if offset is None: + problems.append( + 'Load {0} has no offset entered; shown on the outside ' + 'face by default.'.format(ld['label'] or 'P{0}'.format(i))) + y_tail = line_y - _ARROW_LEN if above else line_y + _ARROW_LEN + primitives.append({'kind': 'line', 'x1': x, 'y1': y_tail, + 'x2': x, 'y2': line_y, 'role': 'load-arrow'}) + head_y = line_y - _ARROW_HEAD if above else line_y + _ARROW_HEAD + points = [(x, line_y), (x - _ARROW_HEAD, head_y), + (x + _ARROW_HEAD, head_y)] + primitives.append({'kind': 'polygon', 'points': points, + 'role': 'load-arrowhead'}) + + name = ld['label'] or 'P{0}'.format(i) + offset_text = (u'{0:+.0f} in'.format(offset) if offset is not None + else u'—') + text = '{0}: D {1} k / L {2} k, e {3}'.format( + name, _fmt_num(ld['dead_k'], '{0:.1f}'), + _fmt_num(ld['live_k'], '{0:.1f}'), offset_text) + # To the RIGHT of the arrow shaft, level with its tail -- not + # stacked above/below the tail, which used to fight the arrowhead + # for the same few pixels right next to the member line. + load_labels.append({'x': x + _LOAD_LABEL_GAP, 'y': y_tail, + 'text': text, 'role': 'load-label', + '_dir': -1.0 if above else 1.0}) + + # loads above and below the line stack independently (their base rows + # sit on opposite sides), so each is destacked against only its own + # side to avoid an above-line label being nudged toward a below-line + # neighbour it does not actually overlap. + above_labels = [l for l in load_labels if l['_dir'] < 0] + below_labels = [l for l in load_labels if l['_dir'] > 0] + _destack(above_labels, direction=-1) + _destack(below_labels, direction=1) + for lab in load_labels: + primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'], + 'text': lab['text'], 'role': lab['role'], + 'anchor': 'start'}) + + # ---- wind zones: a row of small arrows over the from/to extent, in + # its OWN band above the loads (previously the wind arrows shared the + # loads' vertical span and the two rows visually merged into one). + # A zone with neither pressure nor suction typed in yet draws no band + # and no arrows at all -- there is nothing to point at -- just a small + # label saying so; drawing a "+— / -—" arrow row would be inventing a + # pressure nobody entered. + wind_labels = [] + for i, wz in enumerate(wind_rows, 1): + x0 = elev_x(wz['from_elev_ft']) + x1 = elev_x(wz['to_elev_ft']) + if x1 < x0: + x0, x1 = x1, x0 + name = wz['label'] or 'W{0}'.format(i) + has_pressure = (wz['pressure_psf'] is not None or + wz['suction_psf'] is not None) + + if has_pressure: + primitives.append({'kind': 'rect', 'x': x0, + 'y': wind_arrow_bottom_y, + 'w': max(x1 - x0, 1.0), 'h': 3.0, + 'role': 'wind-zone-band', + 'flagged': wz['flagged']}) + + span_px = x1 - x0 + n_arrows = _WIND_ARROWS_MIN + if span_px > 0.0: + n_arrows = int(_clamp(round(span_px / 24.0), + _WIND_ARROWS_MIN, _WIND_ARROWS_MAX)) + for j in range(n_arrows): + frac = 0.5 if n_arrows == 1 else j / float(n_arrows - 1) + ax = x0 + frac * span_px + primitives.append({'kind': 'line', 'x1': ax, + 'y1': wind_arrow_top_y, 'x2': ax, + 'y2': wind_arrow_bottom_y, + 'role': 'wind-arrow'}) + points = [(ax, wind_arrow_bottom_y), + (ax - 3.0, wind_arrow_bottom_y - _ARROW_HEAD), + (ax + 3.0, wind_arrow_bottom_y - _ARROW_HEAD)] + primitives.append({'kind': 'polygon', 'points': points, + 'role': 'wind-arrowhead'}) + text = '{0}: +{1} / -{2} psf, w {3} ft'.format( + name, _fmt_num(wz['pressure_psf'], '{0:.0f}'), + _fmt_num(wz['suction_psf'], '{0:.0f}'), + _fmt_num(wz['width_ft'], '{0:.1f}')) + else: + text = '{0}: no pressure entered yet'.format(name) + + wind_labels.append({'x': (x0 + x1) / 2.0, 'y': wind_label_y, + 'text': text, 'role': 'wind-label'}) + + _destack(wind_labels, direction=-1) + for lab in wind_labels: + primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'], + 'text': lab['text'], 'role': lab['role'], + 'anchor': 'middle'}) + + if not restraint_rows and not load_rows and not wind_rows: + primitives.append({'kind': 'text', 'x': (x_left + x_right) / 2.0, + 'y': line_y + 30.0, + 'text': 'No restraints or loads entered yet.', + 'role': 'note', 'anchor': 'middle'}) + + primitives = [_clamp_primitive(p, width_px, height_px) + for p in primitives] + return primitives, problems + + +# --------------------------------------------------------------- clamping + +def _clamp_primitive(prim, width_px, height_px): + """Pulls one primitive's coordinates back inside the box, in place -- + same discipline as `panelsketch._clamp_primitive`, extended for the + `circle` and `polygon` kinds this drawing also uses.""" + kind = prim.get('kind') + if kind == 'rect': + x = _clamp(prim['x'], 0.0, width_px) + y = _clamp(prim['y'], 0.0, height_px) + w = prim['w'] + h = prim['h'] + if x + w > width_px: + w = width_px - x + if y + h > height_px: + h = height_px - y + if w < 0.0: + w = 0.0 + if h < 0.0: + h = 0.0 + prim['x'], prim['y'], prim['w'], prim['h'] = x, y, w, h + elif kind == 'line': + prim['x1'] = _clamp(prim['x1'], 0.0, width_px) + prim['y1'] = _clamp(prim['y1'], 0.0, height_px) + prim['x2'] = _clamp(prim['x2'], 0.0, width_px) + prim['y2'] = _clamp(prim['y2'], 0.0, height_px) + elif kind == 'circle': + r = prim.get('r', 0.0) + prim['cx'] = _clamp(prim['cx'], r, max(width_px - r, r)) + prim['cy'] = _clamp(prim['cy'], r, max(height_px - r, r)) + elif kind == 'polygon': + prim['points'] = [(_clamp(px, 0.0, width_px), + _clamp(py, 0.0, height_px)) + for px, py in prim.get('points', ())] + else: + if 'x' in prim: + prim['x'] = _clamp(prim['x'], 0.0, width_px) + if 'y' in prim: + prim['y'] = _clamp(prim['y'], 0.0, height_px) + return prim + + +# --------------------------------------------------- real-unit geometry (ft) +# +# Added for the HTML-page rewrite (`vibe.pa_pagespec`): no pixels, no +# canvas size, no Revit -- just the same real-unit rows `freebody()` itself +# draws from, gathered under one dict a page with neither WPF nor a live +# model can still lay out. Built ENTIRELY from this module's own +# `_restraint_rows` / `_conc_load_rows` / `_wind_zone_rows` / `_elevation_ +# span` -- the same four calls `freebody()` makes -- so the JSON and the +# still-live WPF picture can never disagree about what a row means. + +def freebody_geometry(values): + """`{'length_ft', 'bottom_elevation_ft', 'top_elevation_ft', + 'restraints': [...], 'supports': [...], 'conc_loads': [...], + 'wind_zones': [...], 'problems': [...]}` -- real feet / kips / psf, no + pixels. + + `restraints` / `conc_loads` / `wind_zones` are exactly what + `_restraint_rows` / `_conc_load_rows` / `_wind_zone_rows` return (see + those functions' own docstrings for each row's shape); a row missing + the elevation it needs to be placed is skipped and named in `problems`, + the same "not drawn, and says why" rule those functions already follow. + `supports` (owner ruling, 2026-09-05) is `_support_rows`'s own + `{'id', 'x_ft', 'elevation_ft'}` list -- the `support` table's bearing + positions, converted to the same elevation axis restraints already + use, for the page's own "support triangles below the member, + restraints above" drawing rule. `bottom_elevation_ft` / + `top_elevation_ft` come from `_elevation_span`, which falls back to + the rows' own range, then to a bare 0.00-1.00 ft placeholder, when the + panel's own elevations are not entered -- never a crash, never an + empty span. Never raises; `values` is read-only. + """ + values = values or {} + problems = [] + restraints = _restraint_rows(values, problems) + conc_loads = _conc_load_rows(values, problems) + wind_zones = _wind_zone_rows(values, problems) + supports = _support_rows(values, problems) + bottom, top = _elevation_span(values, restraints, conc_loads, + wind_zones, problems) + length_ft = _num(values.get('length_ft')) + return { + 'length_ft': length_ft, + 'bottom_elevation_ft': bottom, 'top_elevation_ft': top, + 'restraints': restraints, 'supports': supports, + 'conc_loads': conc_loads, + 'wind_zones': wind_zones, 'problems': problems, + } diff --git a/WillTools.extension/lib/vibe/pa_sketch_handling.py b/WillTools.extension/lib/vibe/pa_sketch_handling.py new file mode 100644 index 0000000..f1e4588 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_sketch_handling.py @@ -0,0 +1,1285 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis Handling tab: the "Lifter and Dunnage Detail" plan drawing, +plus the panel's weight centroid -- pure geometry/arithmetic, both Pythons, +no Revit, no WPF, no `vibe.panelsketch` import. + +WHY THIS IS A SEPARATE MODULE FROM `panelsketch.py`: the owner's ruling +(2026-09-04) was specific about what this drawing has to look like -- +"lift&dunnage detail 画的完全不对。而且太小了。画图的时候,panel 的长度总要占满 +整个显示窗口的宽度,因为不管什么板,你总是横着画" (the old drawing was wrong +and too small; the panel's LENGTH must always fill the drawing's full width, +because a panel is always drawn lengthwise no matter what it is) -- a layout +rule `panelsketch.plan()` does not follow (it letterboxes to whichever axis is +tighter, so a squat panel can end up far short of the column width). Rather +than change `panelsketch.plan()` out from under whatever else reads it, this +is a new, narrower entry point owned by the same module (`vibe.pa_handling`) +that reads the rest of the Handling tab, so the two stay in lockstep. + +THE SEAM is identical to `panelsketch.py`'s own: a flat list of drawing +PRIMITIVES in a normalised, already-in-pixels coordinate space, origin +top-left, y increasing downward (WPF's `Canvas` convention) -- the WPF +painting is the button's job. `_clamp_primitive` below guarantees every +primitive lies inside `[0, width_px] x [0, height_px]`, same discipline as +`panelsketch._clamp_primitive`. + +PRIMITIVE VOCABULARY reuses `panelsketch.plan()`'s own kinds on purpose (read +`panelsketch.py`'s `_clamp_primitive` and the button's `_drawing_canvas` / +`_draw_primitive` before changing this) so the EXISTING renderer already +knows how to paint everything here with no renderer change required: + + {'kind': 'rect', ...} panel outline / openings + {'kind': 'dot', ..., 'role': 'face-lifter'} a FACE lifter -- the + renderer already draws + `dot` as a small filled + circle. + {'kind': 'support', ..., 'role': 'edge-lifter', 'edge': 'bottom'|'top', + 'edge_kind': 'end'|'side', 'ambiguous': bool} + an EDGE lifter -- the + renderer already draws + `support` as a filled + triangle (hollow when + `flagged`); `edge` names + which side of the panel it + sits on ('bottom'/'top' of + whichever axis `edge_kind` + says -- the panel's two + ENDS for 'end', its two + long SIDES for 'side'), + same "informational until a + renderer wants to orient + the glyph" status as + before. `edge_kind` and + `ambiguous` are new + (2026-09-04) -- see + `parse_edge_lifters()`. + {'kind': 'line', ..., 'role': 'dunnage', 'dashed': True} + a transverse dunnage line, + paired with a + `role: 'dunnage-label'` + text primitive reading + 'Dunnage'. `dashed` is a + hint the current renderer + does not read yet (it + draws every `line` solid); + carried so a later + renderer change needs no + primitive change. + {'kind': 'text', ..., 'role': 'dimension', 'font_px': 11, + 'stagger': 'inner'|'outer'} a dimension-chain label -- + `font_px` is the same kind + of forward-compatible hint + as `dashed` above (the + current renderer hardcodes + every `text` primitive to + 10px regardless). `stagger` + (transverse-chain labels + only) names which of the + two columns a close-packed + label was pushed into -- + see the THIRD ruling. + {'kind': 'marker', 'glyph': 'cross', 'role': 'centroid'} + a 'CG' text + the weight centroid, same + as `panelsketch.plan()`. + {'kind': 'line'/'text', 'role': 'dimension'} dimension chains, same + shapes as everywhere else + in `panelsketch.py`. + +`role` in `'face-lifter'` / `'edge-lifter'` / `'dunnage'` is the exact +vocabulary `panelsketch.plan()`'s own docstring already reserved for this +data the day it existed ("wire it in next to `_parse_opening_row`") -- +matched here for consistency even though the primitives themselves live in +this module, not that one. + +INPUT is the same `values` dict `panelanalysisform.py` / `panelsketch.py` +read from the form -- string-valued, never mutated. Keys read, all optional +except `length_ft` / `width_ft` (see below): + + length_ft, width_ft required -- see MISSING INPUT below. + thickness_in, section_type, wythe_top_in, insulation_in, wythe_bottom_in + feed `centroid()`'s Y (through-thickness) figure. + openings_rows same shape `panelsketch._parse_opening_row` + documents (a list of dicts: 'type', 'pos_along', + 'pos_along_from', 'length', 'pos_vert', 'height', + 'label'). This module only ever needs the + along-length / through-width axes (a PLAN view + has no through-thickness axis), so it parses a + narrower version of that row shape than + `panelsketch.py` does -- duplicated, not + imported, for the same "stay a leaf" reason + `panelsketch.py` gives for not importing + `panelanalysisform`. + face_lifters, edge_lifters + free text, one lifter per line, `'x_ft @ frac'` + -- `x_ft` along `length_ft` from the start, + `frac` the fraction of `width_ft` from the + bottom edge. The exact grammar + `vibe.pa_lifters.parse_lifter_lines` reads; + duplicated here rather than imported for the + same leaf-module reason as `openings_rows` above. + `edge_lifters` rows are classified END vs SIDE + by geometry -- see `parse_edge_lifters()`. + top_edge_lifters, side_edge_lifters + free text, same `'x_ft @ frac'` grammar as + `edge_lifters` -- the reads sibling's OWN + classification of which edge a lifter sits on, + preferred outright over re-deriving it from + `edge_lifters` by geometry when either carries + text. See `parse_edge_lifters()`. + dunnage free text, dunnage positions in ft along + `length_ft`, comma/whitespace separated. + dunnage_positions_ft a list of ft positions along `length_ft`, the + reads sibling's own dunnage read off Panel + Lift's job -- preferred outright over parsing + `dunnage` text when it is a list with at least + one usable entry. See `dunnage_positions()`. + There is NO plant-rule fallback reachable from + this leaf module for either dunnage source (that + lookup lives in `vibe.knowledge`, which this + module deliberately does not import -- see + MISSING INPUT below); neither present means no + dunnage lines are drawn, and is NOT reported + from here -- see `dunnage_positions()`'s own + docstring for why, and `pa_handling.page()` for + where it is said instead. + mark, design_number label only, as in `panelsketch.py`. + +MISSING INPUT IS THE HEADLINE STATE, same law as `panelsketch.py`: +`length_ft` / `width_ft` missing, blank, non-numeric or <= 0 is the one hard +stop -- `sketch()`... `lifter_dunnage()` returns `([], problems)`, no panel +drawn, nothing guessed. Every other gap (no lifters typed, no dunnage, an +opening row with an unresolved axis, a lifter outside the panel) still draws +what it safely can and reports the rest, exactly like every `panelsketch.py` +entry point. + +THE LENGTH-FILLS-THE-WIDTH LAYOUT (the owner's ruling quoted above) means +`lifter_dunnage()` does NOT letterbox to whichever axis is tighter the way +every `panelsketch.py` drawing does -- `draw_w` is always the full available +width, and `draw_h = draw_w * (width_ft / length_ft)` follows from it, +true-to-scale but never fit-to-height. The `'aspect'` the caller's `drawing` +block declares (`width_ft / length_ft`, clamped) is what sizes the +CONTAINER to roughly match this before this function ever runs, so the two +are meant to agree; `_clamp_primitive` is still the backstop that keeps +every primitive inside whatever box actually gets passed in. + +2026-09-04 SECOND RULING, off a headless render of this tab against a real +wall (37.58 x 8.375 ft, 8 face lifters, 4 edge lifters all sitting at +`x == length_ft`), three parts, all folded into this pass: + + 1. EDGE LIFTER CLASSIFICATION was wrong. The old code treated every + `edge_lifters` row identically -- position it along the top/bottom of + the PANEL OUTLINE by its `frac` (`<= 0.5` -> 'bottom', else 'top'), + the same way a face lifter is positioned, just with a triangle glyph + instead of a circle. For four lifters that all share `x == length_ft` + that collapses them onto two overlapping points at the panel's two + right-hand corners -- "the drawing put one triangle at the corner" -- + when what they actually are is four TOP-EDGE (end) lifters spread + along the short edge at `x = length_ft`, one per transverse fraction. + `parse_edge_lifters()` below is the fix: an edge lifter whose `x_ft` + sits within `_END_TOL_FT` (0.5 ft) of 0 or of `length_ft` is an END + lifter -- drawn ON that end edge (`x` pinned to 0 or `draw_w`), at its + TRANSVERSE position (`y = frac * width_ft`, continuously, not snapped + to the panel's top/bottom); one whose `frac` sits within + `_SIDE_TOL_FRAC` (0.05) of 0 or 1 (and does not already qualify as an + END lifter -- the x-check runs first) is a SIDE lifter -- drawn on + that long edge at its longitudinal `x_ft`, the old behaviour. A row + that fits neither test is still drawn (never dropped) as a side + lifter by which half of `frac` it falls in, the historical default, + but comes back with `ambiguous: True` and a `problems` sentence, + because a "edge" lifter that sits on no edge at all is worth a flag. + `top_edge_lifters` / `side_edge_lifters` -- free text in the same + `'x_ft @ frac'` grammar as `edge_lifters`, added by the sibling that + reads Panel Lift's own classification (`vibe.pa_lifters. + edge_lifter_lines`) -- are PREFERRED outright over re-classifying + `edge_lifters` by geometry, when either carries text: a + `top_edge_lifters` row is always an END lifter (`kind`), but that + field files BOTH a panel's bottom-end and top-end lifters -- the name + is a holdover, not a promise -- so `side` still comes from `x_ft` via + `_end_side_from_x`; a `side_edge_lifters` row is always a SIDE + lifter, bottom/top chosen by `frac` alone. `pa_handling._lifter_table_block` + reads this exact same function (through the `psh` it already imports) + for its own "Face / Side Edge / Top Edge" table groups, so the table + and the drawing can never disagree about what a given lifter is. + 2. DIMENSION CHAIN READABILITY. Every dimension-chain text primitive now + carries `'font_px': 11` -- a rendering HINT, same discipline as + `dashed` on a dunnage line (see the vocabulary list below): the + current renderer does not read it yet, but a later renderer change + needs no primitive change to pick it up. The chain itself already + drew one backbone line plus one tick/label per DISTINCT position + (`sorted(set(round(..., 3)))`); that discipline is unchanged, and the + end-edge lifters' transverse positions were already folded into + `all_y_ft` the same as a face lifter's -- the classification fix + above did not touch that part. (A live render of THIS fix still + showed the chain squashed into an illegible ~20px box -- see the + THIRD ruling below, which is the part that actually fixed it.) + 3. DUNNAGE. `dunnage_positions()` (renamed from the old private + `_dunnage_positions`, and now a documented sibling of `centroid()`) + reads `values['dunnage_positions_ft']` -- a list of ft positions the + reads sibling supplies straight off Panel Lift's job -- FIRST, and + only falls back to parsing `values['dunnage']` free text when that + list is absent or every entry in it is unusable. Each dunnage line + drawn now carries a matching `'dunnage-label'` text primitive reading + 'Dunnage'. And the part that actually changes behaviour: when NEITHER + source has anything to draw from, this function no longer adds a + `problems` sentence of its own. It used to ("Dunnage positions are + not entered..."), and `PanelAnalysis.pushbutton/script.py`'s + `_drawing_canvas` prints every `problems` entry a drawing function + returns as a RED inline note directly under the drawing -- so a panel + with genuinely no dunnage data got a permanent red warning under an + otherwise-fine picture. `pa_handling.page()` now owns saying so, ONCE, + as a single quiet sentence elsewhere on the page (see its own + docstring) -- this function stays silent on the "nothing entered" + case and reports only a REAL defect (an unreadable list, unparseable + text) the way it always did. + +2026-09-04 THIRD RULING, off a LIVE render of the second ruling's own font +hint fix -- the transverse chain was still a squashed ~20px-wide box of +ticks, because `font_px` is only a hint the renderer does not act on yet +and the LAYOUT itself was still cramming the whole chain into the ordinary +(<= 40 px) margin: + + 1. THE LEFT MARGIN is now reserved specifically for this chain -- + `_left_margin()` returns at least `_LEFT_MARGIN_MIN_PX` (90 px) + regardless of how small `_margin()` comes out, and `lifter_dunnage()` + uses it (not the ordinary `margin`) for `ox`. `draw_w` is computed off + the REMAINING width (`width_px - left_margin - margin`), so the panel + still fills every pixel that is left over -- the length-fills-the- + width rule from the FIRST ruling did not change, only what counts as + "available." + 2. TWO LABELS WHOSE PIXEL POSITIONS ARE CLOSE now stagger into an inner + and an outer column instead of overlapping: whenever a label's `py` + falls within `_LEFT_LABEL_STAGGER_TOL_PX` (14 px) of the PREVIOUS + label's `py` (both walked in ascending-`y_ft` order), it draws at + `outer_x` instead of `inner_x` (`_LEFT_LABEL_STAGGER_OFFSET_PX`, 34 px + further from the chain); a label whose neighbour is far enough away + resets to the inner column. Each label also carries a `'stagger': + 'inner'|'outer'` hint alongside `font_px`, for a renderer that wants + to draw a short leader line back to its own tick. + +`centroid()` is the second half of the owner's ruling ("centroid 该算要算的" +-- if it can be computed, compute it): the panel's weight centroid, X from +the start and Z from the left (the plan-view, area-weighted centroid, +identical arithmetic to `panelsketch.plan()`'s own, corrected for `Opening` +rows only -- a Reveal is partial-depth and a Solid Zone removes nothing, so +neither reduces the plan-view area) and Y from the bottom (the +through-thickness centroid). Y is deliberately NOT mass-weighted by each +wythe's own unit weight for a Sandwich section: `panelanalysisform.py` has +no established mapping from `unit_weight_pcf` / `unit_weight2_pcf` (wythe 1 +vs wythe 2, a MATERIAL pairing) onto `wythe_top_in` / `wythe_bottom_in` (top +vs bottom, a POSITION pairing) -- inventing one here would be exactly the +kind of unverifiable guess CLAUDE.md SS3 rules out, so Y is computed from +the wythe THICKNESSES alone (half the total, same as a solid section) with +a `problems` note when the wythe trio is what made that possible instead of +`thickness_in`. `centroid()` is called both by `page()` (for the printed +kv block) and by `lifter_dunnage()` (for the marker), so the two can never +show a different number for the same panel. + +Standard-library only (`re`, for the dunnage/lifter text splitting) -- +IronPython 2.7 in the button, plain CPython in `tests/`: no f-strings, no +dataclasses/typing/enum, `'{0}'.format(...)` throughout. `values` is never +mutated by anything below. +""" + +import re + + +_MARGIN_FRACTION = 0.12 +_MARGIN_MIN_PX = 8.0 +_MARGIN_MAX_PX = 40.0 + +# 2026-09-04 THIRD RULING: a live render showed the transverse dimension +# chain squashed into the ordinary (<= 40 px) margin -- a ~20-px-wide box +# of illegible ticks. The left margin is now reserved specifically for +# that chain, at least this wide regardless of how small `_margin()` comes +# out for a given canvas -- see `_left_margin()` / `lifter_dunnage()`. +_LEFT_MARGIN_MIN_PX = 90.0 + +# Two labels whose PIXEL y falls within this of each other alternate +# between an inner and an outer column (see the transverse chain in +# `lifter_dunnage()`) so neither is ever drawn on top of the other. +_LEFT_LABEL_STAGGER_TOL_PX = 14.0 +_LEFT_LABEL_STAGGER_OFFSET_PX = 34.0 + + +# ------------------------------------------------------------------ parsing + +def _num(value): + """A float, or None. Accepts a number already, or text; blank -> None.""" + if value is None: + return None + if isinstance(value, bool): # bool is an int subclass + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +def _clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + + +def _margin(width_px, height_px): + m = min(width_px, height_px) * _MARGIN_FRACTION + if m < _MARGIN_MIN_PX: + m = _MARGIN_MIN_PX + if m > _MARGIN_MAX_PX: + m = _MARGIN_MAX_PX + return m + + +def _left_margin(width_px, height_px): + """The left margin `lifter_dunnage()` reserves for the panel -- always + at least `_LEFT_MARGIN_MIN_PX` (90 px, wide enough for a readable + 'NN.NN ft' vertical label plus its tick and stagger column), never + less than the ordinary `_margin()` figure. Every other side of the + drawing still uses plain `_margin()`; only the transverse dimension + chain needed the extra room.""" + return max(_margin(width_px, height_px), _LEFT_MARGIN_MIN_PX) + + +def _clamp_primitive(prim, width_px, height_px): + """Pulls one primitive's coordinates back inside the box, in place -- + same contract as `panelsketch._clamp_primitive`.""" + kind = prim.get('kind') + if kind == 'rect': + x = _clamp(prim['x'], 0.0, width_px) + y = _clamp(prim['y'], 0.0, height_px) + w = prim['w'] + h = prim['h'] + if x + w > width_px: + w = width_px - x + if y + h > height_px: + h = height_px - y + if w < 0.0: + w = 0.0 + if h < 0.0: + h = 0.0 + prim['x'], prim['y'], prim['w'], prim['h'] = x, y, w, h + elif kind == 'line': + prim['x1'] = _clamp(prim['x1'], 0.0, width_px) + prim['y1'] = _clamp(prim['y1'], 0.0, height_px) + prim['x2'] = _clamp(prim['x2'], 0.0, width_px) + prim['y2'] = _clamp(prim['y2'], 0.0, height_px) + else: + if 'x' in prim: + prim['x'] = _clamp(prim['x'], 0.0, width_px) + if 'y' in prim: + prim['y'] = _clamp(prim['y'], 0.0, height_px) + return prim + + +def _title_primitive(mark, design_number): + mark = (mark or '').strip() if mark else '' + design_number = (design_number or '').strip() if design_number else '' + if not (mark or design_number): + return None + bits = [b for b in (mark, design_number) if b] + return {'kind': 'text', 'x': 4.0, 'y': 4.0, 'text': ' / '.join(bits), + 'role': 'title', 'anchor': 'start'} + + +# ------------------------------------------------------------- openings + +_OPENING_ROLE_BY_TYPE = {'Opening': 'opening', 'Reveal': 'reveal', + 'Solid Zone': 'solid-zone'} + + +def _resolve_axis_range(pos, extent, total, frm, far_name): + """(lo, hi) in ft from the NEAR edge, given a position/extent that may + have been typed measured from the FAR edge instead. `None` when `pos` + or `extent` is missing, or `extent` is non-positive -- see + `panelsketch._resolve_axis_range`, duplicated rather than imported.""" + if pos is None or extent is None or extent <= 0.0: + return None + if frm == far_name: + lo = total - pos - extent + else: + lo = pos + return lo, lo + extent + + +def _parse_opening_row(item, index, length_ft, width_ft): + """(row, problems) for one `openings_rows` entry -- the along-length / + through-width axes only (a plan view has no through-thickness axis, so + `pos_cross` is never read here). See `panelsketch._parse_opening_row` + for the full axis-assignment discussion this narrows down from.""" + if not isinstance(item, dict): + return None, ['Opening row {0} is not a recognised row; skipped.' + .format(index)] + type_ = str(item.get('type') or 'Opening').strip() or 'Opening' + label = str(item.get('label') or '').strip() + problems = [] + + pos_along = _num(item.get('pos_along')) + length_ext = _num(item.get('length')) + along_from = item.get('pos_along_from') or 'start' + along = _resolve_axis_range(pos_along, length_ext, length_ft, + along_from, 'end') + if along is None: + problems.append( + '{0} row {1} ({2}) has no along-length position/extent ' + 'entered; not drawn.'.format(type_, index, label or 'unlabeled')) + return None, problems + + pos_vert = item.get('pos_vert') + height_ext = item.get('height') + vert = _resolve_axis_range( + _num(pos_vert) if pos_vert is not None else None, + _num(height_ext) if height_ext is not None else None, + width_ft, 'start', 'end') + + row = {'type': type_, 'role': _OPENING_ROLE_BY_TYPE.get(type_, 'opening'), + 'label': label, 'index': index, 'along': along, 'vert': vert} + return row, problems + + +def _openings_rows(values, length_ft, width_ft): + """(rows, problems). `rows` is `None` when `values['openings_rows']` is + not a list; otherwise a list of parsed rows, one per entry that at + least resolved an along-length position.""" + raw = values.get('openings_rows') + if not isinstance(raw, list): + return None, [] + rows = [] + problems = [] + for i, item in enumerate(raw): + row, row_problems = _parse_opening_row(item, i + 1, length_ft, + width_ft) + problems.extend(row_problems) + if row is not None: + rows.append(row) + return rows, problems + + +def _opening_rect_primitives(rows, length_ft, width_ft, ox, oy, draw_w, + draw_h): + """Each row's `along` x `vert` extent, to scale, as a hollow rect.""" + primitives = [] + problems = [] + for row in rows: + if row['vert'] is None: + problems.append( + '{0} row {1} ({2}) has no vertical position/extent ' + 'entered; not drawn.'.format(row['type'], row['index'], + row['label'] or 'unlabeled')) + continue + x0, x1 = row['along'] + y0, y1 = row['vert'] + flagged = (x0 < 0.0 or y0 < 0.0 or x1 > length_ft or y1 > width_ft) + if flagged: + problems.append( + '{0} {1} ({2}) falls partly or wholly outside the panel; ' + 'shown clamped and flagged.'.format( + row['type'], row['index'], row['label'] or 'unlabeled')) + cx0 = _clamp(x0, 0.0, length_ft) + cx1 = _clamp(x1, 0.0, length_ft) + cy0 = _clamp(y0, 0.0, width_ft) + cy1 = _clamp(y1, 0.0, width_ft) + if cx1 <= cx0 or cy1 <= cy0: + problems.append('{0} {1} is entirely outside the panel; not ' + 'drawn.'.format(row['type'], row['index'])) + continue + px_x = ox + (cx0 / length_ft) * draw_w + px_w = ((cx1 - cx0) / length_ft) * draw_w + px_y = oy + draw_h - (cy1 / width_ft) * draw_h + px_h = ((cy1 - cy0) / width_ft) * draw_h + primitives.append({'kind': 'rect', 'x': px_x, 'y': px_y, + 'w': px_w, 'h': px_h, 'role': row['role'], + 'flagged': flagged}) + if row['label']: + primitives.append({'kind': 'text', 'x': px_x + px_w / 2.0, + 'y': px_y + px_h / 2.0, 'text': row['label'], + 'role': row['role'] + '-label', + 'anchor': 'middle'}) + return primitives, problems + + +# --------------------------------------------------------- lifters/dunnage + +def _parse_lifter_lines(text): + """(rows, problems) -- one `(x_ft, frac)` tuple per LINE, `'x_ft @ + frac'`. All-or-nothing, the same discipline as + `pa_lifters.parse_lifter_lines` -- duplicated, not imported, so this + stays a leaf module (see the module docstring).""" + rows = [] + problems = [] + if text is None: + text = '' + for i, raw in enumerate(str(text).splitlines(), 1): + line = raw.strip() + if not line: + continue + parts = [p.strip() for p in line.split('@')] + if len(parts) != 2: + problems.append( + "line {0}: expected 'x_ft @ frac', got {1!r}".format( + i, line)) + continue + x_ft = _num(parts[0]) + frac = _num(parts[1]) + if x_ft is None: + problems.append('line {0}: x_ft is not a number ({1!r})' + .format(i, parts[0])) + continue + if frac is None: + problems.append('line {0}: frac is not a number ({1!r})' + .format(i, parts[1])) + continue + rows.append((x_ft, frac)) + if problems: + return [], problems + return rows, [] + + +# --------------------------------------------------- lifter ROW TABLES +# +# Owner ruling, 2026-09-05: `face_lifters` / `edge_lifters` free text +# ('x_ft @ frac', one lifter per line) is replaced on the page by two +# EDITABLE ROW TABLES -- `face_lifter` (x_ft, y_ft) and `edge_lifter` (edge +# [Top/Bottom/Left/Right], position_ft) -- the same row-table mechanism +# `panelanalysisform` already uses for `restraint` / `wind_zone` / `opening`. +# This module still does not IMPORT `panelanalysisform` (see the module +# docstring's "stay a leaf" rule) -- `_table_row_ids` / `_table_row` below +# reimplement its `row_ids`/`row_key` arithmetic independently, the same way +# `pa_sketch_freebody.py` does for its own three tables. The free-text +# fields are read only as a FALLBACK, for a panel saved before these tables +# existed (see `_face_lifter_rows` / `parse_edge_lifters`). + +def _table_row_ids(table, values): + try: + from vibe import panelanalysisform as paf + return list(paf.row_ids(table, values)) + except (ImportError, AttributeError): + pass + values = values or {} + prefix = table + '.' + ids = set() + for key in values: + if not isinstance(key, str) and not isinstance(key, type(u'')): + continue + if not key.startswith(prefix): + continue + n_txt = key[len(prefix):].split('.', 1)[0] + try: + n = int(n_txt) + except (TypeError, ValueError): + continue + if n > 0: + ids.add(n) + out = [n for n in ids + if str(values.get('{0}.{1}.present'.format(table, n)) or '') + .strip().lower() == 'yes'] + out.sort() + return out + + +def _table_row(table, n, values, cols): + out = {} + for col in cols: + out[col] = (values or {}).get('{0}.{1}.{2}'.format(table, n, col)) + return out + + +def _face_lifter_rows_from_table(values): + """[(x_ft, frac), ...] off the `face_lifter` table's PRESENT rows -- + its own `x_ft`/`y_ft` columns converted to the `(x_ft, frac-of-width)` + pair every existing reader below already speaks. `[]` (never raises) + when the table has no rows, or `width_ft` is not a usable positive + number (a `y_ft` cannot become a fraction of nothing).""" + values = values or {} + width_ft = _num(values.get('width_ft')) + out = [] + for n in _table_row_ids('face_lifter', values): + raw = _table_row('face_lifter', n, values, ('x_ft', 'y_ft')) + x_ft = _num(raw.get('x_ft')) + y_ft = _num(raw.get('y_ft')) + if x_ft is None or y_ft is None or not width_ft or width_ft <= 0: + continue + out.append((x_ft, y_ft / width_ft)) + return out + + +def face_lifter_rows(values): + """(rows, problems), `rows` = [(x_ft, frac), ...] -- the `face_lifter` + table when it carries any row, else the legacy `face_lifters` free-text + field (a panel saved before the table existed). Never both at once: the + table, once it has any row, is authoritative. PUBLIC (unlike this + module's other row-table helpers) because `pa_handling._lifter_tables` + reads it too, for its own Face / Side Edge Lifters summary table -- + same "the table and the drawing can never disagree" reasoning + `parse_edge_lifters` already documents for edge lifters. + """ + rows = _face_lifter_rows_from_table(values) + if rows: + return rows, [] + return _parse_lifter_lines((values or {}).get('face_lifters')) + + +# Old private name, kept as an alias -- this module's own two call sites +# were written against it before `pa_handling` needed the same function. +_face_lifter_rows = face_lifter_rows + + +_EDGE_LIFTER_EDGE_MAP = { + 'left': ('end', 'bottom'), 'right': ('end', 'top'), + 'bottom': ('side', 'bottom'), 'top': ('side', 'top'), +} + + +def _edge_lifter_rows_from_table(values, length_ft, width_ft): + """[{'x_ft', 'frac', 'kind', 'side', 'ambiguous', 'source'}, ...] off + the `edge_lifter` table's PRESENT rows -- the EXACT row shape + `parse_edge_lifters()` returns, so every existing reader of that shape + (the drawing, `pa_handling`'s summary table) needs no change to accept + a table row instead of a classified `edge_lifters` text line. + + `edge` NAMES the side directly (owner ruling, 2026-09-05: Left/Right are + the panel's two ENDS along its length -- 'left' = x=0, 'right' = + x=length_ft; Top/Bottom are its two long SIDES across its width -- + 'bottom' = y=0, 'top' = y=width_ft) -- there is no geometry left to + classify, so `ambiguous` is always `False`. `position_ft` reads across + the width for Left/Right, along the length for Top/Bottom. A row with + an unrecognised edge word, or no readable position, is skipped (never + guessed at). + """ + values = values or {} + out = [] + for n in _table_row_ids('edge_lifter', values): + raw = _table_row('edge_lifter', n, values, ('edge', 'position_ft')) + edge = str(raw.get('edge') or '').strip().lower() + pos = _num(raw.get('position_ft')) + mapping = _EDGE_LIFTER_EDGE_MAP.get(edge) + if mapping is None or pos is None: + continue + kind, side = mapping + if kind == 'end': + if not width_ft or width_ft <= 0: + continue + x_ft = 0.0 if side == 'bottom' else (length_ft or 0.0) + frac = pos / width_ft + else: + x_ft = pos + frac = 0.0 if side == 'bottom' else 1.0 + out.append({'x_ft': x_ft, 'frac': frac, 'kind': kind, 'side': side, + 'ambiguous': False, 'source': 'edge_lifter'}) + return out + + +_END_TOL_FT = 0.5 +_SIDE_TOL_FRAC = 0.05 + + +def _end_side_from_x(x_ft, length_ft): + """'bottom' / 'top' / None for one END-edge lifter's `x_ft` -- 'bottom' + within `_END_TOL_FT` of the panel's start, 'top' within `_END_TOL_FT` + of `length_ft`, `None` when it fits neither closely enough to call. + Shared by `_classify_from_geometry` (the `edge_lifters` fallback) AND + `parse_edge_lifters`'s own `top_edge_lifters` branch below -- see that + function's docstring for why a "top edge lifters" ROW still needs this: + `pa_lifters.edge_lifter_lines` (the reads sibling) names the field + `top_edge_lifters` but actually files EVERY end lifter there, bottom + and top both, same tolerance (`_END_TOL_FT` here matches its own + `_END_TOL_FT` exactly) -- it is not a promise that every row in it is + a TOP lifter, only that every row in it is an END lifter.""" + if x_ft is None: + return None + if abs(x_ft - 0.0) <= _END_TOL_FT: + return 'bottom' + if length_ft and abs(x_ft - length_ft) <= _END_TOL_FT: + return 'top' + return None + + +def _classify_from_geometry(x_ft, frac, length_ft): + """(kind, side, ambiguous) for one `edge_lifters` row that has no + explicit `top_edge_lifters` / `side_edge_lifters` field to lean on. + + `kind` is 'end' when `_end_side_from_x` can call a side for `x_ft` + (within `_END_TOL_FT` of either panel end -- Eriksson's own "the end + that ends up high once erected" for the 'top' case); else 'side' when + `frac` sits within `_SIDE_TOL_FRAC` of 0 or 1 (`side` 'bottom' / 'top' + of the WIDTH axis). A row that matches neither test is still + classified 'side' by which half of `frac` it falls in -- the + historical default, so the lifter is never dropped -- but `ambiguous` + comes back `True`: an "edge" lifter sitting on no edge at all is worth + flagging, not silently drawn as if it were an ordinary side lifter. + """ + end_side = _end_side_from_x(x_ft, length_ft) + if end_side is not None: + return 'end', end_side, False + if frac is not None: + if abs(frac - 0.0) <= _SIDE_TOL_FRAC: + return 'side', 'bottom', False + if abs(frac - 1.0) <= _SIDE_TOL_FRAC: + return 'side', 'top', False + side = 'bottom' if (frac if frac is not None else 0.0) <= 0.5 else 'top' + return 'side', side, True + + +def parse_edge_lifters(values, length_ft, width_ft): + """(rows, problems) -- one dict per edge lifter: `{'x_ft', 'frac', + 'kind', 'side', 'ambiguous', 'source'}`. `kind` is 'end' (the lifter + sits on one of the panel's two short ends) or 'side' (one of its two + long edges); `side` is 'bottom' or 'top' of whichever axis `kind` + names. Shared by `lifter_dunnage()` (the drawing) and + `pa_handling._lifter_table_block` (the Face / Side Edge / Top Edge + summary table), so the two can never disagree about what a given + lifter is -- same reason `centroid()` is shared for the CG marker and + the printed kv block. + + `values['top_edge_lifters']` / `values['side_edge_lifters']` -- free + text, the same `'x_ft @ frac'` grammar as `edge_lifters` -- are + PREFERRED OUTRIGHT over classifying `edge_lifters` by geometry, + whenever either one carries any text: both name `kind` outright (every + `top_edge_lifters` row is `'end'`, every `side_edge_lifters` row is + `'side'` -- no geometry check needed for THAT). `side` still needs one: + `pa_lifters.edge_lifter_lines` (the reads sibling that writes these + fields) files BOTH a panel's bottom-end AND top-end lifters into + `top_edge_lifters` -- the field name is a holdover, not a promise every + row in it is a TOP lifter -- so a `top_edge_lifters` row's `side` is + still read off `x_ft` via `_end_side_from_x` (same `_END_TOL_FT` + tolerance that module uses); a `side_edge_lifters` row's `side` is + 'bottom'/'top' of the WIDTH axis, chosen by whichever half of `frac` it + falls in. `edge_lifters` is read only when BOTH of those are blank, and + each of its rows is classified by `_classify_from_geometry()`. + + Never raises; `values` never mutated. `width_ft` is accepted for a + uniform call signature with `lifter_dunnage()`'s other helpers but not + read here -- classification only ever needs `x_ft` / `frac` / + `length_ft`. + """ + values = values or {} + table_rows = _edge_lifter_rows_from_table(values, length_ft, width_ft) + if table_rows: + return table_rows, [] + + top_text = values.get('top_edge_lifters') + side_text = values.get('side_edge_lifters') + has_top = bool(top_text and str(top_text).strip()) + has_side = bool(side_text and str(side_text).strip()) + rows = [] + problems = [] + + if has_top or has_side: + if has_top: + parsed, probs = _parse_lifter_lines(top_text) + problems.extend('top edge lifters, ' + p for p in probs) + for x_ft, frac in parsed: + side = _end_side_from_x(x_ft, length_ft) + ambiguous = side is None + if ambiguous: + side = 'top' + problems.append( + 'Top edge lifter at {0:.2f} ft / {1:.2f} does not ' + 'sit near either panel end; classified top by the ' + 'field name, position flagged ambiguous.' + .format(x_ft, frac)) + rows.append({'x_ft': x_ft, 'frac': frac, 'kind': 'end', + 'side': side, 'ambiguous': ambiguous, + 'source': 'top_edge_lifters'}) + if has_side: + parsed, probs = _parse_lifter_lines(side_text) + problems.extend('side edge lifters, ' + p for p in probs) + for x_ft, frac in parsed: + side = 'bottom' if frac <= 0.5 else 'top' + rows.append({'x_ft': x_ft, 'frac': frac, 'kind': 'side', + 'side': side, 'ambiguous': False, + 'source': 'side_edge_lifters'}) + return rows, problems + + parsed, probs = _parse_lifter_lines(values.get('edge_lifters')) + problems.extend(probs) + for x_ft, frac in parsed: + kind, side, ambiguous = _classify_from_geometry(x_ft, frac, + length_ft) + rows.append({'x_ft': x_ft, 'frac': frac, 'kind': kind, 'side': side, + 'ambiguous': ambiguous, 'source': 'edge_lifters'}) + if ambiguous: + problems.append( + 'Edge lifter at {0:.2f} ft / {1:.2f} sits neither near a ' + 'panel end nor near a long edge; drawn as a side edge ' + 'lifter by its fraction, position flagged ambiguous.' + .format(x_ft, frac)) + return rows, problems + + +def dunnage_positions(values): + """(positions_ft or None, problems) -- x positions (ft from start) for + transverse dunnage lines. + + Prefers `values['dunnage_positions_ft']` -- a list of ft positions the + reads sibling supplies straight off Panel Lift's job -- when it is a + list with at least one entry that reads as a number; falls back to + parsing `values['dunnage']` free text (one number per line, or + comma/whitespace separated) otherwise. There is no plant-rule lookup + reachable from this leaf module either way (that lives in + `vibe.knowledge`, which this module deliberately does not import). + + NEITHER SOURCE PRESENT IS QUIET -- this is the 2026-09-04 fix: it used + to add a `problems` sentence of its own for exactly this case, and + `PanelAnalysis.pushbutton/script.py` prints every `problems` entry a + drawing function returns as a permanent RED note directly under the + drawing, so a panel with genuinely no dunnage data got a standing red + warning under an otherwise-fine picture. `pa_handling.page()` now says + so once, quietly, elsewhere on the page instead -- this function + reports only a REAL defect: `dunnage_positions_ft` present but not a + list, a list entry that will not parse as a number, or `dunnage` text + with an unparseable token. `values` is never mutated. + """ + problems = [] + raw_list = values.get('dunnage_positions_ft') + if raw_list is not None: + if isinstance(raw_list, (list, tuple)): + out = [] + for item in raw_list: + v = _num(item) + if v is None: + problems.append( + 'dunnage_positions_ft entry {0!r} is not a number; ' + 'skipped.'.format(item)) + continue + out.append(v) + if out: + return out, problems + # every entry (if any) was unusable -- fall through to text. + else: + problems.append( + 'dunnage_positions_ft is not a list; ignored, the dunnage ' + 'text field is read instead.') + + raw = values.get('dunnage') + if raw is None or not str(raw).strip(): + return None, problems + + text = str(raw).strip() + out = [] + for tok in re.split(r'[,\s]+', text): + if not tok: + continue + v = _num(tok) + if v is None: + problems.append( + 'Dunnage position {0!r} is not a number; skipped.' + .format(tok)) + continue + out.append(v) + if not out: + return None, problems + return out, problems + + +# ----------------------------------------------------------------- centroid + +def centroid(values): + """(cg_x_ft, cg_z_ft, cg_y_ft, xz_basis, y_basis, problems). + + `cg_x_ft` / `cg_z_ft`: the panel's PLAN-VIEW area centroid -- X from the + START along `length_ft`, Z from the LEFT along `width_ft`. Half the + length / width for a plain rectangle, corrected by subtracting each + `Opening` row's area x its own centroid (`Reveal` / `Solid Zone` rows + never reduce the plan-view area). `None` (with a `problems` entry + naming it) when `length_ft` / `width_ft` is missing or non-positive. + + `cg_y_ft`: the through-thickness centroid, from the BOTTOM (the + casting-bed rail) -- half of `thickness_in`, or when `section_type` is + 'sandwich' and all three wythe thicknesses are entered, half of their + SUM instead (the more granular figure when the two disagree; still + geometrically half the total thickness -- see the module docstring for + why this is not mass-weighted by wythe). `None` (with a `problems` + entry) when neither figure is available. + + `xz_basis` / `y_basis`: one plain-English sentence each, naming what the + number is based on -- 'solid rectangle', 'N opening(s) deducted', etc. + `None` alongside the matching centroid value when it is `None`. + + Never raises. `values` is never mutated. + """ + values = values or {} + problems = [] + + length_ft = _num(values.get('length_ft')) + width_ft = _num(values.get('width_ft')) + cg_x = cg_z = None + xz_basis = None + if length_ft is None or length_ft <= 0 or width_ft is None or width_ft <= 0: + problems.append( + 'Length (ft) and width (ft) are both needed to compute the ' + 'panel centroid; not entered yet.') + else: + rows, row_problems = _openings_rows(values, length_ft, width_ft) + problems.extend(row_problems) + total_area = length_ft * width_ft + mx = total_area * (length_ft / 2.0) + mz = total_area * (width_ft / 2.0) + net_area = total_area + n_deducted = 0 + if rows: + for row in rows: + if row['type'] != 'Opening' or row['vert'] is None: + continue + x0, x1 = row['along'] + y0, y1 = row['vert'] + cx0 = _clamp(x0, 0.0, length_ft) + cx1 = _clamp(x1, 0.0, length_ft) + cy0 = _clamp(y0, 0.0, width_ft) + cy1 = _clamp(y1, 0.0, width_ft) + if cx1 <= cx0 or cy1 <= cy0: + continue + area = (cx1 - cx0) * (cy1 - cy0) + mx -= area * ((cx0 + cx1) / 2.0) + mz -= area * ((cy0 + cy1) / 2.0) + net_area -= area + n_deducted += 1 + if net_area > 0.0: + cg_x = mx / net_area + cg_z = mz / net_area + xz_basis = ('{0} opening(s) deducted'.format(n_deducted) + if n_deducted else 'solid rectangle') + else: + cg_x = length_ft / 2.0 + cg_z = width_ft / 2.0 + xz_basis = ('openings account for the whole panel area; shown ' + 'at the panel geometric center') + problems.append( + 'Openings account for the whole panel area; the plan ' + 'centroid is shown at the panel geometric center instead.') + + cg_y = None + y_basis = None + thickness_in = _num(values.get('thickness_in')) + section_type = str(values.get('section_type') or '').strip().lower() + if section_type == 'sandwich': + top_in = _num(values.get('wythe_top_in')) + ins_in = _num(values.get('insulation_in')) + bot_in = _num(values.get('wythe_bottom_in')) + layers = (top_in, ins_in, bot_in) + if all(v is not None and v > 0 for v in layers): + total_t = top_in + ins_in + bot_in + cg_y = total_t / 24.0 + y_basis = ('sandwich wythes ({0:.2f} + {1:.2f} + {2:.2f} in), ' + 'half thickness'.format(bot_in, ins_in, top_in)) + elif thickness_in is not None and thickness_in > 0: + cg_y = thickness_in / 24.0 + y_basis = ('solid rectangle, half thickness (sandwich wythe ' + 'thicknesses not fully entered)') + else: + problems.append( + 'Sandwich wythe thicknesses and panel thickness are both ' + 'missing or incomplete; the through-thickness centroid ' + 'cannot be computed.') + else: + if thickness_in is not None and thickness_in > 0: + cg_y = thickness_in / 24.0 + y_basis = 'solid rectangle, half thickness' + else: + problems.append( + 'Thickness (in) is missing or not a positive number; the ' + 'through-thickness centroid cannot be computed.') + + return cg_x, cg_z, cg_y, xz_basis, y_basis, problems + + +# --------------------------------------------------------------------- API + +def lifter_dunnage(values, width_px, height_px): + """(primitives, problems) -- the "Lifter and Dunnage Detail" plan: panel + outline, openings/reveals/solid zones to scale, face lifters as filled + circles, edge lifters as triangles on the edge they sit on (an END + edge triangle pinned to `x = 0`/`length_ft`, a SIDE edge triangle + pinned to `y = 0`/`width_ft` -- see `parse_edge_lifters()`), dunnage as + dashed transverse lines with a 'Dunnage' label (when there is anything + to draw one from -- see `dunnage_positions()`), dimension chains + (lifter x positions along the top, transverse positions along the + left, every label carrying a `font_px` hint) and the centroid marked. + See the module docstring for the layout rule (length always fills the + drawing's full width), the primitive vocabulary, and why it is a hard + stop only for `length_ft` / `width_ft`. + + Never raises. `values` is never mutated. + """ + values = values or {} + problems = [] + + length_ft = _num(values.get('length_ft')) + width_ft = _num(values.get('width_ft')) + if length_ft is None or length_ft <= 0: + problems.append( + 'Length (ft) is missing or not a positive number; the lifter ' + 'and dunnage detail is waiting on it.') + if width_ft is None or width_ft <= 0: + problems.append( + 'Width (ft) is missing or not a positive number; the lifter ' + 'and dunnage detail is waiting on it.') + if problems: + return [], problems + + primitives = [] + margin = _margin(width_px, height_px) + left_margin = _left_margin(width_px, height_px) + avail_w = max(float(width_px) - left_margin - margin, 1.0) + avail_h = max(float(height_px) - 2.0 * margin, 1.0) + + # The panel's LENGTH always fills the drawing's full available width + # (after the reserved left gutter below) -- never letterboxed to + # whichever axis is tighter. Owner, 2026-09-04: a panel is always + # drawn lengthwise, no matter what panel it is. + draw_w = avail_w + draw_h = draw_w * (width_ft / length_ft) + # The LEFT margin is reserved for the transverse dimension chain (see + # the THIRD ruling above `_LEFT_MARGIN_MIN_PX`) -- every other side + # still uses the ordinary `margin`. + ox = left_margin + oy = margin + max(0.0, (avail_h - draw_h) / 2.0) + + primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, + 'h': draw_h, 'role': 'panel-plan'}) + + title = _title_primitive(values.get('mark'), values.get('design_number')) + if title: + primitives.append(title) + + rows, row_problems = _openings_rows(values, length_ft, width_ft) + problems.extend(row_problems) + if rows: + opening_prims, opening_problems = _opening_rect_primitives( + rows, length_ft, width_ft, ox, oy, draw_w, draw_h) + primitives.extend(opening_prims) + problems.extend(opening_problems) + + all_x = [] # every lifter's along-length position, for the top chain + all_y_ft = [] # every lifter's transverse position (ft), for the left chain + + face_rows, face_problems = _face_lifter_rows(values) + problems.extend(face_problems) + for x_ft, frac in face_rows: + flagged = x_ft < 0.0 or x_ft > length_ft or frac < 0.0 or frac > 1.0 + if flagged: + problems.append( + 'Face lifter at {0:.2f} ft / {1:.2f} is outside the panel; ' + 'shown clamped and flagged.'.format(x_ft, frac)) + cx = _clamp(x_ft, 0.0, length_ft) + cf = _clamp(frac, 0.0, 1.0) + px = ox + (cx / length_ft) * draw_w + py = oy + draw_h - cf * draw_h + primitives.append({'kind': 'dot', 'x': px, 'y': py, + 'role': 'face-lifter', 'flagged': flagged}) + all_x.append(cx) + all_y_ft.append(cf * width_ft) + + edge_rows, edge_problems = parse_edge_lifters(values, length_ft, width_ft) + problems.extend(edge_problems) + for row in edge_rows: + x_ft, frac = row['x_ft'], row['frac'] + kind, side = row['kind'], row['side'] + flagged = x_ft < 0.0 or x_ft > length_ft or frac < 0.0 or frac > 1.0 + if flagged: + problems.append( + 'Edge lifter at {0:.2f} ft / {1:.2f} is outside the panel; ' + 'shown clamped and flagged.'.format(x_ft, frac)) + cx = _clamp(x_ft, 0.0, length_ft) + cf = _clamp(frac, 0.0, 1.0) + if kind == 'end': + # Pinned to the actual end edge (x = 0 or x = length_ft), not + # to the lifter's own (possibly slightly-off) x_ft -- see the + # module docstring's 2026-09-04 ruling #1. Transverse position + # varies continuously with frac, never snapped to the panel's + # top/bottom the way a side lifter's longitudinal position is. + px = ox if side == 'bottom' else ox + draw_w + py = oy + draw_h - cf * draw_h + else: + px = ox + (cx / length_ft) * draw_w + py = oy + draw_h if side == 'bottom' else oy + primitives.append({'kind': 'support', 'x': px, 'y': py, + 'role': 'edge-lifter', 'flagged': flagged, + 'edge': side, 'edge_kind': kind, + 'ambiguous': row.get('ambiguous', False)}) + all_x.append(cx) + all_y_ft.append(cf * width_ft) + + if not face_rows and not edge_rows: + problems.append( + 'No face or edge lifter positions entered yet; the drawing ' + 'shows the panel outline only.') + + # ---- dimension chain: lifter x positions from the start, along the top + if all_x: + uniq_x = sorted(set(round(x, 3) for x in all_x)) + top_y = max(oy - margin * 0.6, 4.0) + primitives.append({ + 'kind': 'line', + 'x1': ox + (uniq_x[0] / length_ft) * draw_w, 'y1': top_y, + 'x2': ox + (uniq_x[-1] / length_ft) * draw_w, 'y2': top_y, + 'role': 'dimension'}) + for x_ft in uniq_x: + px = ox + (x_ft / length_ft) * draw_w + primitives.append({'kind': 'line', 'x1': px, 'y1': top_y, + 'x2': px, 'y2': oy, 'role': 'dimension'}) + primitives.append({'kind': 'text', 'x': px, + 'y': max(top_y - 12.0, 0.0), + 'text': '{0:.2f} ft'.format(x_ft), + 'role': 'dimension', 'anchor': 'middle', + 'font_px': 11}) + + # ---- dimension chain: transverse positions, along the left. ONE + # vertical chain (a backbone line the full span of the distinct + # positions, one tick + one label per DISTINCT position -- face + # lifters' AND end-edge lifters' transverse positions both land in + # `all_y_ft` the same way). The chain lives entirely inside the + # reserved `left_margin` gutter (see `_left_margin()`), and two labels + # whose PIXEL y falls within `_LEFT_LABEL_STAGGER_TOL_PX` of each other + # alternate between an inner and an outer column so neither is ever + # drawn on top of the other -- the 2026-09-04 THIRD ruling's fix for a + # live render that showed this as a squashed, illegible stack of ticks. + if all_y_ft: + uniq_y = sorted(set(round(y, 3) for y in all_y_ft)) + left_x = max(ox - left_margin * 0.5, 4.0) + inner_x = max(left_x - 6.0, 4.0) + outer_x = max(left_x - 6.0 - _LEFT_LABEL_STAGGER_OFFSET_PX, 4.0) + y_top_px = oy + draw_h - (uniq_y[-1] / width_ft) * draw_h + y_bot_px = oy + draw_h - (uniq_y[0] / width_ft) * draw_h + primitives.append({'kind': 'line', 'x1': left_x, 'y1': y_top_px, + 'x2': left_x, 'y2': y_bot_px, + 'role': 'dimension'}) + prev_py = None + outer = False + for y_ft in uniq_y: + py = oy + draw_h - (y_ft / width_ft) * draw_h + primitives.append({'kind': 'line', 'x1': left_x, 'y1': py, + 'x2': ox, 'y2': py, 'role': 'dimension'}) + if prev_py is not None and \ + abs(py - prev_py) < _LEFT_LABEL_STAGGER_TOL_PX: + outer = not outer + else: + outer = False + text_x = outer_x if outer else inner_x + primitives.append({'kind': 'text', 'x': text_x, 'y': py, + 'text': '{0:.2f} ft'.format(y_ft), + 'role': 'dimension', 'anchor': 'middle', + 'orientation': 'vertical', 'font_px': 11, + 'stagger': 'outer' if outer else 'inner'}) + prev_py = py + + # ---- dunnage: dashed transverse lines, only when there is data for one + dunnage_x, dunnage_problems = dunnage_positions(values) + problems.extend(dunnage_problems) + if dunnage_x: + for x_ft in dunnage_x: + flagged = x_ft < 0.0 or x_ft > length_ft + cx = _clamp(x_ft, 0.0, length_ft) + px = ox + (cx / length_ft) * draw_w + primitives.append({'kind': 'line', 'x1': px, 'y1': oy, + 'x2': px, 'y2': oy + draw_h, + 'role': 'dunnage', 'dashed': True, + 'flagged': flagged}) + primitives.append({'kind': 'text', 'x': px, + 'y': oy + draw_h + 4.0, 'text': 'Dunnage', + 'role': 'dunnage-label', 'anchor': 'middle', + 'flagged': flagged}) + + # ---- centroid marker + cg_x, cg_z, _cg_y, _xz_basis, _y_basis, cg_problems = centroid(values) + problems.extend(cg_problems) + if cg_x is not None and cg_z is not None: + cg_px = ox + (cg_x / length_ft) * draw_w + cg_py = oy + draw_h - (cg_z / width_ft) * draw_h + primitives.append({'kind': 'marker', 'x': cg_px, 'y': cg_py, + 'role': 'centroid', 'glyph': 'cross'}) + primitives.append({'kind': 'text', 'x': cg_px, + 'y': max(cg_py - 12.0, 0.0), 'text': 'CG', + 'role': 'annotation', 'anchor': 'middle'}) + + primitives = [_clamp_primitive(p, float(width_px), float(height_px)) + for p in primitives] + return primitives, problems + + +# --------------------------------------------------- real-unit geometry (ft) +# +# Added for the HTML-page rewrite (`vibe.pa_pagespec`): no pixels, no +# canvas size, no Revit -- built entirely from this module's own real-unit +# helpers (`_parse_lifter_lines`, `parse_edge_lifters`, `dunnage_positions`, +# `_openings_rows`, `centroid`), the SAME ones `lifter_dunnage()` itself +# calls, so the JSON and the still-live WPF picture can never disagree about +# what a lifter, a dunnage line, or the centroid marker means. + +def lifter_dunnage_geometry(values): + """`{'length_ft', 'width_ft', 'face_lifters': [...], 'edge_lifters': + [...], 'dunnage_positions_ft': [...], 'openings': [...], 'centroid': + {...}, 'problems': [...]}` -- real feet, no pixels. + + `face_lifters` is `[{'x_ft', 'frac'}, ...]`; `edge_lifters` is exactly + what `parse_edge_lifters()` returns (`x_ft` / `frac` / `kind` / `side` / + `ambiguous` / `source` per row); `dunnage_positions_ft` is a flat list of + along-length ft positions (`[]` when there is none to draw, never a + fabricated line); `openings` is the same row shape + `pa_sketch_extents.outline_geometry_ft()` returns (`along` / `vert` / + `cross` ranges in ft). `centroid` is `{'x_ft', 'z_ft', 'y_ft', + 'xz_basis', 'y_basis'}` -- any of the three coordinates can be `None` + when `centroid()` could not compute it (see that function's own + docstring); the two basis sentences say why. + + `length_ft` / `width_ft` are `None` when not entered -- this function + never invents a placeholder rectangle the way `pa_sketch_extents. + outline_geometry_ft()` does, because every one of the lists above is + only meaningful relative to a real length/width and a fabricated pair + would misplace every lifter drawn against it; the page is expected to + show "enter length and width" in that case, named in `problems`, rather + than a picture that looks plausible and is not. Never raises; `values` + is read-only. + """ + values = values or {} + problems = [] + length_ft = _num(values.get('length_ft')) + width_ft = _num(values.get('width_ft')) + if length_ft is None or length_ft <= 0: + problems.append( + 'Length (ft) is missing or not a positive number; lifter and ' + 'dunnage positions cannot be placed yet.') + if width_ft is None or width_ft <= 0: + problems.append( + 'Width (ft) is missing or not a positive number; lifter and ' + 'dunnage positions cannot be placed yet.') + + face_rows, p = _face_lifter_rows(values) + problems.extend(p) + face_lifters = [{'x_ft': x, 'frac': f} for x, f in face_rows] + + edge_lifters, p = parse_edge_lifters(values, length_ft, width_ft) + problems.extend(p) + + dunnage, p = dunnage_positions(values) + problems.extend(p) + + openings = [] + if length_ft and width_ft: + rows, p = _openings_rows(values, length_ft, width_ft) + problems.extend(p) + for r in (rows or []): + openings.append({ + 'type': r.get('type'), 'role': r.get('role'), + 'label': r.get('label'), 'index': r.get('index'), + 'along': list(r['along']) if r.get('along') else None, + 'vert': list(r['vert']) if r.get('vert') else None, + 'cross': list(r['cross']) if r.get('cross') else None, + }) + + cg_x, cg_z, cg_y, xz_basis, y_basis, p = centroid(values) + problems.extend(p) + + return { + 'length_ft': length_ft, 'width_ft': width_ft, + 'face_lifters': face_lifters, 'edge_lifters': edge_lifters, + 'dunnage_positions_ft': dunnage or [], 'openings': openings, + 'centroid': {'x_ft': cg_x, 'z_ft': cg_z, 'y_ft': cg_y, + 'xz_basis': xz_basis, 'y_basis': y_basis}, + 'problems': problems, + } diff --git a/WillTools.extension/lib/vibe/pa_sketch_reinf.py b/WillTools.extension/lib/vibe/pa_sketch_reinf.py new file mode 100644 index 0000000..7a3499f --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_sketch_reinf.py @@ -0,0 +1,1388 @@ +# -*- coding: utf-8 -*- +"""The Panel Analysis Reinforcement tab's own cross-section drawing -- pure +geometry, both Pythons, no Revit, no WPF, zero project imports. + +WHY THIS IS A NEW MODULE AND NOT `panelsketch.section()`: the owner looked at +that drawing and ruled it wrong, 2026-09-04 -- "reinforcing 的 reinforcement +cross-section 也没有正确的绘制出来" ("the reinforcement cross-section still +isn't drawn correctly"). `panelsketch.section()` reads the OLD `strand_rows` +shape (`{'count','depth_in','pull_pct'}`, sourced by parsing the free-text +`ps_rows` field), draws every row centred on the panel's horizontal midline +because that shape carries no x position at all, and knows nothing of mild +bars or mesh. `pa_reinforcement.py`'s sibling module replaced that free text +with structured `strand_row` / `mild_bar` TABLES (owner, same ruling: "rebar +怎么可能是打字上去? ... 你这样计算模型怎么接收这个数据?" -- reinforcement has +to be rows a calculation can actually read), so this module reads THAT shape +instead of the old one -- and reading a different data shape from a +DIFFERENT source is reason enough for a new function; bolting a second +reading convention onto `panelsketch.section()` would leave that function +answering to two different callers with two different ideas of what +`values` means. `panelsketch.py` itself is not touched by this change; it is +free to keep serving whatever still calls `section()`. + +THE INPUT is `values`, the SAME dict `panelanalysisform.py` reads from the +form. Fields used here, verbatim: + + width_ft, thickness_in REQUIRED -- the section rectangle. Missing or + <= 0 is the hard stop: `([], problems)`, no + rectangle, nothing letterboxed, nothing + guessed (same rule `panelsketch.py` documents + for its own `length_ft`/`width_ft`). + section_type, wythe_top_in, insulation_in, wythe_bottom_in + a Sandwich section (`section_type` == + 'Sandwich', case-insensitive) draws its three + layers to scale instead of one outline, the + same rule and the same 1/8" tolerance + `panelsketch.section()` already uses. + strand_rail_location labels the rail datum side ('left'/'right' in + the text -> that edge; anything else, or + blank, -> centred / not labelled). + strand_row..count / .depth_in / .dia_in / .pull_lb + one dot GROUP per row, at true depth from the + bottom (the rail, in casting terms). A row + missing `depth_in` has no honest y to draw at + and is reported, not drawn -- it never falls + back to a guessed cover. The ONLY per-row + label drawn AT the section (the right + margin) -- see LAYOUT below. + strand_x_in, strand_columns_in + OPTIONAL flat list of per-strand x positions + (inches from the section's left edge), read + defensively (checked in that order, the + first that is a list of the right LENGTH -- + one entry per strand across every row, in row + order -- wins). When present, strands are + drawn at their real x and the CG's cgx is a + real number; when absent, every row's dots + are spread evenly across the width instead, + and cgx is reported as not shown -- exactly + the same "an assumption is not a fact" + discipline `panelsketch.py` documents + throughout. + mild_bar..size / .count / .depth_in / .direction / .from_ft / .to_ft + / .spacing_in + A row with a blank `depth_in` is simply not + drawn (`pa_reinforcement.page()` is where + that becomes a quiet sentence). Otherwise: + `direction` == 'Transverse' draws ONE thin + horizontal line across the full section + width at the row's depth -- a bar running + across the panel's width is seen ALONG ITS + LENGTH in this cut, so a single line is the + honest picture regardless of how many bars + the row's `count` names. Anything else + (including blank -- 'Longitudinal' is the + form's own default) draws small dots END-ON + instead, spread evenly across the width or, + when `spacing_in` is a real pitch, at that + pitch. NEVER labelled at the section itself + -- see LAYOUT. + mesh, mesh_layers a non-blank `mesh` draws one dashed line at + mid-depth, or -- for a Sandwich section whose + layers resolved AND `mesh_layers` reads >= 2 + (or is blank, the "probably one per wythe" + default for a sandwich) -- one dashed line + per wythe, at that wythe's own mid-depth. + NEVER labelled on the line -- see LAYOUT. + +LAYOUT (2026-09-04 rewrite, then twice more amended the same day off two +further live-render findings -- see each amendment's own note below). The +canvas stacks three bands, top to bottom: + + title row (20 px) -- 'REINFORCEMENT CROSS-SECTION', centred, plus + '(vertical scale ×n)' when the section's + depth axis is stretched beyond its true + proportion -- see AMENDMENT 2. + the section itself -- scaled to fill whatever height is left after + the title row, the bottom band and the + legend, and the full width minus a FIXED + left margin (`_LEFT_MARGIN`, 70 px) and a + wider FIXED right margin (`_RIGHT_MARGIN`, + 130 px) -- neither grows with row count. The + width label sits directly under the rect's + own left corner; the rail label sits below + that. Only strand rows get a per-row label + here: a short depth call-out in the left + margin, a short qty/dia/depth label in the + right margin, one line per row, at that + row's own y -- never stacked in lanes. + the LEGEND row(s) -- every drawn GROUP (each strand row, each mild + bar row that is drawn, mesh, the strand-group + CG) gets exactly one line here, symbol first + (● strand, — transverse bar, · longitudinal + bar, mesh, + CG), laid out LEFT TO RIGHT IN + COLUMNS (capped at `_LEGEND_MAX_COLS` -- see + AMENDMENT 3) and wrapping to a new legend row + only when a row's columns fill up. This is + where every group's full description lives + -- nothing is ever labelled a second time + inside the section rectangle itself. + +`pa_reinforcement.py`'s drawing block asks `aspect_for(values)` for the +FULL aspect it should request -- `clamp_aspect()`'s true section +proportion plus the fixed title/bottom bands plus THIS `values`' own +legend band (see AMENDMENT 3), so the canvas the renderer builds always +has room for all three bands without `pa_reinforcement.py` having to know +anything about legend rows, margins or vertical scale itself. + +ORIGINAL FINDING (2026-09-04): a canvas clamped to `aspect=0.12` for a +thin, wide panel left ~140 px total, five bar-row labels stacked on top of +each other, strand labels and the CG marker clipped at the right edge, the +width label cut at the bottom -- fixed by the three-band layout above. + +AMENDMENT 1 (`strand_x_in` is INCHES, not feet): a live render with +`width_ft=8.375` and `strand_x_in` values up to 94.5 piled every strand +dot at the right edge -- `explicit_x[i] / width_ft` divided an inches +value by a feet one, so every fraction clamped to 1.0. Fixed by converting +once, in `width_in = width_ft * 12.0`, and dividing by THAT everywhere a +`strand_x_in` value is turned into a fraction of the width (dot x, CG +cgx) -- see `_explicit_strand_x`'s own docstring, which also now DROPS +(never clamps) a candidate list carrying any value more than 1 in beyond +`width_in`, falling back to spread-evenly exactly as an absent list would. + +AMENDMENT 2 (thin sections were a mostly-empty sliver): a very thin, wide +panel's TRUE-to-scale `draw_h` can be a fraction of a generously tall +band, drawn small and centred with the band mostly empty above and below +it. `section()` now amplifies the vertical scale alone (never `draw_w`, +never the true thickness/width the depth axis maps against) up to +`_MAX_VSCALE`, enough that the rect fills at least `_MIN_BAND_FRACTION` of +its band -- and ANNOUNCES the distortion in the title row rather than +letting a stretched drawing pass as to-scale. + +AMENDMENT 3 (a busy legend was clipped, so was the last legend row): a +schedule with many groups was clipped on two sides at once -- the +right-margin strand labels ran past a margin sized for a shorter label, +and a flat `ASPECT_EXTRA` guessed too little canvas height for a legend +with more rows than it assumed. Both are fixed together: `_RIGHT_MARGIN` +widened to fit a full "qty × dia @ depth" label, legend columns capped at +`_LEGEND_MAX_COLS` so a wide canvas cannot shrink each column below what +an entry needs, and `aspect_for(values)` -- exported from THIS module, +`pa_reinforcement.py`'s own `_section_aspect` now calls it -- computes the +legend's real row count for THIS `values` and folds its real pixel height +into the requested aspect, so a busy schedule asks for a taller canvas +instead of guessing a flat constant. + +`section(values, width_px, height_px)` returns `(primitives, problems)` and +NEVER RAISES -- every problem becomes a plain sentence, and every field this +module cannot honestly place is left off the drawing rather than guessed. +`primitives` reuses the SAME vocabulary `panelsketch.py` and the button's +`_draw_primitive` already speak (plain dicts, pixel coordinates, origin +top-left, y increasing downward, everything clamped inside `[0, width_px] x +[0, height_px]`): `rect`, `line`, `text`, `dot`, `marker` (glyph 'cross' / +'diamond'). No new primitive `kind` is introduced -- an unrecognised one +would just be silently skipped by the renderer (see `_draw_primitive`'s own +docstring), which would make a drawing element quietly vanish instead of +degrading loudly, so this module draws the CG as a `dot` (the circle) plus a +`marker` (the cross) at the same point rather than inventing a +"crossed-circle" kind. New `role` strings ARE introduced (`bar`, `mesh`, +`rail-label`, `legend`, `dimension`) -- an unrecognised role just falls back +to the renderer's default grey, which is a cosmetic gap, not a functional +one, and is the renderer's (a sibling's) to close by extending its own +role/brush tables, not this module's to work around. + +ZERO TOP-LEVEL PROJECT IMPORTS, on purpose, mirroring `panelsketch.py`'s +own stated reason for not importing `panelanalysisform`: a sibling agent is +restructuring that module at the same time this file was written, and a leaf +module cannot be broken by a change three layers away. So the small pieces +this module needs from elsewhere are duplicated here, deliberately: + + * `row_ids` / `row_key` -- the same `
..` / `
.. + present` key arithmetic `pa_reinforcement.py` also implements (see that + module's docstring for why the shape exists and where it is headed). + * `_STRAND_AREA_IN2` -- the same PUBLISHED nominal strand areas + `panelanalysisform._STRAND_AREA_IN2` carries (0.153 in² for a 1/2" strand + and so on): these are documented industry constants, not a guess, so + copying them here is not the "invented capacity" that module's own + docstring warns against -- inventing a NUMBER NOT IN THAT TABLE would be. + A diameter not in this table draws with no Aps contribution and a named + problem, same as `pa_reinforcement.py`'s own summary does. + +The ONE deliberate exception is the location-chain rule (issue #23 item 9a, +2026-09-07): `section_geometry` / `used_fallback_strand_x` reach +`pa_reinforcement.strand_positions` / `parse_length_in` for a row's `chain` +cell -- the page's own summary and the drawing must resolve a chain IDENTICALLY +or the numbers would drift. That import is FUNCTION-LOCAL and guarded, never +at module scope, precisely because `pa_reinforcement` imports THIS module at +the top (for `aspect_for` / `used_fallback_strand_x`): a top-level import here +would be circular, and a guarded on-demand import degrades a missing/broken +sibling to "chain unresolved" in `problems` instead of a crash. `section()` +(the pixel drawing) does not consume chains and so never enters the cycle. + +Must run on IronPython 2.7 in the button (no f-strings, no typing, no +dataclasses, no `pathlib`) AND on plain CPython under `WillTools.extension/ +tests/conftest.py`'s import trick. +""" + + +# --------------------------------------------------------------- constants + +_ASPECT_MIN = 0.12 +_ASPECT_MAX = 0.4 +_ASPECT_DEFAULT = 0.2 + +# A TYPICAL "MORE aspect" figure (canvas height / width) on TOP of +# `clamp_aspect()`'s own [0.12, 0.4] -- kept only as the LOCAL FALLBACK +# `pa_reinforcement._section_aspect` uses on the rare path where +# `aspect_for` itself is not reachable (see that function's own docstring, +# and the module docstring's LAYOUT section). The REAL number a live +# `values` needs is `aspect_for(values)`, which folds in that particular +# schedule's own legend size instead of guessing a flat constant. +ASPECT_EXTRA = 0.22 + +# Fixed-pixel layout bands -- see the module docstring's LAYOUT section. +# None of these grow with STRAND row count any more (2026-09-04): a +# schedule with many rows gets more LEGEND ROWS, never a wider margin or a +# taller title. The right margin is wider than the left (2026-09-04, +# live-render finding: a "qty × dia @ depth" strand label needs more room +# than the left margin's short depth call-out, and 60 px was clipping it). +_TITLE_H = 20.0 +_LEFT_MARGIN = 70.0 +_RIGHT_MARGIN = 130.0 +_BOTTOM_H = 32.0 # width label + rail label, under the rect +_LEGEND_ROW_H = 14.0 +_LEGEND_COL_W = 200.0 +_LEGEND_PAD = 6.0 +# Legend columns are CAPPED at 3 (2026-09-04) -- a wide canvas dividing +# into many narrow columns was clipping the longer entries ("12 × #4 +# transverse @ 1.50 in, 36 in o.c."); 3 columns keeps each one wide enough +# to fit, and `aspect_for` assumes this same cap when it estimates the +# legend's row count ahead of knowing the real canvas width. +_LEGEND_MAX_COLS = 3 +# The reference width `aspect_for` assumes when converting the legend's +# PIXEL height into a fraction of width (an aspect ratio has no pixel +# width of its own to divide by until the renderer picks one) -- matches +# the width this module's own tests, and typical real renders, use. +_ASPECT_REF_WIDTH_PX = 700.0 + +# A THIN section (the drawn rect using less than this fraction of its +# vertical band under a TRUE-to-scale letterbox) gets its vertical scale +# amplified beyond the horizontal one, up to `_MAX_VSCALE`, so a thin wide +# panel is not a ~50 px sliver in a mostly-empty canvas (2026-09-04 +# live-render finding). This is a DELIBERATE, ANNOUNCED distortion -- see +# `section()`'s own title-row text -- never a change to the true thickness/ +# width the depth axis maps against. +_MIN_BAND_FRACTION = 0.6 +_MAX_VSCALE = 4.0 + +_STRAND_TABLE = 'strand_row' +_MILD_TABLE = 'mild_bar' + +# Published nominal strand areas, in² -- see the module docstring for why +# duplicating THESE PARTICULAR constants (not inventing new ones) is honest. +_STRAND_AREA_IN2 = { + '0.375': 0.085, '3/8': 0.085, + '0.4375': 0.115, '7/16': 0.115, + '0.5': 0.153, '1/2': 0.153, + '0.52': 0.167, '1/2 SP': 0.167, # the 1/2" special (issue #23, 9d) + '0.5625': 0.192, '9/16': 0.192, + '0.6': 0.217, '3/5': 0.217, + '0.7': 0.294, +} + + +# ------------------------------------------------------------------ parsing + +def _num(value): + """A float, or None. Accepts a number already, or text; blank -> None.""" + if value is None: + return None + if isinstance(value, bool): # bool is an int subclass; not a number here + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except (TypeError, ValueError): + return None + + +def _text(value): + if value is None: + return None + if not hasattr(value, 'strip'): + try: + value = str(value) + except Exception: + return None + value = value.strip() + return value or None + + +# --------------------------------------------------------- row-table helpers +# Identical arithmetic to `pa_reinforcement.row_ids` / `row_key` -- see the +# module docstring for why this is duplicated rather than imported. + +def row_key(table, n, col): + return '{0}.{1}.{2}'.format(table, n, col) + + +def row_ids(table, values): + """Every present row id for `table`, ascending, as ints -- the same + shape `panelanalysisform.row_ids` returns (this module does not import + that function -- see the module docstring -- but matches its contract + so a value read here never needs a type coercion a caller of THAT + function would not also need).""" + values = values or {} + prefix = '{0}.'.format(table) + suffix = '.present' + ids = [] + for key in values: + if not hasattr(key, 'startswith') or not hasattr(key, 'endswith'): + continue + if not key.startswith(prefix) or not key.endswith(suffix): + continue + n_txt = key[len(prefix):-len(suffix)] + if not n_txt or '.' in n_txt: + continue + if values.get(key) not in ('yes', True): + continue + try: + n = int(n_txt) + except (TypeError, ValueError): + continue + ids.append(n) + ids.sort() + return ids + + +def _cell(values, table, n, col): + return (values or {}).get(row_key(table, n, col)) + + +def _strand_rows(values): + """([{'id','count','depth_in','dia_in','pull_lb','chain'}, ...], + problems). + + Only rows with a readable `depth_in` are returned -- there is no honest + y to draw the rest at. A row with no readable `count` is kept (drawn as + a single dot, count 1) and reported, matching `panelsketch.py`'s own + `_strand_rows_of` rule. `chain` is the raw `Location chain from rail` + cell ('' when blank); resolving it is `section_geometry`'s job. + """ + rows = [] + problems = [] + for n in row_ids(_STRAND_TABLE, values): + depth = _num(_cell(values, _STRAND_TABLE, n, 'depth_in')) + if depth is None: + problems.append( + 'Strand row {0} has no depth to CG entered; not drawn.' + .format(n)) + continue + count = _num(_cell(values, _STRAND_TABLE, n, 'count')) + count = int(count) if count is not None else None + dia = _text(_cell(values, _STRAND_TABLE, n, 'dia_in')) + pull = _num(_cell(values, _STRAND_TABLE, n, 'pull_lb')) + chain = _text(_cell(values, _STRAND_TABLE, n, 'chain')) + rows.append({'id': n, 'count': count, 'depth_in': depth, + 'dia_in': dia, 'pull_lb': pull, 'chain': chain}) + return rows, problems + + +def _mild_rows(values): + """[{'id','size','count','depth_in','direction','spacing_in', + 'from_ft','to_ft'}, ...] -- every row, including one with a blank depth + (the caller simply does not draw that one; `pa_reinforcement.page()` is + where its absence becomes a quiet sentence). `from_ft`/`to_ft` are the + row's own length-axis position basis (the `mild_bar` table's From/To + cells) -- read so both a plan (`outline_geometry`) and the section can + give a bar row its own independent dimension chain (issue #23 item 9i).""" + rows = [] + for n in row_ids(_MILD_TABLE, values): + depth = _num(_cell(values, _MILD_TABLE, n, 'depth_in')) + count = _num(_cell(values, _MILD_TABLE, n, 'count')) + count = int(count) if count is not None else None + size = _text(_cell(values, _MILD_TABLE, n, 'size')) + direction = _text(_cell(values, _MILD_TABLE, n, 'direction')) + spacing = _num(_cell(values, _MILD_TABLE, n, 'spacing_in')) + from_ft = _num(_cell(values, _MILD_TABLE, n, 'from_ft')) + to_ft = _num(_cell(values, _MILD_TABLE, n, 'to_ft')) + rows.append({'id': n, 'size': size, 'count': count, + 'depth_in': depth, 'direction': direction, + 'spacing_in': spacing, 'from_ft': from_ft, + 'to_ft': to_ft}) + return rows + + +def _is_transverse(direction): + """True for 'Transverse' (case-insensitive, prefix match so 'trans' + typed by hand still counts); everything else -- including blank -- is + Longitudinal, the form's own default (`MILD_BAR_DIRECTION_CHOICES[0]` + in `panelanalysisform.py`).""" + if not direction: + return False + return str(direction).strip().lower().startswith('trans') + + +def _explicit_strand_x(values, total_count, width_in): + """The flat per-strand x list (INCHES from the rail/left edge), if + `values` carries one of the right length AND every value is honestly + inside the panel -- `strand_x_in` checked first, then + `strand_columns_in`, read defensively (a non-list, or a list of the + wrong length, is simply not used). + + `width_in` is the panel width IN INCHES (`width_ft * 12`) -- a live + render (2026-09-04) piled every strand dot at the right edge because + the caller was dividing an inches list by `width_ft`, a number roughly + 12x too small, so every fraction clamped to 1.0. Passing the already- + converted inches value here (rather than converting inside this + function from a `width_ft` it would have to trust unchecked) keeps the + unit conversion in exactly ONE place -- `section()`'s own `width_in`. A + candidate list with any value more than 1 in beyond `width_in` is not a + clamping matter -- it is DROPPED entirely (the whole list refused, not + silently clamped to the edge) and the caller falls back to spread + evenly, same as an absent/wrong-length list; `width_in` missing or <= 0 + skips this particular check (nothing to validate against) rather than + refusing everything. + """ + if total_count <= 0: + return None + for key in ('strand_x_in', 'strand_columns_in'): + raw = values.get(key) + if not isinstance(raw, (list, tuple)): + continue + xs = [] + for v in raw: + n = _num(v) + if n is not None: + xs.append(n) + if len(xs) != total_count: + continue + if width_in and width_in > 0 and any(x > width_in + 1.0 + for x in xs): + continue + return xs + return None + + +def used_fallback_strand_x(values): + """True when `section()`/`section_geometry()` spread some strand dots + evenly across the width for want of a real per-strand x -- the exact + same check those functions make internally, exposed so + `pa_reinforcement.page()` can ask the identical question (to build its + own quiet sentence) without re-deriving the length-match / in-range / + chain-resolution rule. False when there are no strand rows at all -- + nothing to spread -- matching `section()`'s own `if rows and explicit_x + is None` gate. + + A real per-strand x is either the legacy flat `strand_x_in` / + `strand_columns_in` list (`_explicit_strand_x`) OR a `chain` cell on + every row that resolves via `pa_reinforcement.strand_positions` (issue + #23 item 9a, 2026-09-07 -- the chain is now a real position source, so + an all-chained schedule is not "spread evenly"). A MIXED schedule -- + some rows chained, some blank -- still spreads the blank rows, so it + reports True (the geometry's own `problems` name exactly which rows are + blank). `pa_reinforcement` is imported on demand (see + `section_geometry`); an import failure leaves the legacy-`strand_x_in`- + only answer, the safe "it is spread" direction. + """ + rows, _problems = _strand_rows(values) + if not rows: + return False + total = sum((r['count'] if r['count'] and r['count'] > 0 else 1) + for r in rows) + width_ft = _num(values.get('width_ft')) + width_in = width_ft * 12.0 if width_ft and width_ft > 0 else None + if _explicit_strand_x(values, total, width_in) is not None: + return False + try: + from vibe import pa_reinforcement as _par + except Exception: + return True + rail_loc = values.get('strand_rail_location') + rail_off = values.get('strand_rail_offset_in') + for r in rows: + if not (r.get('chain') or '').strip(): + return True # a blank row is spread + if r.get('count') is None: + return True + positions, err = _par.strand_positions( + r['count'], r['chain'], rail_loc, rail_off, width_in, + row_label='strand row {0}'.format(r['id'])) + if err or positions is None: + return True + return False + + +def _strand_area(dia_text): + if not dia_text: + return None + return _STRAND_AREA_IN2.get(str(dia_text).strip()) + + +# ------------------------------------------------------------------ layout + +def _clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + + +def _letterbox(a, b, avail_w, avail_h): + """draw_w, draw_h -- `a` x `b` scaled uniformly to fit inside `avail_w` + x `avail_h`, preserving the aspect ratio (same helper `panelsketch.py` + keeps under this exact name).""" + aspect = a / b + box_aspect = avail_w / avail_h + if aspect >= box_aspect: + draw_w = avail_w + draw_h = draw_w / aspect + else: + draw_h = avail_h + draw_w = draw_h * aspect + return draw_w, draw_h + + +def _clamp_primitive(prim, width_px, height_px): + kind = prim.get('kind') + if kind == 'rect': + x = _clamp(prim['x'], 0.0, width_px) + y = _clamp(prim['y'], 0.0, height_px) + w = prim['w'] + h = prim['h'] + if x + w > width_px: + w = width_px - x + if y + h > height_px: + h = height_px - y + if w < 0.0: + w = 0.0 + if h < 0.0: + h = 0.0 + prim['x'], prim['y'], prim['w'], prim['h'] = x, y, w, h + elif kind == 'line': + prim['x1'] = _clamp(prim.get('x1', 0.0), 0.0, width_px) + prim['y1'] = _clamp(prim.get('y1', 0.0), 0.0, height_px) + prim['x2'] = _clamp(prim.get('x2', 0.0), 0.0, width_px) + prim['y2'] = _clamp(prim.get('y2', 0.0), 0.0, height_px) + else: + if 'x' in prim: + prim['x'] = _clamp(prim['x'], 0.0, width_px) + if 'y' in prim: + prim['y'] = _clamp(prim['y'], 0.0, height_px) + return prim + + +def clamp_aspect(width_ft, thickness_in): + """thickness/width as a canvas aspect ratio, clamped to [0.12, 0.4]. + + This is ONLY the section rectangle's own true-to-scale proportion -- + `aspect_for(values)` is what a caller actually wants (it adds the + title/bottom/legend bands' own real height on top of this, for THIS + `values`' own legend size -- see that function and the module + docstring's LAYOUT section). This function's return value is never + changed by that addition, so "how tall is the rect, to scale" and "how + much fixed chrome does the canvas need" stay two separate questions. + Missing/non-positive input is not this function's business to judge -- + it returns the same default `section()` would draw at, never raises, + never divides by zero. + """ + w = _num(width_ft) + t = _num(thickness_in) + if not w or w <= 0 or not t or t <= 0: + return _ASPECT_DEFAULT + a = (t / 12.0) / w + if a < _ASPECT_MIN: + return _ASPECT_MIN + if a > _ASPECT_MAX: + return _ASPECT_MAX + return a + + +def _legend_entry_count(values): + """How many legend lines `section(values, ...)` would draw -- one per + strand row, one per mild-bar row that HAS a depth (an undrawn row gets + no legend line either), one for mesh if non-blank, one for the + strand-group CG if any strand row names a usable quantity. Computed + off the SAME reads `section()` itself uses (`_strand_rows`, + `_mild_rows`, the `mesh` field), so this can never drift from what + `section()` actually draws -- it does not rebuild the legend TEXT + (which needs strand-area lookups `aspect_for` has no business doing), + only counts entries.""" + rows, _problems = _strand_rows(values) + mild_rows = _mild_rows(values) + n = len(rows) + n += sum(1 for r in mild_rows if r['depth_in'] is not None) + if _text(values.get('mesh')): + n += 1 + if rows: + total = sum((r['count'] if r['count'] and r['count'] > 0 else 1) + for r in rows) + if total > 0: + n += 1 + return n + + +def aspect_for(values): + """The FULL aspect (canvas height / width) `pa_reinforcement.py`'s + drawing block should ask for: `clamp_aspect()`'s true section + proportion PLUS the fixed title/bottom bands PLUS THIS `values`' own + legend band, sized off how many legend lines its strand/bar/mesh/CG + groups actually need (`_legend_entry_count`, capped at + `_LEGEND_MAX_COLS` columns per row -- the same cap `section()` itself + applies). A schedule with many rows gets a correspondingly taller + canvas instead of a fixed guess (2026-09-04 -- the previous flat + `ASPECT_EXTRA` constant clipped the last legend row on a busy + schedule). + + The legend's PIXEL height is converted to a fraction of width against + `_ASPECT_REF_WIDTH_PX` -- an aspect ratio has no real canvas width to + divide by until the renderer picks one, so this is necessarily an + ESTIMATE against a typical width; `section()` itself always sizes the + legend correctly against whatever REAL `width_px` it receives, + regardless of what this function assumed. Never raises. + """ + base = clamp_aspect(values.get('width_ft'), values.get('thickness_in')) + n_entries = _legend_entry_count(values) + legend_rows_n = 0 + if n_entries: + legend_rows_n = (n_entries + _LEGEND_MAX_COLS - 1) \ + // _LEGEND_MAX_COLS + legend_h = (legend_rows_n * _LEGEND_ROW_H + _LEGEND_PAD) if n_entries \ + else 0.0 + extra = (_TITLE_H + _BOTTOM_H + legend_h) / _ASPECT_REF_WIDTH_PX + return base + extra + + +# ------------------------------------------------------------------ legend + +def _strand_legend_entry(row, default_dia_text): + dia_text = row['dia_in'] or default_dia_text or '?' + qty_text = str(row['count']) if row['count'] is not None else '1' + text = u'{0} × {1}″ strand @ {2:.2f} in'.format( + qty_text, dia_text, row['depth_in']) + return (u'●', text) # ● + + +def _bar_legend_entry(row): + label = row['size'] or 'bar' + qty_text = str(row['count']) if row['count'] is not None else '1' + transverse = _is_transverse(row['direction']) + dir_word = 'transverse' if transverse else 'longitudinal' + text = u'{0} × {1} {2} @ {3:.2f} in'.format( + qty_text, label, dir_word, row['depth_in']) + if row['spacing_in']: + text += u', {0:.2f} in o.c.'.format(row['spacing_in']) + symbol = u'—' if transverse else u'·' # — or · + return (symbol, text) + + +def _mesh_legend_entry(mesh_text): + return (u'- -', u'Mesh: {0}'.format(mesh_text)) + + +def _cg_legend_entry(text): + return (u'+', text) + + +# --------------------------------------------------------------------- API + +def section(values, width_px, height_px): + """(primitives, problems) -- the Reinforcement tab's cross-section, laid + out as title row / section (full width minus fixed side margins, + scaled to the remaining height) / legend row(s) -- see the module + docstring's LAYOUT section for the rationale (2026-09-04 rewrite, fixing + a live render where a thin wide panel's clamped-aspect canvas left every + label stacked or clipped). + + `problems` is reserved for GENUINE failures -- the section rectangle + could not be drawn at all, a Sandwich panel's layers do not add up, a + row's depth falls outside the panel thickness. A degraded-but-honest + choice this function makes on its own (dots spread evenly for want of + `strand_x_in`, a depth-less mild bar row left undrawn) is NOT a + `problems` entry (2026-09-04); the sibling `pa_reinforcement.page()` is + where that fact becomes the page's own quiet sentence, computed + independently off the same `values` (this module still takes zero + project imports -- see the module docstring). + + `width_ft` / `thickness_in` missing or <= 0 is the only FATAL case: + `([], problems)`, nothing drawn. Every other missing input narrows what + is drawn without blocking the rest -- see the module docstring's field + list for exactly what each one gates. Never raises, never mutates + `values`. + """ + values = values or {} + problems = [] + + width_ft = _num(values.get('width_ft')) + thickness_in = _num(values.get('thickness_in')) + if width_ft is None or width_ft <= 0: + problems.append( + 'Panel width (ft) is missing or not a positive number; the ' + 'reinforcement cross-section is waiting on it.') + if thickness_in is None or thickness_in <= 0: + problems.append( + 'Panel thickness (in) is missing or not a positive number; ' + 'the reinforcement cross-section is waiting on it.') + if problems: + return [], problems + + thickness_ft = thickness_in / 12.0 + width_in = width_ft * 12.0 # `strand_x_in` is inches, not feet -- + # see `_explicit_strand_x`'s own + # docstring for the bug this avoids. + width_px = float(width_px) + height_px = float(height_px) + + rows, row_problems = _strand_rows(values) + problems.extend(row_problems) + mild_rows = _mild_rows(values) + mesh_text = _text(values.get('mesh')) + default_dia_text = _text(values.get('ps_dia_in')) + + total_strand_count = sum((r['count'] if r['count'] and r['count'] > 0 + else 1) for r in rows) + explicit_x = _explicit_strand_x(values, total_strand_count, width_in) \ + if rows else None + # Dots spread evenly for want of a real per-strand x is an honest, + # expected degradation, not a `problems` entry (2026-09-04) -- + # `used_fallback_strand_x(values)` is how `pa_reinforcement.page()` asks + # the same question for its own quiet sentence. + + # ---- section_type / sandwich resolution (needed for the CG/Aps loop + # below and the rect itself; computed before layout since it changes no + # geometry, only which layers draw). + section_type = str(values.get('section_type') or '').strip().lower() + is_sandwich = section_type == 'sandwich' + top_in = ins_in = bot_in = None + sandwich_ok = False + if is_sandwich: + top_in = _num(values.get('wythe_top_in')) + ins_in = _num(values.get('insulation_in')) + bot_in = _num(values.get('wythe_bottom_in')) + layers = (top_in, ins_in, bot_in) + if any(v is None or v <= 0 for v in layers): + problems.append( + 'Sandwich section: the wythe / insulation thicknesses are ' + 'not all entered; drawn as one outline.') + elif abs(sum(layers) - thickness_in) > 0.125: + problems.append( + 'Sandwich section: {0:.2f} + {1:.2f} + {2:.2f} in do not ' + 'add up to the {3:.2f} in thickness; drawn as one outline.' + .format(top_in, ins_in, bot_in, thickness_in)) + else: + sandwich_ok = True + + # ---- legend entries: one per drawn group, symbol first -- see the + # module docstring's LAYOUT section. Built BEFORE pixel layout so the + # legend band's own height (which depends only on entry COUNT) can be + # carved out of `height_px` before the section rect is sized. + legend = [] + for row in rows: + legend.append(_strand_legend_entry(row, default_dia_text)) + for row in mild_rows: + if row['depth_in'] is None: + continue + legend.append(_bar_legend_entry(row)) + if mesh_text: + legend.append(_mesh_legend_entry(mesh_text)) + # The CG entry's text is computed later (it needs `draw_w`/`ox` for + # `cgx_in` when real strand x's are known) but its PRESENCE in the + # legend -- hence the legend's row count, hence the layout -- is + # decided here: exactly when `rows` names at least one usable strand. + cg_total_n = sum((r['count'] if r['count'] and r['count'] > 0 else 1) + for r in rows) + if rows and cg_total_n > 0: + legend.append(_cg_legend_entry('')) # placeholder; filled below + elif rows: + problems.append( + 'Strand rows have no usable quantity; the group CG could not ' + 'be computed.') + if not rows: + problems.append( + 'No strand rows entered; the cross-section is drawn with no ' + 'strands.') + + # Columns capped at `_LEGEND_MAX_COLS` (2026-09-04) -- see that + # constant's own comment; `aspect_for` assumes this same cap. + n_cols = min(_LEGEND_MAX_COLS, max(1, int(width_px // _LEGEND_COL_W))) + legend_rows_n = 0 + if legend: + legend_rows_n = (len(legend) + n_cols - 1) // n_cols + legend_h = (legend_rows_n * _LEGEND_ROW_H + _LEGEND_PAD) if legend \ + else 0.0 + + # ---- pixel layout: title / section / legend, fixed margins (the right + # one wider than the left -- see `_RIGHT_MARGIN`'s own comment). See + # the module docstring's LAYOUT section for why none of this grows + # with STRAND row count any more. + avail_w = width_px - _LEFT_MARGIN - _RIGHT_MARGIN + if avail_w < 1.0: + avail_w = 1.0 + remaining_h = height_px - _TITLE_H - _BOTTOM_H - legend_h + if remaining_h < 1.0: + remaining_h = 1.0 + + draw_w, draw_h = _letterbox(width_ft, thickness_ft, avail_w, remaining_h) + + # A THIN section's TRUE-to-scale `draw_h` can be a sliver of a + # generously tall `remaining_h` band (2026-09-04 live-render finding: + # ~50 px inside a ~300 px canvas, half of it empty above the rect). + # The vertical scale is amplified beyond the horizontal one -- up to + # `_MAX_VSCALE` -- until the rect fills at least `_MIN_BAND_FRACTION` + # of the band, capped so it never overflows the band either. `draw_w` + # is NEVER touched by this -- only the depth axis stretches, and the + # title row announces exactly how much so nobody reads the drawing as + # to-scale in both directions. + vscale = 1.0 + min_h = _MIN_BAND_FRACTION * remaining_h + if 0.0 < draw_h < min_h: + vscale = min(_MAX_VSCALE, min_h / draw_h) + new_h = draw_h * vscale + if new_h > remaining_h: + new_h = remaining_h + vscale = new_h / draw_h + draw_h = new_h + + ox = _LEFT_MARGIN + (avail_w - draw_w) / 2.0 + oy = _TITLE_H + (remaining_h - draw_h) / 2.0 + bottom_y = oy + draw_h + scale_per_ft = draw_h / thickness_ft # px per ft of depth (post-vscale) + + def depth_y(depth_in, flagged_out=None): + depth_ft = depth_in / 12.0 + flagged = depth_ft < 0.0 or depth_ft > thickness_ft + clamped_ft = _clamp(depth_ft, 0.0, thickness_ft) + if flagged_out is not None: + flagged_out.append(flagged) + return bottom_y - clamped_ft * scale_per_ft + + primitives = [] + + title_text = 'REINFORCEMENT CROSS-SECTION' + if vscale > 1.001: + title_text += u' (vertical scale ×{0:.1f})'.format(vscale) + primitives.append({'kind': 'text', 'x': width_px / 2.0, + 'y': _TITLE_H - 6.0, + 'text': title_text, + 'role': 'note', 'anchor': 'middle'}) + + # ---- concrete section: one outline, or Sandwich wythes/insulation + sandwich_drawn = False + if is_sandwich and sandwich_ok: + sandwich_drawn = True + px_per_in = draw_h / thickness_in + y_cursor = bottom_y + for name, layer_in, role in ( + ('Bottom wythe', bot_in, 'wythe'), + ('Insulation', ins_in, 'insulation'), + ('Top wythe', top_in, 'wythe')): + layer_h = layer_in * px_per_in + y_cursor -= layer_h + primitives.append({'kind': 'rect', 'x': ox, 'y': y_cursor, + 'w': draw_w, 'h': layer_h, 'role': role}) + primitives.append({ + 'kind': 'text', 'x': ox + draw_w + 4.0, + 'y': y_cursor + layer_h / 2.0 - 6.0, + 'text': '{0}: {1:.2f} in'.format(name, layer_in), + 'role': 'layer-label', 'anchor': 'start'}) + + if not sandwich_drawn: + primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, + 'h': draw_h, 'role': 'panel-section'}) + + # ---- width label, under the rect's own LEFT corner (2026-09-04 -- it + # used to be a per-strand-x dimension chain across the whole bottom, + # which is what was getting clipped/cut off; the panel's overall width + # is the one number that belongs here, and it always fits inside the + # fixed `_BOTTOM_H` band because it never grows with row count). + primitives.append({'kind': 'text', 'x': ox, 'y': bottom_y + 14.0, + 'text': '{0:.2f} in'.format(width_in), + 'role': 'dimension', 'anchor': 'start'}) + + # ---- rail datum label -- below the width label, >= 12 px apart so the + # two can never collide regardless of how wide either one renders. + rail_loc = _text(values.get('strand_rail_location')) + if rail_loc: + key = rail_loc.lower() + if 'left' in key: + rx = ox + elif 'right' in key: + rx = ox + draw_w + else: + rx = ox + draw_w / 2.0 + primitives.append({'kind': 'text', 'x': rx, 'y': bottom_y + 28.0, + 'text': 'RAIL ({0})'.format(rail_loc), + 'role': 'rail-label', 'anchor': 'middle'}) + else: + problems.append( + 'Strand rail location is not entered; the rail datum side is ' + 'not labelled on the drawing.') + + # ---- strand rows: dots, plus the ONLY per-row canvas labels this + # drawing still carries -- a short depth call-out in the LEFT margin + # and a short qty/dia/depth label in the RIGHT margin, one line per + # row at that row's own y (never stacked in lanes; the full + # description of every row also lives in the legend below). + flat_index = 0 + for row in rows: + depth_in = row['depth_in'] + flagged_out = [] + y = depth_y(depth_in, flagged_out) + flagged = flagged_out[0] + if flagged: + problems.append( + 'Strand row {0} at {1:.2f} in from the bottom is outside ' + 'the panel thickness (0 to {2:.2f} in); shown clamped and ' + 'flagged.'.format(row['id'], depth_in, thickness_in)) + + count = row['count'] if row['count'] and row['count'] > 0 else 1 + if row['count'] is None: + problems.append( + 'Strand row {0} has no quantity entered; drawn as a ' + 'single dot at its depth.'.format(row['id'])) + + dot_count = count if count <= 20 else 20 + if count > 20: + problems.append( + 'Strand row {0} has {1} strands; only 20 dots drawn (the ' + 'label carries the true count).'.format(row['id'], count)) + + xs = [] + if explicit_x is not None: + for i in range(dot_count): + idx = flat_index + i + if idx < len(explicit_x) and width_in: + frac = _clamp(explicit_x[idx] / width_in, 0.0, 1.0) + xs.append(ox + frac * draw_w) + if not xs: + spacing = draw_w / float(count + 1) + xs = [ox + spacing * (i + 1) for i in range(dot_count)] + + for x in xs: + primitives.append({'kind': 'dot', 'x': x, 'y': y, + 'role': 'strand', 'flagged': flagged}) + + # left-margin depth call-out + primitives.append({'kind': 'line', 'x1': ox - 8.0, 'y1': y, + 'x2': ox, 'y2': y, 'role': 'dimension'}) + primitives.append({'kind': 'text', 'x': ox - 12.0, 'y': y + 4.0, + 'text': '{0:.2f} in'.format(depth_in), + 'role': 'dimension', 'anchor': 'end'}) + + # right-margin qty/dia/depth label + dia_text = row['dia_in'] or default_dia_text or '?' + qty_text = str(row['count']) if row['count'] is not None else \ + str(count) + primitives.append({ + 'kind': 'text', 'x': ox + draw_w + 4.0, 'y': y - 5.0, + 'text': u'{0} × {1}″ @ {2:.2f} in'.format( + qty_text, dia_text, depth_in), + 'role': 'strand-label', 'anchor': 'start'}) + + flat_index += count + + # ---- mild bars: a Transverse row draws as ONE thin horizontal line + # across the full section width at its depth (seen along its length -- + # see the module docstring); anything else (Longitudinal, or blank) + # draws small dots END-ON, evenly spread or at their own `spacing_in` + # pitch. NEVER labelled at the section itself -- the legend carries + # the description. + px_per_in_x = (draw_w / width_in) if width_in else 0.0 + for row in mild_rows: + if row['depth_in'] is None: + continue + flagged_out = [] + y = depth_y(row['depth_in'], flagged_out) + flagged = flagged_out[0] + if flagged: + problems.append( + 'Rebar row {0} at {1:.2f} in from the bottom is outside ' + 'the panel thickness (0 to {2:.2f} in); shown clamped and ' + 'flagged.'.format(row['id'], row['depth_in'], thickness_in)) + + if _is_transverse(row['direction']): + primitives.append({'kind': 'line', 'x1': ox, 'y1': y, + 'x2': ox + draw_w, 'y2': y, + 'role': 'bar', 'flagged': flagged}) + continue + + count = row['count'] if row['count'] and row['count'] > 0 else 1 + dot_count = count if count <= 20 else 20 + xs = [] + spacing_in = row['spacing_in'] + if spacing_in and spacing_in > 0 and px_per_in_x > 0: + pitch_px = spacing_in * px_per_in_x + x = ox + pitch_px + while x < ox + draw_w and len(xs) < dot_count: + xs.append(x) + x += pitch_px + if not xs: + spacing_px = draw_w / float(count + 1) + xs = [ox + spacing_px * (i + 1) for i in range(dot_count)] + for x in xs: + primitives.append({'kind': 'dot', 'x': x, 'y': y, 'size': 3, + 'role': 'bar', 'flagged': flagged}) + + # ---- mesh: one dashed line, or one per wythe for a resolved sandwich. + # No text on or near the line -- its description lives in the legend. + if mesh_text: + layers_n = _num(values.get('mesh_layers')) + layers_n = int(layers_n) if layers_n and layers_n >= 1 else None + if sandwich_drawn and (layers_n is None or layers_n >= 2): + bot_mid_in = bot_in / 2.0 + top_mid_in = thickness_in - top_in / 2.0 + for depth_in in (bot_mid_in, top_mid_in): + y = depth_y(depth_in) + primitives.append({'kind': 'line', 'x1': ox, 'y1': y, + 'x2': ox + draw_w, 'y2': y, + 'role': 'mesh', 'dashed': True}) + else: + y = depth_y(thickness_in / 2.0) + primitives.append({'kind': 'line', 'x1': ox, 'y1': y, + 'x2': ox + draw_w, 'y2': y, + 'role': 'mesh', 'dashed': True}) + + # ---- strand-group CG: dot (circle) + cross marker in the section -- + # its Aps/cg TEXT lives in the legend (built above as a placeholder; + # filled in now that `ox`/`draw_w`/`depth_y` are known). Falls back to + # `ps_dia_in` for a row with no `dia_in` of its own, mirroring + # `pa_reinforcement._strand_block`'s own fallback -- a row that leaves + # its diameter to the form's single default still contributes a real + # Aps here instead of reading "unrecognised strand size". + if rows and cg_total_n > 0: + aps = 0.0 + any_unknown_area = False + weighted_depth = 0.0 + for row in rows: + area = _strand_area(row['dia_in'] or default_dia_text) + count = row['count'] if row['count'] and row['count'] > 0 \ + else 1 + if area is None: + any_unknown_area = True + else: + aps += count * area + weighted_depth += count * row['depth_in'] + + cgy_in = weighted_depth / float(cg_total_n) + cg_y = depth_y(cgy_in) + have_cgx = explicit_x is not None and len(explicit_x) >= cg_total_n + if have_cgx: + cgx_in = sum(explicit_x[:cg_total_n]) / float(cg_total_n) + cg_x = ox + _clamp(cgx_in / width_in, 0.0, 1.0) * draw_w \ + if width_in else ox + draw_w / 2.0 + else: + cgx_in = None + cg_x = ox + draw_w / 2.0 + + aps_text = ('{0:.3f} in²'.format(aps) if not any_unknown_area + else 'unknown (unrecognised strand size)') + if have_cgx: + cg_text = u'CG: Aps = {0}, cg = ({1:.2f} in, {2:.2f} in)' \ + .format(aps_text, cgx_in, cgy_in) + else: + cg_text = u'CG: Aps = {0}, cg depth = {1:.2f} in (x unknown)' \ + .format(aps_text, cgy_in) + + primitives.append({'kind': 'dot', 'x': cg_x, 'y': cg_y, + 'role': 'cg'}) + primitives.append({'kind': 'marker', 'x': cg_x, 'y': cg_y, + 'glyph': 'cross', 'role': 'cg'}) + # Fill in the placeholder legend entry appended earlier. + for idx in range(len(legend) - 1, -1, -1): + if legend[idx][0] == u'+': + legend[idx] = (u'+', cg_text) + break + if any_unknown_area: + problems.append( + 'strand-group CG: Aps could not be fully computed -- at ' + 'least one row has a diameter this drawing does not ' + 'recognise.') + + # ---- legend row(s): every group, one line each, left to right in + # columns, wrapping to a new legend row only when a row's columns fill + # up -- see the module docstring's LAYOUT section. + if legend: + legend_top = height_px - legend_h + col_w = width_px / float(n_cols) + for idx, (symbol, text) in enumerate(legend): + col = idx % n_cols + line = idx // n_cols + x = col * col_w + 6.0 + y = legend_top + _LEGEND_PAD + line * _LEGEND_ROW_H + 9.0 + primitives.append({ + 'kind': 'text', 'x': x, 'y': y, + 'text': u'{0} {1}'.format(symbol, text), + 'role': 'legend', 'anchor': 'start'}) + + primitives = [_clamp_primitive(p, width_px, height_px) + for p in primitives] + return primitives, problems + + +# --------------------------------------------------- real-unit geometry (ft) +# +# Added for the HTML-page rewrite (`vibe.pa_pagespec`): no pixels, no +# canvas size, no Revit -- built from this module's own real-unit row +# readers (`_strand_rows`, `_mild_rows`, `_explicit_strand_x`), the SAME +# ones `section()` itself calls, so the JSON and the still-live WPF picture +# can never disagree about where a strand or a bar sits. + +# `_real_geometry(values)` is the one shared parse both drawings consume +# (issue #23 item 9i): the section reshapes it straight through, the new +# reinforcement `outline` plan reshapes it into plan coordinates. `section_geometry` +# stays the section drawing's own name so callers and pagespec keep working. + +def section_geometry(values): + """The `reinforcement.section` drawing's geometry -- real feet / inches, + no pixels. Exactly `_real_geometry(values)`'s own return (see that + function's docstring for the per-row `chain_in`/`x_in` contract); this + thin wrapper exists so a caller of the section keeps the name it always + had while the outline plan (`outline_geometry`) reuses the SAME parse + rather than re-reading the rows a second time. Never raises; `values` + is read-only. + """ + return _real_geometry(values) + + +def _real_geometry(values): + """`{'width_ft', 'thickness_in', 'strands': [...], 'strand_x_in': + [...] | None, 'used_fallback_strand_x': bool, 'bars': [...], + 'problems': [...]}` -- real feet / inches, no pixels. + + `strands` is exactly `_strand_rows()`'s own row shape (`id` / `count` / + `depth_in` / `dia_in` / `pull_lb`; only rows with a readable depth are + included, matching that function's own rule), plus two per-row keys a + drawing of the chain itself (issue #23 item 9i) reads: + + * `chain_in` -- the row's `Location chain from rail` cell resolved to + a per-strand list of inches FROM THE RAIL (the cumulative chain, + e.g. `2";2";2";2"` -> [2, 4, 6, 8]), or None when the row has no + chain or its chain does not resolve; + * `x_in` -- the per-strand positions measured from the PANEL'S LEFT + edge (`strand_positions()`'s own output: the cumulative chain minus + the rail offset, mirrored when the rail is right), or None when the + row has no chain / it did not resolve. + + `strand_x_in`, when not `None`, is the flat per-strand across-width + position in inches from the left, in the SAME order `section()` draws + its dots in. A row whose `chain` cell RESOLVES supplies its own + positions from that chain (`strand_positions`, shared with + `pa_reinforcement.page()`), which therefore take precedence over the + legacy flat `values['strand_x_in']` / `['strand_columns_in']` list + (`_explicit_strand_x`); when only some rows carry chains the two are + merged per-row, and a row with neither keeps the even-spread fallback + (`used_fallback_strand_x: True` then, as today). A row whose chain does + NOT resolve is named in `problems` and contributes nothing to the flat + list -- never a guessed position. + `bars` is `_mild_rows()`'s own row shape (`id` / `size` / `count` / + `depth_in` / `direction` / `spacing_in`), including rows with a blank + depth -- the caller decides whether an undrawable bar is worth naming, + the same way `pa_reinforcement.page()` already does for the table. + + Never raises; `values` is read-only. + """ + values = values or {} + problems = [] + width_ft = _num(values.get('width_ft')) + thickness_in = _num(values.get('thickness_in')) + + strand_rows, p = _strand_rows(values) + problems.extend(p) + bar_rows = _mild_rows(values) + + width_in = width_ft * 12.0 if width_ft and width_ft > 0 else None + rail_loc = values.get('strand_rail_location') + rail_offset = values.get('strand_rail_offset_in') + + # `pa_reinforcement` is imported ON DEMAND, inside this function, never + # at module scope: `pa_reinforcement` itself imports THIS module at the + # top (`psr`), so a top-level import here would be circular. `section()` + # (the pixel drawing) does not consume chains, so the cycle is only + # ever entered on this real-unit path, and only when a `chain` cell is + # actually present. `pa_reinforcement` is import-safe under both Pythons + # (no Revit/WPF at module scope), and the import is wrapped so a + # sibling mid-edit degrades to "chain unavailable", never a crash. + par_module = None + try: + from vibe import pa_reinforcement as _par + par_module = _par + except Exception: + par_module = None + + def _resolve_chain(row): + """Resolve one row's `chain` cell. `chain_in` is the PARSED chain + -- one rail-to-strand length per cell, exactly as typed + (`2";2";2";2"` -> [2, 2, 2, 2]) -- for a future drawing of the + chain itself (issue #23 item 9i); `x_in` is the per-strand + position from the PANEL'S LEFT edge (`strand_positions()`'s + output: cumulative chain minus the rail offset, mirrored when the + rail is right -- "位置就是累加-offset", owner 2026-09-07). + (None, None) when the row has no chain or its chain cannot be + resolved (the problem is appended here).""" + if not (row.get('chain') or '').strip(): + return None, None + if row.get('count') is None: + problems.append( + 'Strand row {0} has a location chain but no count to match ' + 'it against.'.format(row['id'])) + return None, None + if par_module is None: + problems.append( + 'Strand row {0}: its location chain cannot be resolved -- ' + 'vibe.pa_reinforcement is not available.'.format(row['id'])) + return None, None + lengths, err = par_module.parse_chain_lengths( + row['chain'], row['count'], + row_label='strand row {0}'.format(row['id'])) + if err: + problems.append(err) + return None, None + positions, err = par_module.strand_positions( + row['count'], row['chain'], rail_loc, rail_offset, width_in) + if err: + problems.append(err) + return None, None + return lengths, positions + + # Each row's own geometry: `chain_in` (rail-relative, for the drawing + # of the chain itself -- issue #23 item 9i) and `x_in` (panel-left). + # A row with neither chain nor x_in is left for the caller to spread + # evenly, exactly as `section()` falls back today. + flat_x = [] + have_flat = False + for row in strand_rows: + chain_in, x_in = _resolve_chain(row) + row['chain_in'] = chain_in + row['x_in'] = x_in + if x_in is not None: + flat_x.extend(x_in) + have_flat = True + + # The top-level `strand_x_in` flat list keeps its documented contract: + # one entry per strand across EVERY row, or None. Chain rows therefore + # only produce that list when EVERY row resolved a chain (the list is + # then the rows' concatenated chain positions); a mixed schedule -- some + # rows chained, some not -- returns `strand_x_in: None` with + # `used_fallback_strand_x: True`, names the chain-less rows in + # `problems` (the page turns that into its own quiet fallback + # sentence), and lets the caller place the chained rows from their own + # per-row `x_in` while spreading the rest. With NO chain anywhere the + # legacy `values['strand_x_in']` / `['strand_columns_in']` list is read + # exactly as before (`_explicit_strand_x`), so nothing that worked + # before this change changes. + total_count = sum((r['count'] if r.get('count') and r['count'] > 0 + else 1) for r in strand_rows) + resolved = [r for r in strand_rows if r.get('x_in') is not None] + missing = [r for r in strand_rows if r.get('x_in') is None] + explicit_x = None + if resolved and not missing: + have_flat = have_flat and len(flat_x) == total_count + elif resolved and missing: + have_flat = False + flat_x = [] + ids = ', '.join(str(r['id']) for r in missing) + problems.append( + 'strand rows {0}: no location chain entered -- those strands ' + 'are spread evenly across the width (the rows with chains are ' + 'placed from their own).'.format(ids)) + elif total_count > 0: + explicit_x = _explicit_strand_x(values, total_count, width_in) + if explicit_x is not None: + flat_x = list(explicit_x) + have_flat = True + used_fallback = total_count > 0 and not have_flat + + return { + 'width_ft': width_ft, 'thickness_in': thickness_in, + 'strands': strand_rows, + 'strand_x_in': flat_x if have_flat else None, + 'used_fallback_strand_x': used_fallback, 'bars': bar_rows, + 'problems': problems, + } + + +def outline_geometry(values): + """The `reinforcement.outline` PLAN drawing's geometry -- real feet / + inches, no pixels (issue #23 item 9i): every reinforcement type is its + own group with its own continuous dimension chain from its own + reference, and this plan is the "two drawings" half of the pair the + reinforcement page now carries (paired with the existing `section`). + + REUSES the section's own parse (`_real_geometry`), never re-reads the + rows -- same strands (`chain_in` / `x_in` resolved identically), same + bars, same problems. The plan just reshapes that shared parse into plan + coordinates and adds the mesh + rail summary the section does not draw: + + * `strands` is per row `{'row_id', 'x_in', 'chain_in', 'count', + 'dia_in', 'depth_in'}` -- `x_in` is the per-strand across-width + position in inches from the PANEL'S LEFT edge (the cumulative chain + minus the rail offset, mirrored when the rail is right -- the same + `_real_geometry` numbers the section draws with), so the plan can + lay the strand lines along the length at those x's and draw ONE + continuous dimension chain along the width edge from the rail + outward using `chain_in` (the rail-to-strand lengths, as typed). + * `bars` is per row `{'row_id', 'size', 'count', 'depth_in', + 'direction', 'spacing_in', 'from_ft', 'to_ft'}` -- the bar rows' + own position basis (`from_ft`/`to_ft` on the length axis, `depth_in` + on the width axis) so the plan draws each bar group with its OWN + chain from ITS OWN reference, never shared with the strand chain. + * `rail` is `{'location': 'left'|'right'|None, 'offset_in': float}` + from `strand_rail_location` / `strand_rail_offset_in` (the rail + datum the strand chains run from), and is `None` when the location + is blank. + * `mesh` is `{'designation', 'layers', 'depth_in'}`, or `None` when + the mesh field is blank -- a null mesh is "no mesh", never a + zero-size guess; `layers`/`depth_in` are whatever the form carries + (may be `None` when un-entered). + + `length_ft` / `width_ft` are read defensively (may be `None` -- the plan + rectangle needs them, so a caller shows the `problems` sentences instead + of an honest-to-nothing drawing). Never raises; `values` is read-only. + """ + values = values or {} + geo = _real_geometry(values) + problems = list(geo['problems']) + width_ft = geo['width_ft'] + length_ft = _num(values.get('length_ft')) + + rail_loc_text = _text(values.get('strand_rail_location')) or '' + rail_loc_key = rail_loc_text.strip().lower() + if 'left' in rail_loc_key: + rail_location = 'left' + elif 'right' in rail_loc_key: + rail_location = 'right' + else: + rail_location = None + rail_offset = _num(values.get('strand_rail_offset_in')) + rail = {'location': rail_location, + 'offset_in': rail_offset if rail_offset is not None else 0.0} \ + if rail_location else None + + strands = [] + for s in geo['strands']: + strands.append({ + 'row_id': s['id'], 'x_in': s.get('x_in'), 'chain_in': s.get( + 'chain_in'), 'count': s['count'], 'dia_in': s['dia_in'], + 'depth_in': s['depth_in'], + }) + + bars = [] + for b in geo['bars']: + bars.append({ + 'row_id': b['id'], 'size': b.get('size'), + 'count': b.get('count'), 'depth_in': b.get('depth_in'), + 'direction': b.get('direction'), 'spacing_in': b.get( + 'spacing_in'), 'from_ft': b.get('from_ft'), + 'to_ft': b.get('to_ft'), + }) + + mesh_text = _text(values.get('mesh')) + if mesh_text: + layers = _num(values.get('mesh_layers')) + mesh = { + 'designation': mesh_text, + 'layers': int(layers) if layers is not None else None, + 'depth_in': _num(values.get('mesh_depth_in')), + } + else: + mesh = None + + return { + 'length_ft': length_ft, 'width_ft': width_ft, + 'rail': rail, 'strands': strands, 'bars': bars, + 'mesh': mesh, 'problems': problems, + } diff --git a/WillTools.extension/lib/vibe/pa_structural.py b/WillTools.extension/lib/vibe/pa_structural.py new file mode 100644 index 0000000..4eed527 --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_structural.py @@ -0,0 +1,802 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis input form -- the "Structural Model" section, laid out. + +WHY THIS EXISTS: the owner rejected the Panel Analysis form three times as +unreadable ("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02, +"全部给我做成普通人能看懂的样子" -- make every part of it something an ordinary +person can understand. `page()` builds the Structural Model section +(restraints, bearing, concentrated loads, wind, and the collapsed +seismic/temperature/soil/additional-self-weight/design-strips families) as +a small fixed vocabulary of BLOCK dicts a generic WPF renderer consumes. + +2026-09-04 REWRITE, owner rulings on today's headless render: + + * "restrain & support 的那个示意图,本质上是一个 freebody,用一条直线来代替 + member,同时显示 restrain 的位置,以及 loading" -- the picture is now a + FREE-BODY (`kind: 'drawing'`, `module: 'pa_sketch_freebody'`), not the + four-panel `panelsketch.elevation()` this section used to reuse. That old + drawing block is GONE from this page; the free-body replaces it (see the + 'kind of drawing has 3 tiny sub-drawings + text overlapping' complaint + below). + * "restrain 现在为啥变成了不可编辑了?只是让你预先输入,不是让你直接锁死啊" + -- Restraints is now an EDITABLE TABLE (`kind: 'editable_table'`, + `table: 'restraint'`), not the two dropdown/checkbox fields + (`restraint_foundation_connection` / `restraint_floor_tie`) this page used + to render. Those two fields are GONE; a restraint's type (including + 'Floor tie') is now a per-row column, added with the table's own + Add-row button. + * Concentrated loads: was a "none entered" sentence with no editor. Now an + EDITABLE TABLE (`table: 'conc_load'`) -- label / elevation / offset / + dead / live, one row per point load, Add-row button. + * "既然要用户直接输入 wind pressure/suction,那就别要前面那些东西了。前面那些 + 应该在 project-config 里面,从这里移除。添加一个按钮,按照 project-config + 计算,还是用户手动输入。...现在就只做用户手动输入的部分。手动输入不仅仅是 + windload,还有 apply 在什么位置,从哪个 elevation 到哪个 elevation,多大 + 的宽度" -- every ASCE input (speed, exposure, risk, enclosure, edition, + both chapter dropdowns) is GONE from this page; that computation belongs + in project-config, not here. In its place: one `wind_mode` choice field + ('Manual' / 'From project config') and an EDITABLE TABLE (`table: + 'wind_zone'`) of manually-typed pressure zones -- label, from/to + elevation, width, pressure, suction. Only 'Manual' is built; 'From + project config' renders a quiet sentence saying so, nothing computed. + * "seismic, temp, soil 这几个板块先折叠掉" -- Seismic, Temperature Loads and + Soil Loads (the existing exterior/interior columns, together, as ONE + named block the way the owner named it) are each a `kind: 'section'` + with `collapsed: True`. + * SAME-DAY FOLLOW-UP, on the free-body render itself: the member printed + as a faint band with its end labels clipped at the canvas edge, tiny + restraint symbols, and ~40% of the canvas blank above the member -- + `pa_sketch_freebody.py` (a sibling module, not this one) now packs its + rows tight from the top instead of centring on a fraction of the + canvas height; see its own docstring. Two things land here instead: + the OLD `weight_diagram` drawing block under Additional Self Weight is + GONE -- the free-body above already draws every point load and the + member, so a second small "point loads and self weight" picture + repeating the same data was exactly the leftover-old-drawing complaint + -- and Additional Self Weight / Design Strips (neither built) are now + each a `kind: 'section'` with `collapsed: True`, same as Seismic / + Temperature / Soil. + * SECOND SAME-DAY FOLLOW-UP, after the first fix rendered live: the + restraint symbols were still nearly invisible (a renderer gap in the + WPF button, not this page -- see `pa_sketch_freebody`'s RESTRAINT + SYMBOLS note) and roughly 45% of the canvas sat empty BELOW the + restraint labels/dimension chain now that the top was fixed. The + drawing block's `aspect` here is no longer a flat `0.22` -- + `_freebody_aspect(values)` shrinks it for a diagram with few or no + concentrated-load/wind-zone rows (own docstring has the formula) so + a simple diagram gets a shorter canvas instead of a taller one with + dead space at the bottom. + * Grey helper prose stays out of the inline flow: every explanatory + sentence below (what a block is FOR, not its live state) carries + `quiet: True` so the renderer routes it into the collapsed Notes panel + instead of printing it inline. A functional state sentence -- "Design + contains no releases.", "No concentrated point loads entered..." -- is + the direct answer to a question the user asked by leaving a field blank, + not helper prose, and stays inline (it is one sentence either way, so it + never trips the "no inline non-quiet helper prose longer than one + sentence" gate regardless). + * Section properties, if this page carried them at all, would be a small + `kind: 'kv'` block -- never text drawn on a canvas. This page does not + compute or show them; that stayed with whichever tab already draws the + panel's gross geometry. + +TWO STANDING RULES FROM CLAUDE.md, section 3, both still load-bearing here: + + * "Could not judge" and "judged, found nothing" must never look alike. + Every optional free-text block below (Releases, both Soil Loads, + Additional Self Weight, Design Strips) still uses `_optional_note`: the + key existing in `values` but blank means the user was asked and + answered "none"; the key missing from `values` entirely means this form + has never asked yet. + * Nothing here computes wind or seismic. The owner ruled it explicitly, + 2026-09-02, and again today for wind specifically -- every wind/seismic/ + temperature field or table is collected and rendered, never computed, + never defaulted. + +WHERE THE LAYOUT COMES FROM: Eriksson Wall's own printed "Structural Model" +page for the STRUCTURE and generic field names only -- no project, client, +licensee, engineer, job number, file name or date from it appears anywhere +below; every label is written from scratch in plain engineering English. +See CLAUDE.md's "No client or job information in the repo. Ever." + +READING `vibe.panelanalysisform` DEFENSIVELY: that module owns the actual +key names and is being restructured by another agent in parallel with this +one. `page()` imports it, reads its `FIELDS` list once to find out which +plain fields it already knows about, and for every plain field this section +wants that it does NOT find there, it renders the field anyway (with a +label and hint written locally, right here) and adds one sentence to +`problems` naming the key -- never a raised exception, never a guess passed +off as the shared field model's answer. + +`row_ids(table, values)` / `row_key(table, n, col)` are the CONTRACT this +page and `pa_sketch_freebody.py` share for the three row-tables +(`restraint`, `conc_load`, `wind_zone`): one row lives at +`'
..'`, and `'
..present' == 'yes'` marks the row as +existing. `vibe.panelanalysisform` is expected to land the real +`row_ids`/`row_key` eventually (it does not today -- checked at import +time); until then this module implements the identical key arithmetic +locally under the same two names, so the day the shared version lands, +nothing here has to change. + +Standard library only, no Revit, no WPF, no `panelsketch` import -- the +`kind: 'drawing'` block below only NAMES a `vibe.pa_sketch_freebody` entry +point as its `source`; a sibling agent owns what that module draws. + +IronPython 2.7 throughout: no f-strings, `'{0}'.format(...)` everywhere, +`# -*- coding: utf-8 -*-` up top, no `pathlib` / `dataclasses` / `typing`. +""" + +import re + + +# -------------------------------------------------------------------------- +# Small helpers shared by every block below. None of these touch `values` -- +# they only ever read it, and `page()` never assigns into a caller-owned +# dict, so the same `values` a caller hands in comes back untouched. +# -------------------------------------------------------------------------- + +def _text(value): + """Coerce whatever sits in `values` to a string, defensively. + + `values` is documented as string-valued, but this form is fed by a + live model read and a WPF text box on one side and a mid-refactor + field model on the other -- a stray non-string (an `int` typed + straight into a dict during a test, a `None` a half-written prefill + left behind) must degrade to an empty string, never raise. + """ + if value is None: + return '' + try: + value.strip # duck-typed string check -- works for str/unicode both + return value + except AttributeError: + try: + return str(value) + except Exception: + return '' + + +def _known_field_keys(paf): + """The set of keys `vibe.panelanalysisform.FIELDS` already defines. + + Empty set (never an exception) if the module failed to import or its + `FIELDS` list could not be read -- the caller has already logged why in + `problems` by the time this runs, and an empty set makes every field + below fall through to its local stand-in label, which is the correct + degrade rather than a crash. + """ + if paf is None: + return set() + try: + return set(f['key'] for f in paf.FIELDS) + except Exception: + return set() + + +def _field(paf, known, problems, key, required, editor, choices=None, + fallback_label=None, fallback_symbol='', fallback_hint='', + what=''): + """One `kind: 'field'` block, sourced from `panelanalysisform` when it + already knows `key`, and from the fallback text passed in when it does + not -- appending one `problems` sentence in that second case, naming + the key and what it is for, so the field-model agent has a punch list + rather than a form that silently drifted from `panelanalysisform`. + """ + if key in known: + label = paf.label_of(key) + symbol = paf.symbol_of(key) or None + hint = paf.help_of(key) or None + else: + label = fallback_label or key + symbol = fallback_symbol or None + hint = fallback_hint or None + problems.append( + "'{0}' ({1}) has no entry yet in vibe.panelanalysisform -- " + 'shown with a stand-in label until the field model adds it.' + .format(key, what or fallback_label or key)) + return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol, + 'hint': hint, 'required': required, 'editor': editor, + 'choices': choices} + + +def _sentence(text): + return {'kind': 'sentence', 'text': text} + + +def _quiet(text): + """A grey helper sentence -- explains what a block is FOR, never its + live state. The renderer routes anything carrying `quiet: True` into + the one collapsed Notes panel instead of the inline flow (owner, + 2026-09-04: keep the grey helper prose out of the way).""" + return {'kind': 'sentence', 'text': text, 'quiet': True} + + +def _section(title, blocks, collapsed=True): + return {'kind': 'section', 'title': title, 'collapsed': collapsed, + 'blocks': blocks} + + +def _optional_note(values, key, blank_sentence, missing_sentence, problems, + what): + """(state, text) for one optional free-text block. + + Three states, and the whole point of this helper is that the first two + never look alike: + + 'missing' -- `key` is not in `values` at all. This form has not asked + the question yet; `text` is `missing_sentence` and one + line is appended to `problems` naming the gap. This is + "could not judge", never "judged, found nothing". + 'blank' -- `key` is in `values` but empty/whitespace. The user was + asked and the honest answer is none; `text` is + `blank_sentence`. This IS "judged, found nothing". + 'filled' -- `key` has real text; `text` is that text, stripped, for + the caller to parse into a table. + """ + if key not in values: + problems.append( + "'{0}' ({1}) is not collected by this form yet -- rendered as " + "unknown, never silently as \"none\"." + .format(key, what)) + return 'missing', missing_sentence + text = _text(values.get(key)).strip() + if not text: + return 'blank', blank_sentence + return 'filled', text + + +def _rows_from_lines(text): + """[[id, col, col, ...], ...] -- one row per non-blank line, comma-split. + + A DISPLAY parse, not a validating one: a line that does not split into + the expected shape is shown with whatever columns it did produce rather + than refused, because this table exists to show the user what they + typed back at them. + """ + rows = [] + for i, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if not line: + continue + cols = [p.strip() for p in line.split(',')] + rows.append([str(i)] + cols) + return rows + + +def _table_from_text(text, headers): + return {'kind': 'table', 'headers': headers, 'header_groups': None, + 'rows': _rows_from_lines(text), 'row_key': None} + + +# -------------------------------------------------------------------------- +# row_ids / row_key -- the shared row-table contract (see module docstring). +# Guards the `vibe.panelanalysisform` import and falls back to the identical +# key arithmetic locally, under the same two names, so nothing here has to +# change the day the shared helpers land. +# -------------------------------------------------------------------------- + +def row_key(table, n, col): + """The `values` key for one row's cell: '
..'.""" + try: + from vibe import panelanalysisform as paf + return paf.row_key(table, n, col) + except (ImportError, AttributeError): + return '{0}.{1}.{2}'.format(table, n, col) + + +def row_ids(table, values): + """Every PRESENT row id in one row-table, ascending, as INT ids. + + A row counts as present when its `present` cell reads 'yes' + (case-insensitive); a row whose `present` cell is missing, blank, or + anything else ('no', a removed row) is not returned. This mirrors + `vibe.panelanalysisform.row_ids` / `_row_ids_any` exactly (landed + 2026-09-04, id type and all) so the fallback path below behaves + identically to the real one it stands in for until import succeeds. + """ + try: + from vibe import panelanalysisform as paf + return list(paf.row_ids(table, values)) + except (ImportError, AttributeError): + pass + + values = values or {} + prefix = '{0}.'.format(table) + candidates = set() + for key in values: + if not isinstance(key, str) and not isinstance(key, type(u'')): + continue + if not key.startswith(prefix): + continue + n_txt = key[len(prefix):].split('.', 1)[0] + try: + n = int(n_txt) + except (TypeError, ValueError): + continue + if n > 0: + candidates.add(n) + out = [n for n in candidates + if str(values.get(row_key(table, n, 'present')) or '') + .strip().lower() == 'yes'] + out.sort() + return out + + +def _editable_table(table, columns, add_label, empty_state=None): + """One `editable_table` block. `empty_state`, when given, is + `{'basis_keys': [...], 'text': ...}`: what the page prints under the + table while it has NO rows -- the first `basis[]` sentence the + model read left (the read's own reason, e.g. `supports_basis`), else + `text`. A blank table with no sentence reads as "nothing here", which + for a read that silently found nothing is the wrong answer (issue #22, + items 5 and 11; CLAUDE.md's could-not-judge vs judged-empty rule).""" + block = {'kind': 'editable_table', 'table': table, 'columns': columns, + 'add_label': add_label} + if empty_state: + block['empty_state'] = dict(empty_state) + return block + + +_FREEBODY_ASPECT_BASE = 0.16 +_FREEBODY_ASPECT_STEP = 0.03 +_FREEBODY_ASPECT_MAX = 0.28 + + +def _freebody_aspect(values): + """The free-body drawing's `aspect` -- 0.16 baseline, +0.03 per + non-empty `conc_load` / `wind_zone` row, capped at 0.28. + + Coordinator, live headless render, 2026-09-04: a diagram with no + concentrated loads and no wind zones has nothing to draw in its top + two rows (see `pa_sketch_freebody`'s LAYOUT ROWS), so handing it the + same fixed 0.22 aspect a busier diagram needs left roughly 45% of the + canvas empty -- the fix is a SHORTER canvas for a simple diagram, not + a taller one stretched to fill a fixed height. Two loads/wind rows + combined (a typical single load + single wind zone) lands back on the + old 0.22 by construction, so nothing already-tuned regresses. + """ + n = len(row_ids('conc_load', values)) + len(row_ids('wind_zone', values)) + aspect = _FREEBODY_ASPECT_BASE + _FREEBODY_ASPECT_STEP * n + if aspect > _FREEBODY_ASPECT_MAX: + aspect = _FREEBODY_ASPECT_MAX + return aspect + + +def _col(col, label, editor, choices=None): + return {'col': col, 'label': label, 'editor': editor, + 'choices': choices} + + +_SUPPORT_COLUMNS = [ + _col('x_ft', 'Position along length (ft)', 'num'), +] + +_RESTRAINT_COLUMNS = [ + _col('elevation_ft', 'Elevation (ft)', 'num'), + _col('type', 'Type', 'choice', choices=['Pin', 'Fixed', 'Free', + 'Floor tie']), + _col('gravity', 'Gravity restraint', 'check'), + _col('thermal', 'Thermal restraint', 'check'), + _col('stiffness_kin', 'Stiffness (k/in)', 'num'), +] + +_CONC_LOAD_COLUMNS = [ + _col('label', 'Label', 'text'), + _col('elevation_ft', 'Elevation (ft)', 'num'), + _col('offset_in', 'Offset (in)', 'num'), + _col('dead_k', 'Dead (k)', 'num'), + _col('live_k', 'Live (k)', 'num'), +] + +_WIND_ZONE_COLUMNS = [ + _col('label', 'Label', 'text'), + _col('from_elev_ft', 'From elevation (ft)', 'num'), + _col('to_elev_ft', 'To elevation (ft)', 'num'), + _col('width_ft', 'Width (ft)', 'num'), + _col('pressure_psf', 'Pressure (psf)', 'num'), + _col('suction_psf', 'Suction (psf)', 'num'), +] + + +# -------------------------------------------------------------------------- +# page() +# -------------------------------------------------------------------------- + +def page(values, reads=None): + """(blocks, problems) for the Structural Model section. + + `values` is the form's current string-valued dict, keyed by + `vibe.panelanalysisform` field names plus the three row-tables' keys + (see the module docstring's `row_ids`/`row_key` contract). Never + mutated: every access below is `values.get(...)` or `key in values`, + nothing is assigned into it. `reads` is accepted for symmetry with the + rest of the form but nothing here reads it yet. + + Never raises. A field the shared field model does not know yet, an + unreadable `panelanalysisform`, or `values` missing a key entirely all + degrade to a `problems` sentence plus a reasonable stand-in in the + layout -- never an exception, never a fabricated value. + """ + values = values if values is not None else {} + problems = [] + blocks = [] + + paf = None + try: + from vibe import panelanalysisform as paf + except Exception as exc: + problems.append( + 'could not import vibe.panelanalysisform ({0}); every field ' + 'below is shown with a locally-written label instead of the ' + 'shared field model.'.format(exc)) + known = _known_field_keys(paf) + + blocks.append({'kind': 'heading', 'text': 'Structural Model', 'level': 1}) + blocks.append(_quiet( + 'How this panel is held up, tied back and loaded once it is ' + 'standing in place, separate from its geometry and how it is ' + 'handled before it gets there.')) + + # --------------------------------------------------------- Restraints + blocks.append({'kind': 'heading', 'text': 'Restraints', 'level': 2}) + blocks.append(_quiet( + 'Where the panel is connected to the foundation, and then, going ' + 'up, where it is tied back to each floor as the building is built ' + 'around it. Add one row per restraint elevation; gravity ' + 'restraint means that tie also carries the panel\'s own weight at ' + 'that point, and thermal restraint means it stops the panel from ' + 'bowing out of plane if one face gets hotter than the other.')) + blocks.append(_editable_table( + 'restraint', _RESTRAINT_COLUMNS, 'Add restraint', + empty_state={ + 'basis_keys': ['restraints', 'supports'], + 'text': 'No restraints read from the model and none entered ' + 'yet -- the read left no reason, so this is ' + 'could-not-judge, not "no restraints".'})) + + blocks.append({'kind': 'heading', 'text': 'Releases', 'level': 3}) + blocks.append(_quiet( + 'Any restraint above that is deliberately freed in one direction ' + '-- for example a tie that carries the weight but is left free to ' + 'slide sideways.')) + rel_state, rel_text = _optional_note( + values, 'releases', + blank_sentence='Design contains no releases.', + missing_sentence='Releases are not collected on this form yet -- ' + 'this is unknown, not "no releases".', + problems=problems, what='deliberately-freed restraint directions') + if rel_state == 'filled': + blocks.append(_table_from_text(rel_text, ['ID', 'Release'])) + else: + blocks.append(_sentence(rel_text)) + + blocks.append({'kind': 'rule'}) + + # -------------------------------------------------------------- Bearing + # Owner ruling, 2026-09-05: support positions are an EDITABLE ROW TABLE + # (`support`, one column `x_ft`), the same mechanism as `restraint` / + # `conc_load` -- replacing the free-text `supports_ft` box. That FIELDS + # entry stays declared and is read only as a fallback for a panel saved + # before this table existed (see `panelanalysisform.validate`'s support + # derivation); it is no longer rendered here. + blocks.append({'kind': 'heading', 'text': 'Bearing for this analysis', + 'level': 2}) + blocks.append(_quiet( + 'Which positions actually hold the panel up for THIS run, measured ' + "along its length from the start -- the analysis needs at least " + 'two, and they do not have to be every restraint listed above.')) + blocks.append(_editable_table( + 'support', _SUPPORT_COLUMNS, 'Add support', + empty_state={ + 'basis_keys': ['supports', 'restraints'], + 'text': 'No support positions read from the model and none ' + 'entered yet -- the read left no reason, so this is ' + 'could-not-judge, not "no supports". The analysis ' + 'needs at least two rows here.'})) + + blocks.append({'kind': 'space', 'h': 8}) + + # ---------------------------------------------------- Concentrated Loads + blocks.append({'kind': 'heading', 'text': 'Concentrated Loads', + 'level': 2}) + blocks.append(_quiet( + 'Extra point loads hung on the panel once it is standing -- a ' + 'ledger, a beam pocket, attached equipment -- and where along the ' + 'wall each one acts. A positive offset pulls toward the outside ' + 'face of the wall. Add one row per point load.')) + blocks.append(_editable_table('conc_load', _CONC_LOAD_COLUMNS, + 'Add load')) + + blocks.append({'kind': 'rule'}) + + # -------------------------------------------------------- Load Options + blocks.append({'kind': 'heading', 'text': 'Load Options', 'level': 2}) + blocks.append(_field( + paf, known, problems, 'load_opt_pattern_live', False, 'check', + fallback_label='Pattern live loads for flexure', + fallback_hint='Whether live load is applied in a checkerboard ' + 'pattern (only some spans loaded at once) when ' + 'checking bending, instead of everywhere at once at ' + 'the same time. Leave unchecked if unsure.', + what='whether live load is patterned for the bending check')) + blocks.append(_field( + paf, known, problems, 'load_opt_selfweight_ecc_in', False, 'num', + fallback_label='Self-weight eccentricity', fallback_symbol='in', + fallback_hint="How far the panel's own weight is assumed to act " + 'off its centerline. Leave blank to assume it acts ' + 'right on the centerline.', + what='the self-weight eccentricity')) + blocks.append(_field( + paf, known, problems, 'load_opt_offset_from', False, 'choice', + choices=['Deflection Section', 'Top in Form', 'Bottom in Form'], + fallback_label='Offset measured from', + fallback_hint='What point the point-load offsets above are ' + "measured from. Leave blank to assume the panel's " + 'own deflection section.', + what='what point-load offsets are measured from')) + + blocks.append({'kind': 'rule'}) + + # ------------------------------------------------------------- Wind + # + # 2026-09-04: every ASCE input this section used to render (speed, + # exposure, risk category, enclosure, code edition, both chapter + # dropdowns) is GONE -- the owner ruled that computation belongs in + # project-config, not this page. `wind_mode` names the choice; only + # 'Manual' is built today, so a quiet sentence says so rather than a + # button that does nothing. + blocks.append({'kind': 'heading', 'text': 'Wind', 'level': 2}) + blocks.append(_field( + paf, known, problems, 'wind_mode', False, 'choice', + choices=['Manual', 'From project config'], + fallback_label='Wind pressures come from', + fallback_hint='Whether wind pressure/suction is typed in directly ' + 'below, or (once built) computed from the ' + 'project-config wind parameters.', + what='whether wind pressures are typed in or computed')) + blocks.append(_quiet( + "'From project config' is not built yet -- wind pressures and " + 'suctions have to be entered manually below regardless of this ' + 'choice.')) + blocks.append(_quiet( + 'One row per wind zone: where it starts and ends up the panel ' + '(elevation, not a fraction), how wide a strip of the wall it ' + 'covers, and the pressure and suction typed in directly, in psf. ' + 'Nothing here computes a pressure -- every number is typed in.')) + blocks.append(_editable_table('wind_zone', _WIND_ZONE_COLUMNS, + 'Add wind zone')) + + blocks.append({'kind': 'rule'}) + + # ----------------------------------------------------------- Seismic + seismic_blocks = [ + {'kind': 'heading', 'text': 'Seismic', 'level': 2}, + _quiet( + 'Nothing in this tool computes a seismic force yet either -- ' + 'the same later-stage engine as wind. Shown here so it is not ' + 'lost, not used by the analysis.'), + _field(paf, known, problems, 'seismic_cs', False, 'num', + what='the seismic coefficient, as a percent of dead load'), + _field( + paf, known, problems, 'seismic_zone_location', False, 'text', + fallback_label='Seismic zone location(s)', + fallback_hint='Which part of the panel height the seismic ' + 'load applies over (e.g. full height). Not yet ' + 'used by the solve.', + what='where along the panel the seismic load applies'), + _field( + paf, known, problems, 'seismic_apply_to', False, 'choice', + choices=['None', 'Out-of-plane', 'In-plane', 'Both'], + fallback_label='Apply seismic multiplier to', + fallback_hint='Which loads the seismic coefficient multiplies ' + '(out-of-plane, in-plane, or both). Not yet used ' + 'by the solve.', + what='what the seismic coefficient is applied to'), + ] + sz_state, sz_text = _optional_note( + values, 'seismic_zone_rows', + blank_sentence='No seismic load zones entered.', + missing_sentence='Seismic load zones (start/end position, load ' + 'percentage) are not collected on this form yet ' + '-- this is unknown, not "no seismic zones".', + problems=problems, what='seismic load zones along the height') + if sz_state == 'filled': + seismic_blocks.append( + _table_from_text(sz_text, + ['ID', 'Start / end / seismic load % (as typed)'])) + else: + seismic_blocks.append(_sentence(sz_text)) + blocks.append(_section('Seismic', seismic_blocks, collapsed=True)) + + # ------------------------------------------------------- Temperature + temp_blocks = [ + {'kind': 'heading', 'text': 'Temperature Loads', 'level': 2}, + _quiet( + 'How much hotter or colder one face of the panel gets than ' + 'the other, and how stiff the concrete is treated as being ' + 'while it moves. Not yet used by the solve.'), + ] + temp_fields = ( + ('temp_ext_f', 'Exterior temperature', 'deg F'), + ('temp_int_f', 'Interior temperature', 'deg F'), + ('temp_elasticity_mult', 'Thermal elasticity multiplier', ''), + ('temp_alpha_t', 'Coefficient of thermal expansion', u'αT × 10⁻⁶'), + ) + for key, flabel, fsym in temp_fields: + temp_blocks.append(_field( + paf, known, problems, key, False, 'num', + fallback_label=flabel, fallback_symbol=fsym, + fallback_hint='Not yet used by the solve.', + what=flabel.lower())) + blocks.append(_section('Temperature Loads', temp_blocks, collapsed=True)) + + # ------------------------------------------------------------ Soil + # + # Owner named this ONE block ("seismic, temp, soil"), even though it + # has always held two sub-columns (exterior / interior face) -- kept as + # a single collapsed section with the same `columns` layout inside, + # not split into two sections. + ext_col = [{'kind': 'heading', 'text': 'Exterior Soil Loads', + 'level': 3}] + es_state, es_text = _optional_note( + values, 'ext_soil_loads', + blank_sentence='Design contains no exterior soil loads.', + missing_sentence='Exterior soil loads are not collected on this ' + 'form yet -- this is unknown, not "no exterior ' + 'soil loads".', + problems=problems, what='soil pressing on the outside face') + ext_col.append(_sentence(es_text)) + if es_state == 'filled': + ext_col.append(_field( + paf, known, problems, 'ext_soil_elevation_in', False, 'num', + fallback_label='Elevation', fallback_symbol='in', + fallback_hint='Where the exterior soil load starts, measured ' + 'up from the bottom of the panel. Not yet used ' + 'by the solve.', what='exterior soil elevation')) + ext_col.append(_field( + paf, known, problems, 'ext_soil_surcharge_psf', False, 'num', + fallback_label='Surcharge', fallback_symbol='psf', + fallback_hint='How much extra pressure the soil (plus ' + 'anything sitting on top of it) puts on the ' + 'panel. Not yet used by the solve.', + what='exterior soil surcharge pressure')) + + int_col = [{'kind': 'heading', 'text': 'Interior Soil Loads', + 'level': 3}] + is_state, is_text = _optional_note( + values, 'int_soil_loads', + blank_sentence='Design contains no interior soil loads.', + missing_sentence='Interior soil loads are not collected on this ' + 'form yet -- this is unknown, not "no interior ' + 'soil loads".', + problems=problems, what='soil pressing on the inside face') + int_col.append(_sentence(is_text)) + if is_state == 'filled': + int_col.append(_field( + paf, known, problems, 'int_soil_elevation_in', False, 'num', + fallback_label='Elevation', fallback_symbol='in', + fallback_hint='Where the interior soil load starts, measured ' + 'up from the bottom of the panel. Not yet used ' + 'by the solve.', what='interior soil elevation')) + int_col.append(_field( + paf, known, problems, 'int_soil_surcharge_psf', False, 'num', + fallback_label='Surcharge', fallback_symbol='psf', + fallback_hint='How much extra pressure the soil (plus ' + 'anything sitting on top of it) puts on the ' + 'panel. Not yet used by the solve.', + what='interior soil surcharge pressure')) + + soil_blocks = [ + {'kind': 'heading', 'text': 'Soil Loads', 'level': 2}, + {'kind': 'columns', 'columns': [ext_col, int_col]}, + ] + blocks.append(_section('Soil Loads', soil_blocks, collapsed=True)) + + blocks.append({'kind': 'rule'}) + + # ------------------------------------------------- Additional self weight + # + # 2026-09-04: neither this nor Design Strips below is built (nothing in + # the solve reads either yet), and the old `weight_diagram` drawing this + # block used to show is GONE -- the free-body above already draws every + # point load and the member itself; a second, tiny "point loads and self + # weight" picture repeating a subset of the same thing was exactly the + # "old drawing still on the page" the owner's headless render flagged. + # Collapsed the same way Seismic/Temperature/Soil are, so an unbuilt + # block is not sitting open and prominent in the inline flow. + asw_blocks = [{'kind': 'heading', 'text': 'Additional Self Weight', + 'level': 2}] + asw_state, asw_text = _optional_note( + values, 'addl_sw_magnitude_plf', + blank_sentence='No additional self weight entered -- the ' + "analysis uses only the panel's own concrete " + 'weight.', + missing_sentence='Additional self weight is not collected on ' + 'this form yet -- this is unknown, not "none".', + problems=problems, what='extra weight added over part of the panel') + if asw_state == 'missing': + asw_blocks.append(_sentence(asw_text)) + else: + if asw_state == 'blank': + asw_blocks.append(_sentence(asw_text)) + asw_blocks.append(_field( + paf, known, problems, 'addl_sw_start_in', False, 'num', + fallback_label='Start from bottom', fallback_symbol='in', + fallback_hint='Where the extra weight starts, measured up ' + 'from the bottom of the panel. Not yet used by ' + 'the solve.', what='additional self weight start')) + asw_blocks.append(_field( + paf, known, problems, 'addl_sw_end_in', False, 'num', + fallback_label='End from top', fallback_symbol='in', + fallback_hint='Where the extra weight ends, measured down ' + 'from the top of the panel. Not yet used by ' + 'the solve.', what='additional self weight end')) + asw_blocks.append(_field( + paf, known, problems, 'addl_sw_magnitude_plf', False, 'num', + fallback_label='Magnitude', fallback_symbol='plf', + fallback_hint='How much extra weight per foot of height is ' + 'added over that stretch. Leave blank for none. ' + 'Not yet used by the solve.', + what='additional self weight magnitude')) + blocks.append(_section('Additional Self Weight', asw_blocks, + collapsed=True)) + + blocks.append({'kind': 'rule'}) + + # -------------------------------------------------------- Design Strips + ds_blocks = [ + {'kind': 'heading', 'text': 'Design Strips', 'level': 2}, + _quiet( + 'Whether the panel is analysed as one member across its full ' + 'width, or split into narrower strips first.'), + ] + ds_blocks.append(_field( + paf, known, problems, 'design_strips_method', False, 'choice', + choices=['Single Member', 'Design Strips'], + fallback_label='Analysis method', + fallback_hint='Whether the whole cross section is analysed as one ' + 'member, or split into design strips. Blank uses ' + 'the whole cross section. Not yet used by the ' + 'solve.', what='the design-strip analysis method')) + ds_state, ds_text = _optional_note( + values, 'design_strips_rows', + blank_sentence='No design strips are defined; the entire cross ' + 'section will be used.', + missing_sentence='Design strips are not collected on this form ' + 'yet -- this is unknown, not "no strips".', + problems=problems, what='the design strip boundaries') + if ds_state == 'filled': + ds_blocks.append(_table_from_text(ds_text, ['ID', 'Strip (as typed)'])) + else: + ds_blocks.append(_sentence(ds_text)) + blocks.append(_section('Design Strips', ds_blocks, collapsed=True)) + + blocks.append({'kind': 'rule'}) + + # --------------------------------------------------------- Free-body + # + # Owner, 2026-09-04: "restrain & support 的那个示意图,本质上是一个 + # freebody" -- one horizontal line standing in for the member, the + # restraints at their true positions, and the loads drawn on it. This + # replaces the old `source: 'elevation'` drawing (panel front/side/ + # section-properties/cross-section) this page used to reuse here -- + # that drawing overlapped its own sub-panels and had nothing to do + # with restraints or loading; it is gone from this page. + # + # Owner, 2026-09-07 (issue #22, item 6b): "把 free body 的图挪到页面的 + # 最底下" -- it is the LAST block on this page, after every collapsed + # section, so everything it draws is entered above it. `_freebody_last` + # in `test_pa_structural.py` pins that. + # + # `aspect` is no longer a flat 0.22 -- see `_freebody_aspect`'s own + # docstring: a diagram with few or no loads/wind zones gets a shorter + # canvas instead of a fixed-height one with empty space at the bottom. + blocks.append({'kind': 'drawing', 'module': 'pa_sketch_freebody', + 'source': 'freebody', 'full_width': True, + 'aspect': _freebody_aspect(values), + 'caption': 'Free body: restraints and loads', + 'depends_on': ['restraint', 'support', 'conc_load', + 'wind_zone', 'bottom_elevation_ft', + 'top_elevation_ft', 'supports_ft']}) + + return blocks, problems diff --git a/WillTools.extension/lib/vibe/pa_supports.py b/WillTools.extension/lib/vibe/pa_supports.py new file mode 100644 index 0000000..084475e --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_supports.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +"""Pre-fills Panel Analysis's Structural Model `supports_ft` field. + +Owner, 2026-09-03 (structural model 没能正确识别 embeds 当成是 support): the +connection embeds already modelled in the panel's assembly ARE the supports, +so a form that leaves `supports_ft` blank is asking the engineer to retype +something the model already says. This module reads the assembly's members +the same way `_assembly_reinforcement` in PanelAnalysis's `script.py` reads +them for the reinforcement fields -- `ticketqc._panel_frame` + +`ticketqc.member_boxes` -- and turns the ones the plant calls out as support +hardware (`panelanalysis.supports.families`, plant knowledge, never a +hardcoded family name) into positions along the panel's length, in the SAME +convention `liftexport.export` uses for a lifter's `x`: feet from the end +that ends up lower once the piece is erected. + +WHICH MEMBERS ARE SUPPORTS is never decided here. `families` is read out of +`know` with default `[]` -- an empty list means "the plant has not said", +which reads as no match, not as a fabricated guess. + +Written for IronPython 2.7: no f-strings, no pathlib. +""" +# pylint: disable=import-error,invalid-name,broad-except + +from vibe import knowledge as K + +# Two boxes are the same support when their length-axis positions land within +# this many feet of each other -- a connection plate is rarely a single point, +# so its members (e.g. a plate plus its bolt/rod assembly) commonly report two +# boxes a couple of inches apart for what is mechanically one support. +DEDUPE_FT = 0.25 + + +def support_positions(boxes, know, length_ft, x_of_box): + """(positions_ft, why) from the assembly's boxes. + + `boxes` -- the list `ticketqc.member_boxes` returns (each a dict with at + least `family`); `know` -- the merged knowledge tree; `length_ft` -- the + panel's length, passed through for a caller building a fuller `why` (not + otherwise required by this function's own arithmetic); `x_of_box` -- a + callable, `box -> feet along the length` (or None when a box cannot be + placed), so this function stays ignorant of Revit units and axes. + + Filters `boxes` to the ones whose `family` matches + `panelanalysis.supports.families` (case-insensitive substring, the same + test `knowledge.matches()` / `knowledge.is_strand()` use), takes each + matched member's `x_of_box` position, DEDUPES positions within + `DEDUPE_FT` of each other (keeping the mean of the group, not the first + or last), sorts ascending, and returns that list plus a sentence naming + how many members of which families produced it. + + An empty `families` list, or no member matching it, both come back as + `([], why)` -- never a fabricated position. `why` is the reason either + way; a caller checking "did this work" should look at the list, not + parse the sentence. + """ + families = K.get(know, 'panelanalysis.supports.families', []) or [] + if not families: + return [], ('no member matched panelanalysis.supports.families ' + '(the list is empty -- the plant has not named which ' + 'embeds are supports)') + + matched = [] # [(family, x_ft), ...] one per matching member + for box in (boxes or []): + family = (box or {}).get('family') or '' + if not K.matches(family, families, []): + continue + try: + x = x_of_box(box) + except Exception: + continue + if x is None: + continue + matched.append((family, float(x))) + + if not matched: + return [], ('no member matched panelanalysis.supports.families ' + '({0})'.format(', '.join(families))) + + matched.sort(key=lambda pair: pair[1]) + + groups = [] # [{'xs': [...], 'families': [...]}, ...] + for family, x in matched: + # Chained on the LAST position added, not the group's running mean -- + # a run of members each 0.2 ft from its neighbour is one support even + # if the run's two ends are more than DEDUPE_FT apart, the same way a + # connection plate's own hardware fans out a few inches at a time. + if groups and (x - groups[-1]['xs'][-1]) <= DEDUPE_FT: + groups[-1]['xs'].append(x) + groups[-1]['families'].append(family) + else: + groups.append({'xs': [x], 'families': [family]}) + + positions = [round(_group_mean(g), 4) for g in groups] + + counts = {} + for family, _x in matched: + counts[family] = counts.get(family, 0) + 1 + by_family = ', '.join('{0} {1}'.format(counts[f], f) + for f in sorted(counts)) + why = ('{0} support position(s) from {1} matching member(s) ' + '({2})'.format(len(positions), len(matched), by_family)) + return positions, why + + +def _group_mean(group): + xs = group['xs'] + return sum(xs) / float(len(xs)) + + +def read_supports(el, asm, know): + """{'supports_ft': ..., 'supports_basis': ...} for the form, or {}. + + Builds the frame and boxes exactly like `_assembly_reinforcement` in + PanelAnalysis's `script.py` does (`ticketqc._panel_frame`, + `ticketqc.member_boxes`), then converts each matched member's box centre + along the length axis into feet from the lower end -- the SAME + convention `liftexport.export`'s `to_x` uses for a lifter, built off the + same `liftexport._axes` / `liftexport.erected_orientation` this module + borrows rather than re-deriving. + + Fewer than two positions comes back as `{}` -- the form's field needs at + least two, and this never invents an end to pad the list out. Any + exception, anywhere in here, is also `{}`: a support guess that is wrong + is worse than a form field the engineer has to type in themselves. + """ + if el is None or asm is None: + return {} + try: + from vibe import ticketqc as tq + from vibe import liftexport as LE + except Exception: + return {} + + try: + frame = tq._panel_frame(el) + boxes = tq.member_boxes(el.Document, asm, el, frame, know) or [] + except Exception: + return {} + if frame is None or not boxes: + return {} + + try: + extents = tq.concrete_extents(el, frame) + except Exception: + extents = None + if extents is None: + return {} + + try: + lo = [v * 12.0 for v in extents[0]] + hi = [v * 12.0 for v in extents[1]] + L, W, T = LE._axes(lo, hi) + length_ft = (hi[L] - lo[L]) / 12.0 + width_ft = (hi[W] - lo[W]) / 12.0 + + vertical_axis, up_is_max, _shares = LE.erected_orientation( + asm, el, frame, (L, W, T), know, (length_ft, width_ft)) + except Exception: + return {} + + if vertical_axis is None: + return {} + if vertical_axis == 'thickness': + # A flat piece: neither end ends up lower, so x keeps the model's + # order -- the same rule `liftexport.export` follows for a flat + # piece (see its `flat_piece` handling). + up_is_max = True + + x_ascends = up_is_max if vertical_axis == 'length' else True + + def x_of_box(box): + try: + box_lo = box.get('lo') + box_hi = box.get('hi') + if box_lo is None or box_hi is None: + return None + center_in = (box_lo[L] + box_hi[L]) / 2.0 + along = (center_in - lo[L]) / 12.0 + except Exception: + return None + return along if x_ascends else length_ft - along + + try: + positions, why = support_positions(boxes, know, length_ft, x_of_box) + except Exception: + return {} + + if len(positions) < 2: + return {} + + text = ', '.join('{0:.2f}'.format(p) for p in positions) + return {'supports_ft': text, 'supports_basis': why} diff --git a/WillTools.extension/lib/vibe/pa_units.py b/WillTools.extension/lib/vibe/pa_units.py new file mode 100644 index 0000000..6a140ce --- /dev/null +++ b/WillTools.extension/lib/vibe/pa_units.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis: decimal feet -> feet-inches display text, shared. + +Owner ruling (`CLAUDE.md` / the pagespec docs): every length this tool +displays is feet-inches to 1/8" (`8'-4 1/2"`), never decimal feet -- the same +rule `tools/pa_web/static/units.js`'s `Units.fmtFtIn` enforces client-side for +plain editable fields. A `kv` block or a read-only table CELL built server- +side (Panel Weight & Centroid, the lifter/dunnage summary tables) is plain +text by the time it reaches the page -- the client never reformats it -- so +the same rounding has to happen here, in CPython/IronPython, before the text +is ever sent. + +Split out of `vibe.pa_extents._feet_str` (2026-09-05) once a second module +(`vibe.pa_handling`) needed the identical formatting for its own kv/table +cells -- one algorithm, not two copies that can drift on a rounding edge. +Standard library only, both Pythons: no f-strings, no `pathlib`, no +`dataclasses`/typing/`enum`. +""" + +# Eighths-of-an-inch -> the fraction word this module prints. A precast +# dimension finer than 1/8" is not meaningful, so `feet_str` rounds to it +# rather than pretending to more precision than the field carries. +EIGHTHS = {0: '', 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', + 5: '5/8', 6: '3/4', 7: '7/8'} + + +def feet_str(value): + """Decimal feet -> an architectural dimension string, e.g. 8'-4 1/2". + + Internal units are decimal feet throughout this codebase (`CLAUDE.md` + SS3), so a bare number is read as feet and converted here to the + feet-inches form the owner requires everywhere a length is displayed. + A value that is already a string is left to the CALLER to decide + whether to pass through unreformatted (a sibling module may hand this a + value that is already formatted correctly, and reformatting a string + that already reads right is how a "2'-11.5\"" becomes a wrong + "2'-11 1/2\"" glued onto a stray decimal) -- this function itself always + tries to read `value` as a plain number. + + Returns `None` only when `value` cannot be read as a number at all -- + never a fabricated dimension. + """ + try: + feet = float(value) + except (TypeError, ValueError): + return None + negative = feet < 0.0 + feet = abs(feet) + whole_ft = int(feet) + inches = (feet - whole_ft) * 12.0 + eighths_total = int(round(inches * 8.0)) + whole_in, eighths = divmod(eighths_total, 8) + if whole_in >= 12: + whole_ft += whole_in // 12 + whole_in = whole_in % 12 + frac = EIGHTHS[eighths] + if frac and whole_in: + in_part = '{0} {1}'.format(whole_in, frac) + elif frac: + in_part = frac + else: + in_part = str(whole_in) + if whole_ft > 0: + text = u'{0}\'-{1}"'.format(whole_ft, in_part) + else: + text = u'{0}"'.format(in_part) + return u'-' + text if negative else text diff --git a/WillTools.extension/lib/vibe/panelanalysisform.py b/WillTools.extension/lib/vibe/panelanalysisform.py new file mode 100644 index 0000000..ff394cc --- /dev/null +++ b/WillTools.extension/lib/vibe/panelanalysisform.py @@ -0,0 +1,3994 @@ +# -*- coding: utf-8 -*- +"""Panel Analysis input form -- pure logic, both Pythons, no Revit. + +The Panel Analysis button pops a confirmation form: one collapsible section +per selected structural-framing panel, each pre-filled with what the model +could read and BLANK where it could not, and each gated so the analysis cannot +run until the fields it needs are filled (the owner's rule, 2026-09-02: + + "按完以后出一个输入界面,在这个界面里面预填充能读到的数据,读不到就留空白, + 用户不输入就不让用户跑分析。" + + Press, then a form; pre-fill what can be read, blank what cannot; do not + let the user run the analysis until they have filled it in.) + +THE FIELDS REPLICATE ERIKSSON WALL'S OWN INPUT SECTIONS, verbatim-named +(owner, 2026-09-02: "表单照着eriksson wall抄 ... Design Criteria, Concrete +Extents, Structural Model, Handling, Reinforcement 这几个板块,都给我照着复刻"). +The field list is taken from Eriksson's actual per-section inputs +(`docs/eriksson-wall-method.md` §A.1 and the extracted golden samples), NOT +invented -- but every LABEL and value here is generic precast nomenclature; no +client, job or sample value is carried. The five section names are the whole +of what "照着复刻" fixed; the GROUPS inside each section are new, added to +mirror Eriksson's own page-by-page blocks (Material Properties / Prestress +Losses / Load Combinations inside Design Criteria; a Stage Options matrix +inside Handling; and so on) without inventing a sixth top-level section. + +THE SECOND OWNER RULING THIS MODULE ANSWERS, 2026-09-02, is legibility, not +completeness: "全都做掉。全部给我做成普通人能看懂的样子" after twice calling the +form unreadable ("太丑了", "你这么输入用户上哪儿能看懂"). A field used to carry +ONLY its engineering name -- `Handling tension limit (n·√f′ci)` -- as if the +symbol were the explanation. Every field below instead carries a plain-English +`label` a non-engineer can read, an engineering `symbol` beside it (never +instead of it -- the Unicode math is not decoration, it is what a reviewing +engineer checks against Eriksson's own printout), and a one-sentence `help` +string saying in words what the field is and what happens if it is left +blank. `display_label(key)` is what an error message names a field by -- +label AND symbol together, so a person who thinks in either vocabulary can +find the field the message means. + +Each field declares which Eriksson SECTION (and, inside it, which GROUP) it +belongs to, and whether it `drives` the Stage-0 self-weight solve: + + 'solve' -- consumed by the `panelanalysis/1` deck the Stage-0 tool reads. + 'stage3' -- a real Eriksson input the Stage-0 solve does NOT use yet + (in-place wind / restraints / mild steel / prestress-loss + method / stage rigging / ...). Shown so the form is + Eriksson-complete, marked as not-yet-driving-analysis, never + faked into a result and never dropped. + 'info' -- a reference/computed value (section type, etc.). + +This module is the SEAM between the WPF form (owner-must-verify, in Revit) and +the deck. It imports no Revit and no `pyrevit`, so it loads on plain CPython +the way the rest of the Revit-free `vibe` does (see `tests/conftest.py`), and +it must run under IronPython 2.7 in the button: no f-strings, no typing, no +dataclasses. + +WHY THE DECK IS BUILT BY HAND HERE, not through `panelanalysis_schema`: that +schema imports `panellift` (CPython dataclasses) and cannot be imported from +IronPython -- the same boundary `liftexport.py` builds the `panellift/1` job +dict by hand across. `build_deck`'s shape mirrors `panelanalysis_schema. +new_deck`, and the host tests feed it straight back through `schema.to_panel` + +the solve to prove the two agree. + +HOW OPENINGS REACH `blockouts` / `recesses` (wired 2026-09-04, `_openings_ +for_deck`): `panelanalysis_schema.to_panel` feeds `concrete_extents. +blockouts` / `.recesses` straight into `panellift.cli.read_panel`, which +expects `{name, x0, y0, length, width, depth_in[, concrete_in]}` -- a +DIFFERENT shape than the `{pos_along, pos_along_from, length, pos_cross, +pos_cross_from, width, pos_vert, height, label}` rows this form collects +(Eriksson's own Openings/Reveals/Solid-Zones table columns). The AXIS +ASSIGNMENT is a judgement call, the SAME one `panelsketch._parse_opening_row` +already made and flagged (medium confidence on the cross/width axis -- there +is nothing else in the codebase to check it against): `pos_along`/`length` is +the panel LENGTH axis (`x0`/`length`, high confidence -- 'along' unambiguously +means along the panel); `pos_vert`/`height` (always measured from the bottom +-- there is no `_from` word for it) is the panel WIDTH axis (`y0`/`width`, +matching `panellift.model.Blockout`'s own golden example, whose `y0` sits +inside `[0, width_ft]`); `pos_cross`/`width`/`pos_cross_from` (left/right) is +the THROUGH-THICKNESS extent. An 'Opening' row always goes full depth +(`depth_in=None`) regardless of what is typed there -- a hole is a hole, +clean through, in Eriksson's own usage; a 'Reveal' row's `depth_in` IS that +extent, in inches (this form's universal feet, x12); `concrete_in` (how much +is left behind a reveal) has no field on this form and is left `None` -- +unmeasured, not zero. 'Solid Zone' rows have no `panellift` shape (neither a +hole nor a blind pocket) and are left out of both lists, counted in a +`concrete_extents._openings_note` sentence rather than silently dropped. + +WHAT IS NOT HERE, on purpose: no clash detection, no `OST_SpecialtyEquipment`, +no support auto-detection. Stage 1 is an input CONFIRMATION form and the user +TYPES the supports (the owner ruled auto support detection is a later +convenience). Reading geometry/materials off the live framing element is the +button's job -- it hands the numbers it managed to read to `prefill()` and +this module never touches a `doc`. +""" + +import re + + +SCHEMA = 'panelanalysis/1' + +# Eriksson's five INPUT section names, in the order the form stacks them. +# Verbatim, per the owner's instruction. GROUPS live inside each section (see +# `sections()`) and are new; the five names themselves are not renegotiable. +SECTION_ORDER = ( + 'Design Criteria', + 'Concrete Extents', + 'Structural Model', + 'Handling', + 'Reinforcement', +) + +# Nominal prestressing-strand section areas by diameter, in^2. Turns a strand +# COUNT + SIZE the user types into the per-strand `area_in2` the deck carries. +# Keyed by both the decimal and the fraction the size is written as. A size not +# in this table is refused by name rather than guessed -- an invented area is a +# fabricated capacity, the cardinal sin here. +# '1/2 SP' is the 1/2" SPECIAL strand -- nominally the same 0.5" diameter as +# plain 1/2" but a larger 0.167 in² section (owner, 2026-09-07, issue #23: +# "the most-used size"), so it is its own dropdown choice, never a spelling +# of '1/2'. Keyed here by the shape `strand_area_in2` normalises to +# (lower-case, quotes and spaces stripped: '1/2 SP' -> '1/2sp'); the older +# bare-decimal '0.52' key stays because stored values may carry it. +_STRAND_AREA_IN2 = { + '0.375': 0.085, '3/8': 0.085, + '0.4375': 0.115, '7/16': 0.115, + '0.5': 0.153, '1/2': 0.153, + '0.52': 0.167, '1/2sp': 0.167, + '0.5625': 0.192, '9/16': 0.192, + '0.6': 0.217, '3/5': 0.217, + '0.7': 0.294, +} + +# Nominal strand DIAMETER, in inches, per spelling the dropdown offers or a +# model read supplies -- what `prestress['diameter_in']` (transfer / +# development length, `panellift.pci.development_length_in`) is built +# from. Before 2026-09-07 that value came from `float()` of the typed text, +# which fails on every fraction spelling ('3/8', '1/2', '9/16') and fell +# back to 0.5 silently, so a 3/8" strand developed as a 1/2" one. '1/2 SP' +# is a 1/2" strand by diameter (its extra is section, not size). +_STRAND_DIA_NOMINAL_IN = { + '0.375': 0.375, '3/8': 0.375, + '0.4375': 0.4375, '7/16': 0.4375, + '0.5': 0.5, '1/2': 0.5, + '0.52': 0.5, '1/2sp': 0.5, + '0.5625': 0.5625, '9/16': 0.5625, + '0.6': 0.6, '3/5': 0.6, + '0.7': 0.7, +} + +# The one dropdown vocabulary for a strand size, shared by the scalar +# `ps_dia_in` field (`FIELD_CHOICES`) and the `strand_row` table's own +# `dia_in` column (`pa_reinforcement._STRAND_COLUMNS` reads this) -- one +# list, so the two can never offer different sizes. +STRAND_DIA_CHOICES = ('3/8', '7/16', '1/2', '1/2 SP', '9/16', '0.6', '0.7') + +DEFAULT_LOSS_PCT = 8.0 +DEFAULT_FPU_KSI = 270.0 +DEFAULT_SELF_WEIGHT_FACTOR = 1.0 +DEFAULT_TENSION_LIMIT_SQRT_FC = 5.0 +DEFAULT_COMPRESSION_LIMIT_RATIO = 0.6 +DEFAULT_PHI_FLEXURE = 0.90 +DEFAULT_PHI_SHEAR = 0.75 +DEFAULT_DEV_MULT = 1.0 + + +def concrete_modulus_psi(fc_psi, unit_weight_pcf): + """ACI 318 concrete modulus of elasticity, in psi: + + Ec = 33 * wc^1.5 * sqrt(f'c) (wc in pcf, f'c in psi) + + the same normal-weight formula Eriksson's own printout uses (checked + against a golden run: wc=150, f'c=6000 -> 4,695,900 psi = 4696 ksi; + f'ci=3500 -> 3,586,500 psi = 3587 ksi, both matching the sample to the + ksi). Returns None -- the could-not-compute answer, never a guessed zero + -- when either input is missing, non-numeric, or not positive. Callers + round to psi for display; Eci is this same function on f'ci. + """ + try: + fc = float(fc_psi) + wc = float(unit_weight_pcf) + except (TypeError, ValueError): + return None + if fc <= 0 or wc <= 0: + return None + return 33.0 * (wc ** 1.5) * (fc ** 0.5) + + +# Fields COMPUTED from other fields rather than typed or read: key -> +# ((source_key, ...), compute_fn). The initial value is computed in +# `prefill`; the WPF renderer (script.py) re-runs `derived_value` live as a +# source field changes, one-directionally (source -> derived) and only until +# the user edits the derived field itself, after which their value stands (the +# owner's rule: editing f'c updates Ec, but editing Ec never rewrites f'c and +# freezes Ec's auto-update). Keep this the single source of truth for the +# derived relationships so a new one is added in one place, host-testable. +DERIVED_FIELDS = { + 'conc_ec_psi': (('fc_psi', 'unit_weight_pcf'), concrete_modulus_psi), + 'conc_eci_psi': (('fci_psi', 'unit_weight_pcf'), concrete_modulus_psi), +} + + +def derived_value(key, values): + """The computed value of a derived field from the current form `values`, + or None when any source is blank/unreadable (so the caller shows blank, + never a fabricated number). Rounding to a display string is the caller's + job (`_fmt(round(v))`).""" + spec = DERIVED_FIELDS.get(key) + if not spec: + return None + sources, fn = spec + args = [] + for s in sources: + v, _err = _parse_num(values.get(s) if values else None) + if v is None: + return None + args.append(v) + try: + return fn(*args) + except Exception: + return None + + +def derived_dependents(source_key): + """The derived-field keys that read `source_key`, in `DERIVED_FIELDS` order. + + The renderer's live wiring asks this on every edit: "what, if anything, + follows from the box that just changed?". Keeping the answer here rather + than as a reversed table in the WPF file means a new derived relationship + is declared once, in `DERIVED_FIELDS`, and the live update follows for free + -- and it is host-testable, which the WPF side is not. + + Returns a TUPLE, empty for a key nothing derives from (the ordinary case -- + most fields drive nothing). + """ + if not source_key: + return () + out = [] + for key in sorted(DERIVED_FIELDS): + sources, _fn = DERIVED_FIELDS[key] + if source_key in sources: + out.append(key) + return tuple(out) + + +def format_number(value): + """A computed number as the string the form shows. None -> blank. + + The public name for `_fmt`: the renderer writes derived values back into + live editors and needs exactly the formatting `prefill` used, or a field + would visibly change shape ('4696' -> '4696.0') the moment it recomputed. + """ + return _fmt(value) + + +# Constant, job-independent DEFAULTS the owner set for a fresh panel (2026-09-03): +# normal-weight concrete, rebar 60/29000, mesh 80/29000, standard 270 ksi / +# 28500 ksi strand. These are generic precast policy, not one job's data, and a +# real value read off the model always wins -- `prefill` only applies a default +# where the model supplied nothing. None triggers the prestress group +# (ps_fpu_ksi/strand_ep_ksi are reference/extra keys, not the prestress +# trigger), so defaulting them cannot make an un-prestressed panel fail to run. +# The Prestress Losses seeds (owner, 2026-09-03): the PCI method is the +# dropdown option string exactly as FIELD_CHOICES lists it, and 70% relative +# humidity / 8 days to release / 14 days to erection are the PCI handbook's +# usual assumptions for the loss estimate that follows. +PREFILL_DEFAULTS = { + 'concrete_type': 'Normalweight', + 'rebar_fy_ksi': '60', + 'rebar_es_ksi': '29000', + 'mesh_fy_ksi': '80', + 'mesh_es_ksi': '29000', + 'ps_fpu_ksi': '270', + 'strand_ep_ksi': '28500', + 'ps_loss_method': 'PCI', + 'ps_loss_humidity_pct': '70', + 'ps_loss_release_hours': '8', + 'ps_loss_erection_days': '14', + # Design Criteria / Stress Limits & Factors -- the generic code values; + # a plant's own numbers arrive through `settings_from_plant` first and + # win (owner, 2026-09-03: "settings 里面也要做预选预先填充"). + 'handling_tension_sqrt': '5', + 'handling_comp_ratio': '0.6', + 'inplace_tension_sqrt': '7.5', + 'inplace_comp_ratio': '0.6', + 'phi_flexure': '0.9', + 'phi_shear': '0.75', + # dev_mult 1.3 and second_order 'Yes' -- owner ruling, 2026-09-04: 1.3 is + # Eriksson's own strand-normal development multiplier (ACI 318's + # development length for strand), and P-Delta / second-order effects are + # on by default; the plant settings path (`settings_from_plant`) still + # overrides both where a plant has pinned its own value. + 'dev_mult': '1.3', + 'second_order': 'Yes', + 'cracking_assumption': 'Uncracked', +} + + +# The Stress Limits & Factors fields a plant may pin in its knowledge layer +# under `panelanalysis.settings.` (same key names as the form). +SETTINGS_KEYS = ('handling_tension_sqrt', 'handling_comp_ratio', + 'inplace_tension_sqrt', 'inplace_comp_ratio', + 'phi_flexure', 'phi_shear', 'dev_mult', 'deflection_limit', + 'second_order', 'cracking_assumption') + + +def _yes_no(value): + text = str(value).strip().lower() + if text in ('', 'no', 'none', 'false', '0', 'off'): + return 'No' + return 'Yes' # 'yes', 'P-Delta', 'true', 'moment magnification'... + + +def settings_from_plant(settings): + """String form values from a plant's `panelanalysis.settings` mapping. + + `settings` is the mapping read off the knowledge layers (or None). + Numbers come back as their shortest string ('0.9', '6', 'L/150' as is); + `second_order` normalises to the form's Yes / No (a plant writes the + METHOD there -- 'P-Delta' -- and any method means Yes); the cracking + assumption normalises to Cracked / Uncracked. A key the plant does not + carry, or carries as None / blank, is left out so `PREFILL_DEFAULTS` + fills it. Never raises. + """ + out = {} + if not isinstance(settings, dict): + return out + for key in SETTINGS_KEYS: + value = settings.get(key) + if value is None: + continue + text = str(value).strip() + if not text: + continue + if key == 'second_order': + out[key] = _yes_no(text) + elif key == 'cracking_assumption': + out[key] = 'Cracked' if text.lower().startswith('crack') else 'Uncracked' + elif isinstance(value, float) and value == int(value): + out[key] = str(int(value)) + else: + out[key] = text + return out + + +# The ACI 318 load combinations included by default (owner, 2026-09-03): +# strength 1, 2, 5, 7 and service 1, 2, 5, 9. The combination tables live in +# the Design Criteria section (pa_criteria) with one Include tick per row keyed +# `load_combo...include`; `prefill` pre-ticks exactly this set and +# leaves the rest for the user. The user may tick/untick any of them and edit +# the factors -- this is the starting selection, never an enforced one. +LOAD_COMBO_DEFAULT_INCLUDED = { + 'strength': (1, 2, 5, 7), + 'service': (1, 2, 5, 9), +} + + +def load_combo_include_key(name, n): + """The values-dict key for one Include tick, e.g. + 'load_combo.strength.1.include' -- mirrors pa_criteria's own key format.""" + return 'load_combo.{0}.{1}.include'.format(name, n) + + +# The load-combination FACTOR columns, in the order the table prints them: +# dead, live, roof live, wind, seismic, temperature/restraint, lateral earth. +LOAD_COMBO_FACTORS = ('D', 'L', 'R', 'W', 'E', 'T', 'H') + +# The standard ACI 318 basic strength and service combinations -- PUBLIC CODE +# TEXT, the same rows any ACI-compliant tool prints, carrying no job data. +# Each row is (id, D, L, R, W, E, T, H), verbatim from where they used to live +# in `pa_criteria._STRENGTH_COMBOS` / `_SERVICE_COMBOS`. They moved here because +# the owner asked for the factors to be EDITABLE (2026-09-03): the moment a +# factor is a form value rather than printed text, the table stops being a +# renderer detail and becomes part of the schema -- one place that knows both +# the code default and the key it is stored under, host-testable, with the +# section module reading it rather than owning a second copy. +LOAD_COMBO_ROWS = { + 'strength': ( + ('1', '1.4', '0', '0', '0', '0', '1', '1.6'), + ('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'), + ('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'), + ('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'), + ('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'), + ('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'), + ('7', '0.9', '0', '0', '1', '0', '1', '1.6'), + ('8', '0.9', '0', '0', '0', '1', '1', '1.6'), + ), + 'service': ( + ('1', '1', '0', '0', '0', '0', '0.75', '1'), + ('2', '1', '1', '0', '0', '0', '0.75', '1'), + ('3', '1', '0', '1', '0', '0', '0.75', '1'), + ('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'), + ('5', '1', '0', '0', '0.6', '0', '0.75', '1'), + ('6', '1', '0', '0', '0', '0.7', '0.75', '1'), + ('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'), + ('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'), + ('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'), + ('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'), + ), +} + + +def load_combo_factor_key(name, n, col): + """The values-dict key for one editable factor cell, e.g. + 'load_combo.strength.1.D' -- the same `load_combo.
..` + shape the Include tick uses, so one row's cells all sit together.""" + return 'load_combo.{0}.{1}.{2}'.format(name, n, col) + + +def load_combo_row_ids(name, values): + """Every row id in one table: the code rows first, then the user's own. + + A user row is discovered from the values dict, not from a counter kept + somewhere else -- the form adds a row by writing its keys, so the keys ARE + the record of what rows exist, and a row survives anything that rebuilds + the table from `values`. User ids are 'u1', 'u2', ... and are returned in + numeric order after every code row, so a table never renumbers under the + user (the row they typed into keeps its id). + """ + ids = [row[0] for row in LOAD_COMBO_ROWS.get(name, ())] + prefix = 'load_combo.{0}.u'.format(name) + extra = [] + for key in (values or {}): + if not key.startswith(prefix) or not key.endswith('.include'): + continue + rid = key[len('load_combo.{0}.'.format(name)):-len('.include')] + if '.' in rid or rid in extra: + continue + try: + int(rid[1:]) + except (TypeError, ValueError): + continue + extra.append(rid) + extra.sort(key=lambda r: int(r[1:])) + return ids + extra + + +def load_combinations(values): + """The TICKED load combinations, as [{table, id, factors, included}]. + + One entry per row whose Include tick is on, in table then row order. Each + `factors` maps a column letter to the parsed number; a cell that will not + parse comes back `None` with a sentence in that entry's `problem` -- never + a silent 0, which is a real factor and would read as "this load is + deliberately excluded" rather than "nobody could read what you typed". A + blank cell is also None, with its own reason: the user cleared it, and an + empty factor is not a zero factor. + """ + values = values or {} + out = [] + for name in ('strength', 'service'): + for rid in load_combo_row_ids(name, values): + tick, _err = _parse_bool(values.get(load_combo_include_key( + name, rid))) + if tick is not True: + continue + factors = {} + problems = [] + for col in LOAD_COMBO_FACTORS: + raw = values.get(load_combo_factor_key(name, rid, col)) + val, err = _parse_num(raw) + factors[col] = val + if err: + problems.append( + '{0} combination {1}: factor {2} {3}'.format( + name, rid, col, err)) + elif val is None: + problems.append( + '{0} combination {1}: factor {2} is blank -- a blank ' + 'factor is not a zero factor'.format(name, rid, col)) + entry = {'table': name, 'id': rid, 'factors': factors, + 'included': True} + if problems: + entry['problem'] = '; '.join(problems) + out.append(entry) + return out + +# ------------------------------------------------------------- row tables +# +# A second row-table mechanism, generalising `load_combo_row_ids` / +# `load_combo_factor_key` above for the tables the owner ruled must be typed, +# EDITABLE grids rather than a free-text box or a locked-down summary +# (2026-09-04: "只是让你预先输入,不是让你直接锁死啊" for restraints; wind +# pressure/suction and where it applies typed directly instead of an ASCE +# input set; a concentrated-load editor was simply missing; "rebar 怎么可能 +# 是打字上去?" for the two reinforcement tables). One string value per cell, +# keyed `
..` (n = 1-based, ASCENDING, never reused once a row +# has ever existed -- see `_row_ids_any`), plus a per-row presence flag under +# the `present` column ('yes' / anything else). A row is never actually +# deleted from `values` by `remove_row`, only marked absent, so removing a +# row and adding a new one never collides with an id an in-flight edit or an +# undo still refers to. This is deliberately the SAME shape +# `load_combo_row_ids` / `load_combo_factor_key` already use for the +# strength/service tables (`load_combo.
..`) -- generalised to +# a bare `
` here so a page module can build a new row-editable table +# without reinventing the key format. +# +# The five tables built ON this mechanism today, each `(name, cols)`: +# +# restraint RESTRAINT_COLS in-place lateral restraints +# conc_load CONC_LOAD_COLS concentrated (bearing) loads +# wind_zone WIND_ZONE_COLS manually-typed wind pressure/suction zones +# strand_row STRAND_ROW_COLS the prestressing strand schedule +# mild_bar MILD_BAR_COLS mild (non-prestressed) reinforcement + + +def row_key(table, n, col): + """The `values` dict key for one row-table cell, e.g. + 'restraint.3.elevation_ft' -- the same '
..' shape + `load_combo_factor_key` uses for its own (sub-namespaced) tables.""" + return '{0}.{1}.{2}'.format(table, n, col) + + +def _row_ids_any(table, values): + """Every row id `table` has EVER carried a key for in `values`, present + or not. `add_row` uses this (never `row_ids`) to pick a new row's id, so + a removed row's id is never handed to a different row -- ids only ever + go up, the same discipline `load_combo_row_ids`'s user rows ('u1', 'u2', + ...) already follow.""" + values = values or {} + prefix = table + '.' + ids = set() + for key in values: + if not key.startswith(prefix): + continue + n_txt = key[len(prefix):].split('.', 1)[0] + try: + n = int(n_txt) + except (TypeError, ValueError): + continue + if n > 0: + ids.add(n) + return ids + + +def row_ids(table, values): + """Every PRESENT row id in `table`, ascending. + + A row counts as present when its `present` cell reads 'yes' + (case-insensitive, matching `_parse_bool`'s vocabulary loosely); a row + whose `present` cell is missing, blank, or anything else ('no', a + removed row) is not returned. Always ascending, never insertion order, + so a caller never has to sort this again. + """ + values = values or {} + out = [n for n in _row_ids_any(table, values) + if (values.get(row_key(table, n, 'present')) or '').strip() + .lower() == 'yes'] + out.sort() + return out + + +def rows_of(table, values, cols): + """[{'n': n, col: value, ...}, ...] -- one dict per PRESENT row, + ascending `n`, every `col` read as its raw string ('' when the cell was + never written). `n` (an int) rides along on every row so a validation + message can name 'row 3' without the caller re-deriving it from the key + it came from.""" + values = values or {} + out = [] + for n in row_ids(table, values): + row = {'n': n} + for col in cols: + row[col] = values.get(row_key(table, n, col)) or '' + out.append(row) + return out + + +def add_row(table, values, cols, defaults=None): + """Writes a new row's `present` flag and every column's starting text + into `values` IN PLACE and returns its `n`. + + The new id is one past the HIGHEST id `table` has ever used, present or + removed (`_row_ids_any`) -- so removing row 2 and adding a new row never + reuses '2' for a different row. `defaults` maps a subset of `cols` to + their starting text ('' for anything not named). A `values` that is not + a (mutable) mapping writes nothing and returns None -- the same + could-not-act discipline `apply_defaults` uses for a malformed `values`. + """ + try: + existing = _row_ids_any(table, values) + n = (max(existing) + 1) if existing else 1 + values[row_key(table, n, 'present')] = 'yes' + defaults = defaults or {} + for col in cols: + values[row_key(table, n, col)] = defaults.get(col) or '' + return n + except (TypeError, AttributeError): + return None + + +def remove_row(table, values, n): + """Marks row `n` absent (`present` -> 'no') IN PLACE. The cell values + are left sitting in `values`, harmless and unread -- `row_ids` / + `rows_of` never return an absent row, and `n` is never reused by + `add_row` regardless (see `_row_ids_any`). A `values` that is not a + mutable mapping is a no-op.""" + try: + values[row_key(table, n, 'present')] = 'no' + except (TypeError, AttributeError): + pass + + +# Table name + column-order constants for the five tables above. Column +# order matches the ROW-TABLE VALUE CONTRACT exactly (the same order a +# renderer should draw them in). `_n` NEVER appears in a `cols` tuple -- it +# is not a cell, it rides along on every `rows_of` entry automatically. + +RESTRAINT_TABLE = 'restraint' +RESTRAINT_COLS = ('elevation_ft', 'type', 'gravity', 'thermal', + 'stiffness_kin') +# Eriksson's own restraint-type vocabulary, plus 'Floor tie' (the owner's +# addition) -- offered, never enforced (the box stays free text/editable). +RESTRAINT_TYPE_CHOICES = ('Pin', 'Fixed', 'Free', 'Floor tie') + +CONC_LOAD_TABLE = 'conc_load' +CONC_LOAD_COLS = ('label', 'elevation_ft', 'offset_in', 'dead_k', 'live_k') + +WIND_ZONE_TABLE = 'wind_zone' +WIND_ZONE_COLS = ('label', 'from_elev_ft', 'to_elev_ft', 'width_ft', + 'pressure_psf', 'suction_psf') +WIND_MODE_CHOICES = ('Manual', 'From project config') + +STRAND_ROW_TABLE = 'strand_row' +STRAND_ROW_COLS = ('count', 'depth_in', 'dia_in', 'pull_lb') + +MILD_BAR_TABLE = 'mild_bar' +MILD_BAR_COLS = ('size', 'count', 'depth_in', 'direction', 'from_ft', + 'to_ft', 'spacing_in') +MILD_BAR_DIRECTION_CHOICES = ('Longitudinal', 'Transverse') +# ASTM standard rebar area per bar, in^2 (#3 through #11) -- public code +# data, not a plant standard. `MILD_BAR_SIZE_CHOICES` is this table's own +# keys, in size order, for a renderer's dropdown. +MILD_BAR_AREA_IN2 = { + '#3': 0.11, '#4': 0.20, '#5': 0.31, '#6': 0.44, '#7': 0.60, + '#8': 0.79, '#9': 1.00, '#10': 1.27, '#11': 1.56, +} +MILD_BAR_SIZE_CHOICES = ('#3', '#4', '#5', '#6', '#7', '#8', '#9', '#10', + '#11') + +# Owner ruling, 2026-09-05: bearing/support positions are a row table like +# every other structural input, not a comma-separated text box -- one row +# per position along the panel's length, add/remove the same way restraints +# do. `SUPPORT_COLS` is deliberately one column: a position has nothing +# else to carry (no type, no stiffness -- those live on the RESTRAINT table +# for the elevations that actually tie the panel to something; a support +# row here only says where THIS analysis treats the panel as held up). +SUPPORT_TABLE = 'support' +SUPPORT_COLS = ('x_ft',) + +# Owner ruling, 2026-09-05: openings/reveals/solid zones are a row table, +# not a free-text field with an undiscoverable seven-field-per-line grammar. +# Columns are ABSOLUTE positions/extents along the panel's own two axes +# (x_ft/length_ft along the length, always measured from the panel's start; +# y_ft/width_ft across the width, always measured from the bottom) -- the +# same simplification the `restraint` table already made for elevations +# (typed directly, no `_from` word to interpret). `depth_in` is meaningful +# only for a Reveal (how far it cuts into the panel's thickness); left blank +# for an Opening (always full depth) or a Solid Zone (no `panellift` shape +# either way, see `_openings_for_deck`). This REPLACES the old +# `{pos_along, pos_along_from, length, pos_cross, pos_cross_from, width, +# pos_vert, height}` shape everywhere in this module -- `openings_rows` (the +# `PASSTHROUGH_KEYS` entry, `panelsketch`'s own contract, and +# `_openings_for_deck`'s input) is now built FROM this table's rows by +# `_opening_table_rows`, not read as a separate structured list. +OPENING_TABLE = 'opening' +OPENING_COLS = ('type', 'x_ft', 'length_ft', 'y_ft', 'width_ft', 'depth_in') +OPENING_TYPE_CHOICES = ('Opening', 'Reveal', 'Solid zone') + +# Owner ruling, 2026-09-07 (issue #23, item 9h): an INTERRUPT is a position +# along the panel's length where reinforcement is cut and development +# restarts -- a break or a saw cut, independent of any opening (which +# `panellift.prestress.cuts_for` already handles for strands). One row per +# interrupt: `x_ft` along the length from the panel's start, the same axis +# and datum `OPENING_COLS`' `x_ft` and `MILD_BAR_COLS`' `from_ft` use, and +# `cuts`, which reinforcement it severs there. Collected here, on the +# page, only; the development-length restart is the companion engine +# issue's job and nothing reads this table into the deck yet. +INTERRUPT_TABLE = 'interrupt' +INTERRUPT_COLS = ('x_ft', 'cuts') +INTERRUPT_CUTS_CHOICES = ('All', 'Strands', 'Rebar', 'Mesh') + +# Owner ruling, 2026-09-05: `face_lifters` / `edge_lifters` free text +# ("3.50 @ 0.18", one lifter per line) becomes two editable row tables. +# `face_lifter` is an absolute position pair (x_ft along the length, y_ft +# across the width) -- the same "typed directly, no fraction to interpret" +# simplification the `opening`/`restraint` tables already made. `edge_lifter` +# NAMES the edge directly (Top/Bottom = the panel's two long sides, measured +# along the length; Left/Right = its two ends, measured across the width) -- +# see `vibe.pa_sketch_handling._edge_lifter_rows_from_table`'s own comment +# for the mapping onto that module's existing kind/side vocabulary. Both +# tables are read by `vibe.pa_sketch_handling` (the drawing) and +# `vibe.pa_handling` (the summary table) in preference to the legacy +# `face_lifters`/`edge_lifters` text, which stays a `FIELDS` entry read only +# as a fallback for a panel saved before these tables existed. +FACE_LIFTER_TABLE = 'face_lifter' +FACE_LIFTER_COLS = ('x_ft', 'y_ft') +EDGE_LIFTER_TABLE = 'edge_lifter' +EDGE_LIFTER_COLS = ('edge', 'position_ft') +EDGE_LIFTER_EDGE_CHOICES = ('Top', 'Bottom', 'Left', 'Right') + +# Which ROW TABLE replaced each legacy free-text field (issue #22, item 11). +# `validate()` still reports a problem under the legacy key when it falls +# back to it -- `supports_ft` ("at least two support positions are needed") +# is the standing case -- but that key is no longer a rendered field, so a +# page looking the problem up by key found nothing to highlight and the +# refusal sank into the banner's general bucket with no pointer. The page +# reads this map (serialised as `problem_targets` by `pa_pagespec`) to +# route such a problem onto the table that actually holds the answer now. +# Keep this beside the table constants: a new legacy/table split is added +# here or the page cannot point at it. +LEGACY_TABLE_KEYS = { + 'supports_ft': SUPPORT_TABLE, + 'supports': SUPPORT_TABLE, + 'openings': OPENING_TABLE, + 'openings_rows': OPENING_TABLE, + 'face_lifters': FACE_LIFTER_TABLE, + 'edge_lifters': EDGE_LIFTER_TABLE, +} + + +def _F(key, label, symbol, help, required, kind, section, group, drives): + """One field record, as a dict -- the unit `FIELDS` is built from. + + A dict, not a namedtuple: this record grew from a 6-tuple to 9 named + parts over two rulings (Eriksson parity, then plain-language labels), and + a positional tuple that wide is unreadable at every call site that builds + or reads one. Every reader below goes through `_spec` / `display_label` / + `help_of` / `symbol_of`, never a raw index, so the dict shape is the only + place the field's parts are named. + + Keys: 'key' (the values-dict / deck key), 'label' (plain English, no + engineering notation), 'symbol' (the engineering notation + unit, shown + BESIDE the label, never replacing it -- '' when there is none), 'help' + (one sentence, plain words, says what happens if left blank), 'required' + (bool -- the Run-button gate), 'kind' ('text' | 'num' | 'int' | + 'supports' | 'openings' | 'strands' -- what parses it), 'section' (one of + SECTION_ORDER), 'group' (a name inside the section, or None for the + section's own ungrouped/primary fields), 'drives' ('solve' | 'stage3' | + 'info', as above). + """ + return { + 'key': key, 'label': label, 'symbol': symbol, 'help': help, + 'required': required, 'kind': kind, 'section': section, + 'group': group, 'drives': drives, + } + + +# The form fields, in display order within their section/group. See `_F`'s +# docstring for what each part means. Kept in Eriksson's own page order inside +# each section so a reviewer can tell it was copied, not reinvented. +FIELDS = [ + # ============================================================= + # Design Criteria / Project Info (Eriksson Project Info p.1 header) + # ============================================================= + _F('project_name', 'Project name', '', + "The project this panel belongs to. Pre-filled from the model's " + 'Project Information (Project Name). Reference only.', + False, 'text', 'Design Criteria', 'Project Info', 'info'), + _F('client_name', 'Client', '', + "The client on the model's Project Information (Client Name). " + 'Reference only.', + False, 'text', 'Design Criteria', 'Project Info', 'info'), + _F('design_date', 'Design date', '', + 'The date this analysis is prepared; defaults to today. Reference ' + 'only.', + False, 'text', 'Design Criteria', 'Project Info', 'info'), + _F('job_number', 'Job number', '', + "This job's tracking number, read from the Design Summary sheet's " + 'title block where the model carries it. Reference only.', + False, 'text', 'Design Criteria', 'Project Info', 'info'), + # ============================================================= + # Design Criteria / Material Properties (Eriksson Project Info p.1) + # ============================================================= + _F('concrete_type', 'Concrete type', '', + 'What kind of concrete this panel is cast from (normal weight, ' + 'lightweight, ...). Reference only -- nothing computes from it yet.', + False, 'text', 'Design Criteria', 'Material Properties', 'info'), + _F('fc_psi', 'Concrete strength at 28 days', u"f′c (psi)", + "The concrete's design strength once it has fully cured; every stress " + 'limit on this form is a multiple of this number. Required -- the ' + 'solve cannot run without it.', + True, 'num', 'Design Criteria', 'Material Properties', 'solve'), + _F('fci_psi', 'Concrete strength at release', u"f′ci (psi)", + 'How strong the concrete needs to be when the strand is cut or the ' + 'panel is stripped from the bed -- usually lower than the 28-day ' + 'strength. Leave blank if this panel is not prestressed.', + False, 'num', 'Design Criteria', 'Material Properties', 'solve'), + _F('unit_weight_pcf', 'Concrete unit weight', 'pcf', + 'How much the concrete weighs per cubic foot; the self-weight of the ' + 'panel is computed from this. Required.', + True, 'num', 'Design Criteria', 'Material Properties', 'solve'), + # The SECOND wythe's material properties, for a Sandwich section only. + # The wythe-1 fields above (fc_psi / fci_psi / unit_weight_pcf) stay the + # plain, un-suffixed names -- they describe wythe 1 (the "main" concrete), + # and the pair is what makes a sandwich's per-wythe Concrete Properties + # answerable. These three are NOT required and do NOT drive the solve yet: + # collected so the form is honest that a sandwich has two concretes, and + # shown (by the renderer) only while section_type is Sandwich. + _F('fc2_psi', 'Concrete strength at 28 days, wythe 2', u"f′c₂ (psi)", + 'The 28-day design strength of the SECOND concrete wythe, for a ' + 'Sandwich section only -- normally the same concrete as wythe 1 ' + 'unless the panel is a mix. Pre-filled from wythe 1 as a starting ' + 'assumption. Not yet used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('fci2_psi', 'Concrete strength at release, wythe 2', u"f′ci₂ (psi)", + 'The release strength of the SECOND concrete wythe, for a Sandwich ' + 'section only. Pre-filled from wythe 1 as a starting assumption. ' + 'Leave blank if the panel is not prestressed or the wythes are the ' + 'same concrete. Not yet used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('unit_weight2_pcf', 'Concrete unit weight, wythe 2', 'pcf', + 'How much the SECOND concrete wythe weighs per cubic foot, for a ' + 'Sandwich section only (e.g. a lightweight architectural wythe on a ' + 'normal-weight structural one). Pre-filled from wythe 1 as a ' + 'starting assumption. Not yet used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('conc_eci_psi', 'Concrete elastic modulus at release', u"E′ci (psi)", + "How stiff the concrete is at release strength. Computed by ACI 318 " + "from the release strength and unit weight (Ec = 33 wc^1.5 sqrt(f'ci)) " + 'and kept up to date as you edit those; type a value to override it.', + False, 'num', 'Design Criteria', 'Material Properties', 'info'), + _F('conc_ec_psi', 'Concrete elastic modulus at 28 days', u"E′c (psi)", + "How stiff the concrete is once fully cured. Computed by ACI 318 from " + "the 28-day strength and unit weight (Ec = 33 wc^1.5 sqrt(f'c)) and " + 'kept up to date as you edit those; type a value to override it.', + False, 'num', 'Design Criteria', 'Material Properties', 'info'), + _F('rebar_fy_ksi', 'Mild rebar yield strength', 'fy (ksi)', + 'The stress at which the ordinary (non-prestressed) reinforcing bar ' + 'starts to yield. Shown for the record; not yet used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('rebar_es_ksi', 'Mild rebar modulus of elasticity', 'Es (ksi)', + 'How stiff the ordinary reinforcing steel is. Shown for the record; ' + 'not yet used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('mesh_fy_ksi', 'Mesh yield strength', 'fy (ksi)', + 'The yield strength of the welded wire mesh. Defaults to the common ' + '80 ksi. Shown for the record; not yet used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('mesh_es_ksi', 'Mesh modulus of elasticity', 'Es (ksi)', + 'How stiff the welded wire mesh is. Defaults to 29000 ksi. Shown for ' + 'the record; not yet used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('strand_ep_ksi', 'Strand modulus of elasticity', 'Ep (ksi)', + 'How stiff the prestressing strand is. Shown for the record; not yet ' + 'used by the solve.', + False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), + _F('ps_fpu_ksi', 'Strand ultimate strength', u'fₚᵤ (ksi)', + 'The breaking strength of the prestressing strand; used with the ' + 'per-group pull percentage below to work out the jacking force. ' + 'Leave blank if this panel is not prestressed.', + False, 'num', 'Design Criteria', 'Material Properties', 'solve'), + # ============================================================= + # Design Criteria / Prestress Losses (Eriksson Project Info p.1) + # ============================================================= + _F('ps_loss_method', 'Prestress loss method', '', + 'Which published method (PCI, AASHTO, ...) the assumed prestress ' + 'loss below comes from. Reference only -- the solve uses the loss ' + 'percentage you type in Reinforcement directly, not this method.', + False, 'text', 'Design Criteria', 'Prestress Losses', 'stage3'), + _F('ps_loss_humidity_pct', 'Ambient humidity assumed', '%', + 'The relative humidity the loss method above uses -- losses are ' + 'larger in drier air. Not yet used by the solve.', + False, 'num', 'Design Criteria', 'Prestress Losses', 'stage3'), + _F('ps_loss_release_hours', 'Hours from casting to release', '', + 'How many hours after casting the strand is cut / the panel is ' + 'stripped. Not yet used by the solve.', + False, 'num', 'Design Criteria', 'Prestress Losses', 'stage3'), + _F('ps_loss_erection_days', 'Days from casting to erection', '', + 'How many days after casting the panel is erected in place. Not yet ' + 'used by the solve.', + False, 'num', 'Design Criteria', 'Prestress Losses', 'stage3'), + # ============================================================= + # Design Criteria / Load Combinations (Eriksson Project Info p.1) + # ============================================================= + _F('load_combinations', 'Load combinations checked', '', + 'Which ACI 318 strength and service load combinations apply to this ' + 'panel, in your own words. Not yet used by the solve -- the in-place ' + 'engine builds these itself later.', + False, 'text', 'Design Criteria', 'Load Combinations', 'stage3'), + # ============================================================= + # Design Criteria / Stress Limits & Factors (Eriksson Settings p.2) + # ============================================================= + _F('handling_tension_sqrt', 'Tension allowed while handling', + u"n·√f′ci", + 'How much the concrete may be pulled apart (tension) during ' + 'stripping / handling, as a multiple of √f′ci. Blank uses the ' + 'built-in default.', + False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), + _F('handling_comp_ratio', 'Compression allowed while handling', + u"n·f′ci", + 'How much the concrete may be squeezed (compression) during ' + "handling, as a fraction of f′ci. Blank uses the built-in default.", + False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), + _F('phi_flexure', 'Bending strength reduction factor', u'φ, flexure', + "The ACI safety factor applied to the panel's computed bending " + 'strength. Blank uses the built-in default (0.90).', + False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), + _F('phi_shear', 'Shear strength reduction factor', u'φ, shear', + "The ACI safety factor applied to the panel's computed shear " + 'strength. Blank uses the built-in default (0.75).', + False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), + _F('dev_mult', 'Strand development length multiplier', '', + 'A multiplier on the strand development length used near the ends ' + 'of the panel. Blank uses the built-in default (1.0).', + False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), + _F('inplace_tension_sqrt', 'Tension allowed once erected', u"n·√f′c", + 'The same tension limit as above, but for the panel once it is ' + 'standing in place rather than being handled. Not yet used by the ' + 'solve.', + False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), + _F('inplace_comp_ratio', 'Compression allowed once erected', u"n·f′c", + 'The same compression limit as above, but once the panel is ' + 'standing in place. Not yet used by the solve.', + False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), + _F('deflection_limit', 'Deflection limit', 'L / n', + 'How much the panel is allowed to bow or sag, as a fraction of its ' + 'span. Read into the in-place service check (`_service_from`); ' + 'blank leaves that check NOT JUDGED.', + False, 'text', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), + _F('second_order', 'Second-order effects', '', + 'Whether P-delta / second-order bending is considered for this ' + 'panel. Not yet used by the solve.', + False, 'text', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), + _F('cracking_assumption', 'Cracked or uncracked section assumed', '', + 'Whether the stress check assumes the concrete section is cracked ' + 'or uncracked. Read into the in-place service check as the ' + 'cracking class (`_service_from`).', + False, 'text', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), + # ============================================================= + # Concrete Extents (Eriksson p.3; section properties are COMPUTED, not + # input, so they have no field here) + # ============================================================= + _F('section_type', 'Section type', '', + 'What the cross-section is: Solid or Sandwich (insulated). Choosing ' + 'Sandwich reveals the per-wythe Concrete Extents fields and the ' + 'second wythe\'s material properties. Reference only.', + False, 'text', 'Concrete Extents', None, 'info'), + # Top/bottom elevation: read off the framing element's own world-space + # bounding box (`pa_elevation.elevations_from_bbox`, wired by the + # button's `_read_elevations`) for a piece modelled standing up in + # place. Declared here (owner ruling, 2026-09-05) so a value typed into + # either one is actually saved -- `pa_extents._field` only prints its + # "not a field yet" limitation note when `panelanalysisform` does not + # know the key, and these two are read (with their own + # `*_basis` sentence, carried through by `prefill`'s generic "every + # other read lands too" pass) on every model-backed run. `drives='info'`: + # `_bottom_elevation` / `_wind_from` / `_restraints_from` read the raw + # string straight out of `values`, not this parse, so nothing here needs + # to block the run over a value that will not parse as a number. + _F('top_elevation_ft', 'Top elevation', 'ft', + 'How high the top edge of the panel sits above the project ' + 'reference elevation. Pre-filled from the model\'s own bounding box ' + 'for a piece modelled standing up in place; leave blank if not yet ' + 'known.', + False, 'num', 'Concrete Extents', None, 'info'), + _F('bottom_elevation_ft', 'Bottom elevation', 'ft', + 'How high the bottom edge of the panel sits above the project ' + 'reference elevation. Pre-filled the same way as Top elevation, ' + 'and shared by the wind-zone and restraint elevation-to-position ' + 'conversions. Leave blank if not yet known.', + False, 'num', 'Concrete Extents', None, 'info'), + _F('length_ft', 'Panel length', 'ft', + "The panel's long dimension, end to end. Required -- everything " + 'downstream is measured against this span.', + True, 'num', 'Concrete Extents', None, 'solve'), + _F('width_ft', 'Panel width', 'ft', + "The panel's dimension across the span -- how wide it is. Required.", + True, 'num', 'Concrete Extents', None, 'solve'), + _F('thickness_in', 'Panel thickness', 'in', + 'How thick the panel is. Required.', + True, 'num', 'Concrete Extents', None, 'solve'), + _F('top_in_form', 'Which face is up in the casting bed', + 'Interior / Exterior', + 'Which face of the panel faces up while it is cast. Reference only.', + False, 'text', 'Concrete Extents', None, 'stage3'), + _F('left_miter', 'Left edge mitered?', '', + 'Whether the left edge of the panel is cut at a miter angle rather ' + "than square. Leave blank if it is not -- Eriksson prints a sentence " + "('Left miter not included in design') for that case rather than a " + 'blank field; this form leaves it blank instead. Not yet used by ' + 'the solve.', + False, 'text', 'Concrete Extents', None, 'stage3'), + _F('right_miter', 'Right edge mitered?', '', + 'The same as the left-edge question above, for the right edge. Not ' + 'yet used by the solve.', + False, 'text', 'Concrete Extents', None, 'stage3'), + # The top edge's own formed step -- separate from `top_in_form` (which + # face is up in the bed). Boolean stored as 'yes'/'' the same way the + # restraint table's `gravity` flag is, per this form's own convention; + # declared here (owner ruling, 2026-09-05) so the width/height typed + # beside it are actually saved. + # Eriksson Wall's "top in form" STEP: a change in the formed thickness + # at the top edge (a rebate / step in the section profile), which is a + # different thing from `top_in_form` above (which face is up). Declared + # so a saved value survives, but NOT RENDERED since issue #22 (item 4, + # 2026-09-07): nothing in `pa_sketch_extents`, the weight/centroid + # summary or `panellift` reads these three, and the owner's complaint + # was a checkbox plus two number boxes that accepted input and changed + # nothing ("我尝试打勾,填进去数字,也没啥反应啊"). They come back onto the + # page the day `outline_geometry_ft` / `section_at` fold the step into + # the drawn section, the weight and the centroid -- not before. + _F('top_in_form_check', 'Top edge has a formed step', '', + 'Whether the top edge of the panel has a formed step -- a change ' + 'in the formed thickness at the top edge, not which face faces up. ' + 'Leave unchecked if there is none. Not yet used by the solve, and ' + 'not shown on the page until it is.', + False, 'text', 'Concrete Extents', None, 'stage3'), + _F('top_in_form_width_in', 'Formed step width', 'in', + 'How wide the formed step at the top edge is, if there is one. ' + 'Leave blank if there is no step. Not yet used by the solve, and ' + 'not shown on the page until it is.', + False, 'num', 'Concrete Extents', None, 'stage3'), + _F('top_in_form_height_in', 'Formed step height', 'in', + 'How tall the formed step at the top edge is, if there is one. ' + 'Leave blank if there is no step. Not yet used by the solve, and ' + 'not shown on the page until it is.', + False, 'num', 'Concrete Extents', None, 'stage3'), + _F('openings', 'Openings, reveals and solid zones', '', + 'One row per opening, reveal or solid zone, one per line: ' + "'type, pos_along, pos_along_from, length, pos_cross, " + "pos_cross_from, width, pos_vert, height [, label]' -- e.g. " + "'Opening, 2, start, 3, 4, left, 5, 1, 6, Door' is an opening 2 ft " + "from the start of the panel, 3 ft long, 4 ft in from the left edge " + "and 5 ft wide, 1 ft up from the bottom and 6 ft tall, labelled " + "'Door'. type is Opening / Reveal / Solid Zone; the two 'from' " + "words are start/end and left/right. Leave blank if there are none. " + 'Not yet used by the solve (see the module docstring for why).', + False, 'openings', 'Concrete Extents', 'Openings / Reveals', 'stage3'), + # The three thicknesses of a sandwich (insulated) panel's cross-section, + # read off the model's layup when the piece is detected as a Sandwich. + # Each is in inches, displayed top wythe -> insulation -> bottom wythe. + # Shown only while `section_type` is Sandwich -- the renderer collapses + # the whole group otherwise. + _F('wythe_top_in', 'Top wythe thickness', 'in', + 'How thick the TOP concrete wythe of the sandwich is, as read ' + 'through the piece\'s thickness (the outermost layer on the high ' + 'side of the local thickness axis). Applies only when the Section ' + 'type is Sandwich. Pre-filled from the model when it can be read; ' + 'blank means "not read", never zero.', + False, 'num', 'Concrete Extents', 'Sandwich Wythes', 'stage3'), + _F('insulation_in', 'Insulation thickness', 'in', + 'How thick the insulation core between the two concrete wythes is. ' + 'Applies only when the Section type is Sandwich. Pre-filled from the ' + 'model when it can be read; blank means "not read", never zero.', + False, 'num', 'Concrete Extents', 'Sandwich Wythes', 'stage3'), + _F('wythe_bottom_in', 'Bottom wythe thickness', 'in', + 'How thick the BOTTOM concrete wythe of the sandwich is, as read ' + 'through the piece\'s thickness (the outermost layer on the low ' + 'side of the local thickness axis). Applies only when the Section ' + 'type is Sandwich. Pre-filled from the model when it can be read; ' + 'blank means "not read", never zero.', + False, 'num', 'Concrete Extents', 'Sandwich Wythes', 'stage3'), + _F('ext_soil_loads', 'Exterior soil loads', '', + 'Any soil load pressing on the panel from outside (a below-grade or ' + 'retaining condition). Leave blank if there are none. Not yet used ' + 'by the solve.', + False, 'text', 'Concrete Extents', 'Soil Loads', 'stage3'), + _F('int_soil_loads', 'Interior soil loads', '', + 'The same as the exterior soil question above, for the interior ' + 'face. Not yet used by the solve.', + False, 'text', 'Concrete Extents', 'Soil Loads', 'stage3'), + # ============================================================= + # Structural Model (Eriksson p.5) + # ============================================================= + # Owner ruling, 2026-09-05: support positions are now an EDITABLE ROW + # TABLE (`SUPPORT_TABLE` / `SUPPORT_COLS`, drawn by `pa_structural.page` + # as an `editable_table` block) prefilled off the same `supports_ft` + # model read this field used to hold directly -- one row per position, + # add/remove like every other row table. `supports_ft` itself STAYS a + # `FIELDS` entry (its `display_label`/`help_of` are still used by the + # "at least two supports" validation message) but is no longer rendered + # as its own field; it is read only as a HIDDEN FALLBACK for a values + # file saved before this table existed and carrying no `support.*` rows. + _F('supports_ft', 'Support positions', 'ft, along the length', + 'Where the panel is held up for THIS analysis, measured along its ' + 'length; at least two. Required -- an unsupported panel cannot be ' + 'checked. Superseded by the Support Positions table below; kept ' + 'only so an older saved panel with no table rows still reads.', + True, 'supports', 'Structural Model', None, 'solve'), + # `restraints` and `conc_loads` (free-text boxes) are GONE -- owner ruling + # 2026-09-04 ("只是让你预先输入,不是让你直接锁死啊" for restraints; a + # concentrated-load editor was simply missing). Both are now ROW TABLES + # (`RESTRAINT_TABLE` / `CONC_LOAD_TABLE`, via `row_key`/`row_ids`/ + # `rows_of`/`add_row`/`remove_row`), not a `FIELDS` entry at all -- the + # same way the Handling stage matrix is a grid rather than a field. See + # `_restraints_from` / `_conc_loads_from`. + # + # Wind: same ruling, same day -- "既然要用户直接输入 wind pressure/ + # suction,那就别要前面那些东西了。前面那些应该在 project-config 里面, + # 从这里移除。添加一个按钮,按照 project-config 计算,还是用户手动输入。 + # 现在就只做用户手动输入的部分。手动输入不仅仅是 windload,还有 apply + # 在什么位置,从哪个 elevation 到哪个 elevation,多大的宽度。" Every ASCE + # input (Vult, risk/exposure/enclosure, code version, C&C/MWFRS chapter, + # mean roof height, effective wind area) and the old direct-entry + # pressure/suction pair are GONE from this form -- ASCE site data belongs + # to project config, a later "From project config" button this form + # explicitly refuses to run today (see `validate`). What is left is + # `wind_mode` (this one real FIELDS entry) plus the `wind_zone` ROW TABLE + # (`WIND_ZONE_TABLE`) the user types pressure/suction and where it + # applies into directly. See `_wind_from`. + _F('wind_mode', 'Wind input mode', '', + 'Whether wind pressure and suction are typed directly (Manual) or ' + 'computed from the project-config ASCE 7 site data (From project ' + "config). Only Manual is built today -- choosing the other refuses " + 'to run rather than fabricate a pressure.', + False, 'text', 'Structural Model', 'Wind', 'stage3'), + _F('seismic_cs', 'Seismic coefficient', u'Cₛ, % of dead load', + "The seismic force on the panel, as a percentage of its own dead " + 'weight. Not yet used by the solve.', + False, 'num', 'Structural Model', 'Seismic', 'stage3'), + # ============================================================= + # Handling (Eriksson p.6-7; the stage matrix itself is `STAGE_OPTIONS` / + # `stage_matrix()` below, not a FIELDS entry -- see that section's + # docstring for why) + # ============================================================= + _F('weight_lb', 'Panel weight (override)', 'lb', + "The panel's weight, if you already know it. Leave blank to have " + 'it computed from the geometry and unit weight above.', + False, 'num', 'Handling', None, 'solve'), + _F('self_weight_factor', 'Self-weight load factor', '', + "A multiplier on the panel's own weight for the handling load " + 'cases (e.g. 1.5 for a dynamic lift) when a stage does not set its ' + 'own. Blank uses the built-in default (1.0).', + False, 'num', 'Handling', None, 'solve'), + _F('face_lifters', 'Face lifters', 'column @ fraction of width', + 'Where the flat-face lifting inserts are, as a column position ' + 'and a fraction across the width. Not yet used by the solve.', + False, 'text', 'Handling', 'Lifter & Dunnage Positions', 'stage3'), + _F('edge_lifters', 'Edge lifters', 'column @ fraction of width', + 'Where the edge lifting inserts are, the same way as the face ' + 'lifters above. Not yet used by the solve.', + False, 'text', 'Handling', 'Lifter & Dunnage Positions', 'stage3'), + # ============================================================= + # Reinforcement (Eriksson p.8) + # ============================================================= + # Owner ruling, 2026-09-07 (issue #23, items 9b / 9e): once the + # `strand_row` table has any rows, `ps_count` is DISPLAY ONLY -- the sum + # of the rows' own counts, never a second number typed beside them + # (`pa_reinforcement._strand_block` renders it as a kv row then, and + # `_prestress_from` no longer compares it against the rows) -- and the + # rows are the ONLY source of initial pull: `ps_pull_lb` is not shown on + # the page any more and is not a fallback for a row that left its pull + # blank (that row is an error naming the row). The key is kept declared + # because the model read (`pa_reinf.strand_prefill`) still supplies it + # and `prefill` seeds each new row's `pull_lb` from it. + _F('ps_count', 'Strand count', '', + 'The total number of prestressing strands. Once the strand rows ' + 'below exist this is their sum, shown for reference, not typed.', + False, 'int', 'Reinforcement', 'Prestressing Strands', 'solve'), + _F('ps_dia_in', 'Strand diameter', 'in', + 'The prestressing strand size (e.g. 1/2, 1/2 SP, 3/8), used to look ' + 'up its cross-sectional area for any strand row that does not give ' + 'its own.', + False, 'text', 'Reinforcement', 'Prestressing Strands', 'solve'), + _F('ps_pull_lb', 'Initial pull per strand (model read)', 'lb', + 'The jacking force per strand read off the model. Not shown on the ' + 'page: it only seeds the initial pull of strand rows at pre-fill, ' + 'and every row carries its own.', + False, 'num', 'Reinforcement', 'Prestressing Strands', 'solve'), + # `ps_rows` (free text, 'count @ depth_in [@ pull_pct]' per line) is GONE + # -- owner ruling 2026-09-04 ("rebar 怎么可能是打字上去?而不是进入 + # model 的?"): the strand schedule is a ROW TABLE now, `STRAND_ROW_TABLE` + # (count / depth_in / dia_in / pull_lb per row), not a text box. See + # `_prestress_from`. + _F('ps_loss_pct', 'Assumed prestress loss', '%', + 'How much of the initial pull is assumed lost to shrinkage, creep ' + 'and relaxation by the time it matters. Blank uses the built-in ' + 'default.', + False, 'num', 'Reinforcement', 'Prestressing Strands', 'solve'), + _F('strand_rail_location', 'Strand rail location', '', + 'Which casting-bed rail the strands run along. Read when placing ' + 'the model-read strand positions across the width ' + '(`_model_strand_x_ft`): Right edge mirrors, Centerline offsets.', + False, 'text', 'Reinforcement', 'Prestressing Strands', 'stage3'), + _F('strand_rail_offset_in', 'Strand rail offset', 'in', + 'How far the strands sit off the rail. Not yet used by the solve.', + False, 'num', 'Reinforcement', 'Prestressing Strands', 'stage3'), + # `mild_as` (free text, comma-joined mark names / callouts) is GONE -- + # same 2026-09-04 ruling as `ps_rows`: mild reinforcement is a ROW TABLE + # now, `MILD_BAR_TABLE` (size / count / depth_in / direction / from_ft / + # to_ft / spacing_in per row), not a text box. See `_mild_bars_from`. + # Owner ruling, 2026-09-07 (issue #23, item 9g): mesh is a STRUCTURED + # input -- a designation that resolves to wire spacing and area + # (`wwf_designation`, shown resolved on the page), a layer count and a + # depth to its CG -- not a bare text box. The designation stays typed + # (and refused by name when it does not parse) rather than a dropdown, + # because WHICH designations a plant stocks is plant knowledge nobody + # has supplied yet; the capacity side is the companion engine issue. + _F('mesh', 'Welded wire mesh', 'WWF', + "The welded wire mesh designation, spacing then wire size, e.g. " + "'6x6-W2.9xW2.9' (longitudinal x transverse; W = plain wire, D = " + "deformed; the number is the wire area in hundredths of a square " + "inch). Resolved to wire spacing and area below. Not yet used by " + "the solve.", + False, 'text', 'Reinforcement', 'Mild Reinforcement', 'stage3'), + _F('mesh_layers', 'Mesh layers', '', + 'How many layers of the welded wire mesh above the panel carries. ' + 'Leave blank if there is none or it is unknown. Not yet used by the ' + 'solve.', + False, 'int', 'Reinforcement', 'Mild Reinforcement', 'stage3'), + _F('mesh_depth_in', 'Mesh depth to CG', 'in', + 'How deep the mesh sits, from the face the strand depths are ' + 'measured from to the centre of the wires. For a sandwich panel ' + 'with a layer in each wythe, the depth of the first layer. Not yet ' + 'used by the solve.', + False, 'num', 'Reinforcement', 'Mild Reinforcement', 'stage3'), +] + +# The prestress fields form one optional group: all blank (and no +# `strand_row` table rows) means "no prestress", either filled means +# "describe it fully". `ps_ecc_in` is gone, and so -- 2026-09-04 -- is +# `ps_rows`: the strand schedule is the `STRAND_ROW_TABLE` row table now, +# not a text field, so it is no longer one of these four TEXT keys +# (`_prestress_from` checks the table separately). See `parse_strand_rows`, +# still used at PREFILL time to seed the table from a text read. +# `ps_pull_lb` left this tuple on 2026-09-07 (issue #23, 9e): it is no +# longer a form input, so a model-read value sitting in it must not by +# itself mean "prestress described" -- only the rows and the two typed +# scalars do. +_PRESTRESS_KEYS = ('ps_count', 'ps_dia_in') + +# Parsed inline by `_prestress_from` rather than the generic num/int loop in +# `validate`, alongside `_PRESTRESS_KEYS` -- so they are skipped there too. +_PRESTRESS_EXTRA_KEYS = ('ps_loss_pct', 'ps_fpu_ksi') + +# Keys the model can pre-fill off the framing element. Anything the button +# could not read is absent (or None) and comes up blank. +PREFILLABLE = ('mark', 'design_number', 'length_ft', 'width_ft', + 'thickness_in', 'fc_psi', 'fci_psi', 'unit_weight_pcf', + 'weight_lb', 'concrete_type', 'section_type', + 'wythe_top_in', 'insulation_in', 'wythe_bottom_in', + 'fc2_psi', 'fci2_psi', 'unit_weight2_pcf', + 'project_name', 'client_name', 'design_date', + 'job_number', 'conc_ec_psi', 'conc_eci_psi', + 'rebar_fy_ksi', 'rebar_es_ksi', 'mesh_fy_ksi', 'mesh_es_ksi', + 'ps_fpu_ksi', 'strand_ep_ksi', 'ps_dia_in', 'ps_count', + 'ps_pull_lb', + # Read off the assembly's members / the design spec, not off a + # panel parameter (see the button's `_read_reinforcement`). + # `ps_rows` / `mild_as` are read as ROW-TABLE SOURCES (see + # `prefill`'s row-tables section), not through this whitelist + # -- they no longer name a `FIELDS` entry to pre-fill. + 'mesh', 'mesh_layers') + SETTINGS_KEYS + +# mark / design_number are shown as a header per panel rather than as fields +# inside a section; they still pre-fill and ride into the deck. +_HEADER_KEYS = ('mark', 'design_number') + +# Reads the button hands to `prefill` that are STRUCTURED VALUES, not +# display strings -- `openings_rows` (the grouped opening/reveal rows the +# Concrete Extents page and the sketches consume) and, 2026-09-04, +# `strand_x_in` (a flat per-strand x list, inches from the left rail, a +# later reads sibling supplies -- `pa_sketch_reinf.section`'s own +# `_explicit_strand_x` is the reader). A structured value must never be run +# through `_fmt` (a list would come out as its repr), so `prefill` copies +# these verbatim under their own keys and the `PREFILLABLE` string loop +# never sees them. +PASSTHROUGH_KEYS = ('openings_rows', 'strand_x_in') + +# Reads consumed as the SOURCE text/list for seeding a ROW TABLE at prefill +# time (`restraint`, `strand_row`, `mild_bar` -- see prefill's "row tables" +# section), never a `FIELDS` entry any more. Named here so the generic +# "every other read the button recovered lands too" pass below does not +# also dump the raw text into `values` under these keys -- it is read +# straight off `reads` where it is used, and would otherwise sit in +# `values` unused and confusing (there is no editor for a key like +# 'ps_rows' any more). `mild_bar_rows`, the structured alternative to +# `mild_as`, is a PASSTHROUGH_KEYS-shaped list read the same way +# `openings_rows` is -- it is read directly off `reads`, never through +# `values`, so it needs no entry here. +_ROW_TABLE_SOURCE_READ_KEYS = ('restraints', 'ps_rows', 'mild_as') + +# Fields that are only meaningful for a Sandwich (insulated) panel: the two +# concrete wythes' thicknesses + the insulation between them (Concrete +# Extents / Sandwich Wythes) and the SECOND wythe's material properties +# (Design Criteria / Material Properties). For this tool a section is Solid +# or Sandwich and nothing else, so these are shown ONLY while the panel's +# `section_type` is Sandwich: the pa_* section modules emit their rows, and +# the renderer builds each one collapsed and flips it when the section_type +# editor changes (see script.py's `_render_field` / PanelForm). +SANDWICH_ONLY_KEYS = ('wythe_top_in', 'insulation_in', 'wythe_bottom_in', + 'fc2_psi', 'fci2_psi', 'unit_weight2_pcf') + + +# Fields whose value is really a pick-list, so the form shows a dropdown +# instead of a free-text box (owner, 2026-09-03: "很多东西应该是选项,而不是让 +# 用户填写" -- many of these should be options, not something the user types). +# The renderer (script.py `_render_field`) upgrades any of these keys to an +# editable ComboBox over these options when the section module rendered the +# field as plain text/number; a field a section module already declares a +# 'choice' keeps its own options. The box stays EDITABLE -- these are the +# common answers, never an enforced set and never a default (the box opens on +# the pre-filled value, or blank). Every option is generic precast / ASCE +# nomenclature and carries no client or job data. +FIELD_CHOICES = { + 'concrete_type': ('Normalweight', 'Lightweight', 'Sand-Lightweight'), + 'section_type': ('Solid', 'Sandwich'), + 'top_in_form': ('Interior', 'Exterior'), + 'left_miter': ('Yes', 'No'), + 'right_miter': ('Yes', 'No'), + 'second_order': ('Yes', 'No'), + 'cracking_assumption': ('Cracked', 'Uncracked'), + 'ps_loss_method': ('PCI', 'AASHTO'), + 'ps_dia_in': STRAND_DIA_CHOICES, + # The ASCE wind category dropdowns (risk / exposure / enclosure / code + # version / C&C / MWFRS chapter) are GONE -- 2026-09-04 owner ruling, + # site data moved to project config; see the module docstring's Wind + # section and `_wind_from`. `wind_mode` is the one wind pick-list left + # on this form. + 'wind_mode': WIND_MODE_CHOICES, + # Added 2026-09-04, owner: "很多东西应该是选项" -- governing code and the + # rest of the enumerated Design Criteria / Structural Model settings + # become pick-lists too. Every ComboBox stays EDITABLE (script.py's + # `make_editor`), so these are offered options, never an enforced set -- + # a value outside the list still round-trips. + 'design_spec': ('ACI 318-19', 'ACI 318-14', 'ACI 318-11'), + 'design_criteria.analysis_beta_d': ('Non-Sway', 'Sway'), + 'design_criteria.cracking_method': ('Mast Method', 'Uncracked', + 'Cracked'), + 'design_criteria.flexure_method': ('Whitney Stress Block', + 'Strain Compatibility'), + 'design_criteria.flexure_cracking_check': ('None', '1.2 Mcr', '1.33 Mu'), + 'design_criteria.deflection_cantilever_limit': ('L/75', 'L/120', + 'L/150'), + 'deflection_limit': ('L/150', 'L/120', 'L/240', 'L/360'), + 'load_opt_offset_from': ('Deflection Section', 'Top in Form', + 'Bottom in Form'), + 'seismic_apply_to': ('None', 'Out-of-plane', 'In-plane', 'Both'), + 'restraint_foundation_connection': ('Pin', 'Fixed', 'Free'), + 'strand_rail_location': ('Left edge', 'Right edge', 'Centerline'), +} + + +def choices_for(key): + """The dropdown options for a pick-list field, or None for a free-text one. + + Consulted by the renderer to turn a field a section module drew as plain + text into a ComboBox. Returns None (free text) for any key not listed -- + the could-not-judge default, never an empty tuple that would render an + empty dropdown. The words `wind_enclosure` returns normalize cleanly + through `_normalize_enclosure` ('Partially enclosed' -> 'partially_enclosed'), + so the GCpi readout keeps working when that field is a dropdown. + """ + opts = FIELD_CHOICES.get(key) + return tuple(opts) if opts else None + + +def _spec(key): + for f in FIELDS: + if f['key'] == key: + return f + return None + + +def label_of(key): + """The plain-English label alone (no symbol) -- 'Piece mark', not 'PM'.""" + if key == 'mark': + return 'Piece mark' + if key == 'design_number': + return 'Design number' + spec = _spec(key) + return spec['label'] if spec else key + + +def symbol_of(key): + """The engineering notation + unit shown beside the label, or ''.""" + spec = _spec(key) + return (spec['symbol'] if spec else '') or '' + + +def help_of(key): + """The one-sentence plain-language help line, or '' if there is none.""" + spec = _spec(key) + return (spec['help'] if spec else '') or '' + + +def display_label(key): + """'label — symbol' for messages a person reads, e.g. errors. + + Both halves matter here: the plain word for someone new to the form, the + engineering notation for someone checking this against Eriksson's own + printout. Never drop either. + """ + label = label_of(key) + symbol = symbol_of(key) + if symbol: + return u'{0} — {1}'.format(label, symbol) + return label + + +def required_keys(): + return [f['key'] for f in FIELDS if f['required']] + + +def sections(): + """[(section_name, [(group_name, [field, ...]), ...]), ...]. + + In display order: `SECTION_ORDER`, and inside each section the fields + with no `group` (Concrete Extents' own geometry, Structural Model's + supports, ...) come first as a `(None, [...])` bucket, followed by each + named group in the order it first appears in `FIELDS` -- Eriksson's own + page order, since `FIELDS` is written in that order. + + The Handling section's Stage Options matrix is NOT a group here -- it has + no per-field rows, it is a grid. The button renders it separately, via + `stage_matrix()`, positioned inside the Handling section alongside these + groups (see that function's docstring). + """ + out = [] + for name in SECTION_ORDER: + secfields = [f for f in FIELDS if f['section'] == name] + ungrouped = [f for f in secfields if not f['group']] + groups = [] + if ungrouped: + groups.append((None, ungrouped)) + seen = [] + for f in secfields: + g = f['group'] + if g and g not in seen: + seen.append(g) + for g in seen: + groups.append((g, [f for f in secfields if f['group'] == g])) + out.append((name, groups)) + return out + + +# --------------------------------------------------------------- formatting + +def _fmt(value): + """A model-read value as the string the form shows. None -> blank.""" + if value is None: + return '' + if isinstance(value, bool): + return '' + if isinstance(value, float): + if value == int(value): + return str(int(value)) + return ('%.4f' % value).rstrip('0').rstrip('.') + return str(value) + + +def prefill(reads, know=None): + """Initial form values from what the model supplied. + + `reads` carries the `PREFILLABLE` keys the button read off the framing + element, each STRING-valued, PLUS the structured `PASSTHROUGH_KEYS` + (today `openings_rows`, a list of opening/reveal row dicts) copied + through verbatim. A key that is absent or None comes up blank -- the + owner's rule: pre-fill what is readable, blank the rest, and let the + user fill the blanks before anything runs. Every field the model cannot + supply (supports, load factors, prestress, the stage matrix, all the + stage3 in-place inputs) starts blank too. + + `know` is optional -- the merged knowledge-layer mapping (job/plant/config, + same shape `knowledge.load()` returns) the caller may hand through so a + sibling `vibe.pa_defaults.apply_defaults(values, know)` can fill in + everything the owner ruled a sane, plant-agnostic default rather than a + real read ("按一下 panel analysis,然后手动输入 loading,剩下的它就能跑 + 了", 2026-09-04). A caller that passes nothing behaves exactly as before + -- `know` defaults to `None` and `pa_defaults` is free to treat that as + "no plant layer available" rather than an error. + """ + reads = reads or {} + values = {} + for f in FIELDS: + key = f['key'] + values[key] = _fmt(reads.get(key)) if key in PREFILLABLE else '' + # PREFILLABLE keys that the pa_* section modules render under their own + # labels (job number, the elastic moduli, mesh, strand) are not all FIELDS + # entries; give every one a starting value so its editor opens on the read + # value rather than blank. + for key in PREFILLABLE: + if key not in values: + values[key] = _fmt(reads.get(key)) + for key in _HEADER_KEYS: + values[key] = _fmt(reads.get(key)) + # EVERY OTHER read the button recovered lands too. `PREFILLABLE` used to + # be the only door, and it silently dropped the model-measured supports, + # lifters, elevations and restraints the reads had already found -- + # measured 2026-09-04 on a real wall: `supports_basis` said "6 support + # positions" while the form opened with `supports_ft` blank, which is + # exactly the blank the owner saw that morning. A read the button went to + # the trouble of making is the form's to show; the whitelist above only + # says which keys are EXPECTED, not which are allowed. `*_basis` reads + # (the one-sentence provenance beside each read) are kept together under + # `_basis` so the runlog and the page can show why a field is what it is + # -- they are not fields and `validate` / `build_deck` never see them. + basis = {} + for key, value in reads.items(): + if key.endswith('_basis'): + basis[key] = value + continue + if key in PASSTHROUGH_KEYS or key in _HEADER_KEYS \ + or key in _ROW_TABLE_SOURCE_READ_KEYS: + continue + if key in values and (values.get(key) or '').strip(): + continue + if value is None or isinstance(value, (list, dict)): + continue + values[key] = _fmt(value) + values['_basis'] = basis + # Structured reads (openings_rows) pass through as the LIST the section + # module / sketches read -- never `_fmt`'d into a repr string. A missing + # key -> [] (a real "no openings" answer), so the page builds a clean + # empty table instead of degrading to its "not present" fallback. + for key in PASSTHROUGH_KEYS: + value = reads.get(key) + values[key] = value if isinstance(value, list) else [] + for key in stage_field_keys(): + values[key] = '' + # Constant, generic defaults -- only where the model read nothing, so a + # real value always wins (owner, 2026-09-03: normal weight, rebar 60/29000, + # mesh 80/29000, 270 ksi strand). + for key, default in PREFILL_DEFAULTS.items(): + if not (values.get(key) or '').strip(): + values[key] = default + # A Sandwich panel's second wythe is normally the SAME concrete as its + # first -- only an architectural/lightweight face wythe differs. So where + # the piece is a Sandwich and a wythe-2 material field is still blank, + # seed it from the wythe-1 value as a starting assumption the user can + # overwrite; a real read value always wins, and a Solid panel gets no + # such copy (the fields stay blank for a Solid section). + if (values.get('section_type') or '').strip().lower() == 'sandwich': + for pair in (('fc2_psi', 'fc_psi'), ('fci2_psi', 'fci_psi'), + ('unit_weight2_pcf', 'unit_weight_pcf')): + target, source = pair + if not (values.get(target) or '').strip(): + values[target] = values.get(source) or '' + # Elastic moduli: compute from f'c / f'ci + unit weight so they arrive + # filled (owner: "Eci, Ec 你不会算?"). The renderer keeps them live as the + # source fields change, until the user overrides them. + for key in DERIVED_FIELDS: + if not (values.get(key) or '').strip(): + dv = derived_value(key, values) + if dv is not None: + values[key] = _fmt(round(dv)) + # Pre-tick the default load combinations (owner: strength 1,2,5,7 / + # service 1,2,5,9); every other combo's tick stays absent -> unchecked. + for name, nums in LOAD_COMBO_DEFAULT_INCLUDED.items(): + for n in nums: + values[load_combo_include_key(name, n)] = 'yes' + # Seed every code row's FACTORS as editable starting values (owner, + # 2026-09-03: the factors are the user's to change). The ACI defaults go in + # so a fresh form shows the code table rather than a grid of empty boxes, + # and anything the user types then simply replaces the seed. + for name, rows in LOAD_COMBO_ROWS.items(): + for row in rows: + for i, col in enumerate(LOAD_COMBO_FACTORS): + values[load_combo_factor_key(name, row[0], col)] = row[i + 1] + + # ----------------------------------------------------------- row tables + # The owner's 2026-09-04 rulings: restraints must be pre-filled but + # EDITABLE (never locked); wind becomes purely user-typed pressure/ + # suction zones; concentrated loads need an editor at all; and the two + # reinforcement schedules are rows a model reads into, not text a person + # types. Seeded here, straight off `reads` (not `values`), after every + # generic model-read / default pass above so 'width_ft', 'length_ft', + # 'bottom_elevation_ft', 'top_elevation_ft', 'ps_dia_in' and + # 'ps_fpu_ksi' are already whatever this panel's `values` will show. + + # `restraint`: seeded from the read `restraints` text -- the SAME + # comma/whitespace-separated elevation list the button has always + # supplied under that key; only the free-text box it used to fill is + # gone. Row 1 (the lowest-numbered reading -- the panel's own + # foundation) defaults to 'Pin'; every other row defaults to 'Floor + # tie', Eriksson's own restraint-type vocabulary. A read that does not + # parse seeds nothing (never a partial or guessed table). + restraint_xs, _err = parse_supports(reads.get('restraints')) + for i, elev in enumerate(restraint_xs or [], 1): + add_row(RESTRAINT_TABLE, values, RESTRAINT_COLS, defaults={ + 'elevation_ft': _fmt(elev), + 'type': 'Pin' if i == 1 else 'Floor tie', 'gravity': 'yes', + }) + + # `support`: seeded from the SAME `supports_ft` model read the old + # free-text field used to show directly -- one editable row per bearing + # position (owner ruling, 2026-09-05). A read that does not parse seeds + # no rows, same discipline as `restraint` above. + support_xs, _err = parse_supports(reads.get('supports_ft')) + for x in (support_xs or []): + add_row(SUPPORT_TABLE, values, SUPPORT_COLS, + defaults={'x_ft': _fmt(x)}) + + # `opening`: seeded from the model's OWN `openings_rows` read (the + # button's `_read_openings`, one row per hole/blind recess it found on + # the panel's own geometry, always `pos_along_from='start'` / + # `pos_cross_from='Face'` per that reader's own contract -- so no + # `_from` word needs interpreting here, the same simplification + # `restraint` already made for elevations). Owner ruling, 2026-09-05: + # one editable row per opening/reveal/solid zone, in this table's own + # x_ft/length_ft (along the length, from the start) / y_ft/width_ft + # (across the width, from the bottom) / depth_in (a Reveal's + # through-thickness cut, converted from the model read's own feet-deep + # `width` -- blank for an Opening, which always goes full depth). + # `values['openings_rows']` (below, PASSTHROUGH_KEYS) stays populated + # with the model's raw list too, as `validate`'s fallback for a panel + # saved before this table existed. + for _orow in (reads.get('openings_rows') or []): + if not isinstance(_orow, dict): + continue + _rtype = (_orow.get('type') or 'Opening').strip() + if _rtype not in OPENING_TYPE_CHOICES: + _rtype = 'Opening' if _rtype.lower() != 'reveal' else 'Reveal' + _depth_in = '' + if _rtype == 'Reveal' and _orow.get('width') is not None: + try: + _depth_in = _fmt(round(float(_orow['width']) * 12.0, 3)) + except (TypeError, ValueError): + _depth_in = '' + add_row(OPENING_TABLE, values, OPENING_COLS, defaults={ + 'type': _rtype, + 'x_ft': _fmt(_orow.get('pos_along')), + 'length_ft': _fmt(_orow.get('length')), + 'y_ft': _fmt(_orow.get('pos_vert')), + 'width_ft': _fmt(_orow.get('height')), + 'depth_in': _depth_in, + }) + + # `face_lifter` / `edge_lifter`: seeded from the model's legacy + # `face_lifters` / (`top_edge_lifters` + `side_edge_lifters`, else + # `edge_lifters`) text reads -- one editable row per lifter (owner + # ruling, 2026-09-05). Needs `width_ft`/`length_ft`, which the generic + # model-read pass above has already landed in `values` by this point. + _fl_width_ft, _fl_werr = _parse_num(values.get('width_ft')) + _fl_length_ft, _fl_lerr = _parse_num(values.get('length_ft')) + if _fl_werr: + _fl_width_ft = None + if _fl_lerr: + _fl_length_ft = None + + for _fx, _ffrac in _parse_xfrac_lines(reads.get('face_lifters')): + if not _fl_width_ft: + continue + add_row(FACE_LIFTER_TABLE, values, FACE_LIFTER_COLS, defaults={ + 'x_ft': _fmt(_fx), + 'y_ft': _fmt(round(_ffrac * _fl_width_ft, 4)), + }) + + _top_edge_rows = _parse_xfrac_lines(reads.get('top_edge_lifters')) + _side_edge_rows = _parse_xfrac_lines(reads.get('side_edge_lifters')) + if _top_edge_rows or _side_edge_rows: + for _ex, _efrac in _top_edge_rows: + _edge, _epos = _edge_lifter_row_from_legacy( + _ex, _efrac, _fl_length_ft, _fl_width_ft, 'end') + if _epos is None: + continue + add_row(EDGE_LIFTER_TABLE, values, EDGE_LIFTER_COLS, defaults={ + 'edge': _edge, 'position_ft': _fmt(_epos)}) + for _ex, _efrac in _side_edge_rows: + _edge, _epos = _edge_lifter_row_from_legacy( + _ex, _efrac, _fl_length_ft, _fl_width_ft, 'side') + add_row(EDGE_LIFTER_TABLE, values, EDGE_LIFTER_COLS, defaults={ + 'edge': _edge, 'position_ft': _fmt(_epos)}) + else: + for _ex, _efrac in _parse_xfrac_lines(reads.get('edge_lifters')): + _edge, _epos = _edge_lifter_row_from_legacy( + _ex, _efrac, _fl_length_ft, _fl_width_ft, None) + if _epos is None: + continue + add_row(EDGE_LIFTER_TABLE, values, EDGE_LIFTER_COLS, defaults={ + 'edge': _edge, 'position_ft': _fmt(_epos)}) + + # `conc_load`: NO rows -- concentrated loads are real LOADING, always + # the user's to type (owner, 2026-09-02: "现在就只做用户手动输入的部 + # 分"), never guessed from the model. The table starts empty and + # editable. + + # `wind_zone`: one row covering the panel's own bottom-to-top elevation + # at its full width, pressure and suction left BLANK for the user to + # type (owner, 2026-09-04: "手动输入不仅仅是 windload,还有 apply 在 + # 什么位置,从哪个 elevation 到哪个 elevation,多大的宽度"). Either end + # being unreadable leaves that part of the seeded row blank rather than + # a guessed elevation -- the row is still there, still editable. + # + # `top_elevation_ft` and `length_ft` are two SEPARATE measurements (a + # bbox Z extent vs. whatever `length_ft` was measured off) that agree to + # within `pa_elevation`'s own tolerance but are not always bit-identical + # -- WPA655 measured 37'-8" of elevation against a 37'-7" length. A + # default row built from the raw elevation read can then overhang the + # panel `validate()` actually checks against by that same fraction of an + # inch, so the prefilled `to_elev_ft` is clamped here to + # bottom + length_ft when the two disagree, rather than handing the user + # a default that already needs `validate`'s 1" tolerance to pass. + _wz_bottom = values.get('bottom_elevation_ft') + _wz_top = values.get('top_elevation_ft') + _wz_length = values.get('length_ft') + try: + _wz_bottom_f = float(_wz_bottom) + except (TypeError, ValueError): + _wz_bottom_f = None + try: + _wz_top_f = float(_wz_top) + except (TypeError, ValueError): + _wz_top_f = None + try: + _wz_length_f = float(_wz_length) + except (TypeError, ValueError): + _wz_length_f = None + _wz_to = _wz_top or '' + if _wz_bottom_f is not None and _wz_length_f is not None: + _wz_clamped_top = _wz_bottom_f + _wz_length_f + if _wz_top_f is None or abs(_wz_top_f - _wz_clamped_top) > 1e-6: + _wz_to = _fmt(round(_wz_clamped_top, 4)) + add_row(WIND_ZONE_TABLE, values, WIND_ZONE_COLS, defaults={ + 'label': 'W1', + 'from_elev_ft': _wz_bottom or '', + 'to_elev_ft': _wz_to, + 'width_ft': values.get('width_ft') or '', + }) + if not (values.get('wind_mode') or '').strip(): + values['wind_mode'] = 'Manual' + + # `strand_row`: prefers the STRUCTURED `strand_rows` read -- a sibling + # reads module supplies `[{'count','depth_in','dia_in','pull_lb'}, ...]` + # straight off the model -- when it is a real, non-empty list; falls + # back to the text parse of `ps_rows` ('count @ depth_in [@ pull_pct]' + # per line via `parse_strand_rows`) exactly as before when it is not. + # + # BOTH paths now seed every row's `dia_in` and `pull_lb` rather than + # leaving them blank: `dia_in` from the row's own reading, or else the + # scalar `ps_dia_in` mapped through `strand_dia_choice` onto the + # table's own fraction vocabulary ('0.5' -> '1/2'); `pull_lb` from the + # row's own reading (or a pull_pct resolved against fpu/diameter, text + # path only), or else the scalar `ps_pull_lb`. A row that came up + # blank in both used to leave the Aps/cgy summary reading "unknown + # (unrecognised strand size)" even though the scalars right above the + # table carried a perfectly good answer. + # Owner ruling, 2026-09-05: the model read for `ps_dia_in` is a bare + # decimal ('0.5'), but the FIELD ITSELF is a `choice` editor whose + # options are `strand_dia_choice`'s own fraction vocabulary ('1/2', + # not '0.5') -- see `pa_reinforcement._STRAND_COLUMNS`'s twin + # (`_STRAND_SCALAR_KEYS`'s `ps_dia_in`, same choice list). A `' + .format(notate(k), notate(v)) for k, v in pairs) + + +def _sec(title: str, body_html: str, tag: str = '', + cls: str = 'section') -> str: + """One collapsed disclosure. Closed by default -- everything on the + report is, except the summary itself; see the module docstring. + + `tag` is markup pinned to the right of the summary line, for a section + whose STATE has to be legible without opening it. Only the sections that + pass one get the flex wrapper, so every other summary is exactly the + markup it always was. + """ + head = esc(title) + if tag: + head = '{0}{1}'.format(head, tag) + return ('
{1}' + '
{2}
' + .format(cls, head, body_html)) + + +REC_TITLE = 'Recommended positions' + + +def _rec_tag(rec) -> str: + """NOT RUN on the closed summary line, or nothing. + + The section is always there and always closed -- the engineer asked for + that, and it is the report's own idiom. But a section that is closed for + "we did not look" and a section that is closed for "here is the answer" + must not present the same shut door, or the default run silently reads as + a piece with nothing to move. + """ + if getattr(rec, 'searched', True): + return '' + return 'not run' + + +def _signed(value: float, digits: int = 2) -> str: + """A delta reads as a delta only when the sign is always there.""" + if abs(value) < 0.005: + return '—' + return '{0:+,.{1}f}'.format(value, digits) + + +def _recommendation(rec: 'layout.Recommendation') -> str: + """Where the inserts should go, and how far that is from where they are. + + The delta is the column that gets acted on, so it is given in inches on + each axis rather than as a new coordinate somebody has to subtract from + the old one themselves. + + A search that did not run says so IN THE SAME WORDS a switched-off stage + does, and in the same shape: the dashed NOT RUN body, inside the section + it would have filled, with NOT RUN on the closed summary line above it + (`_rec_tag`). Dropping the section would leave a page that reads exactly + like a piece with nothing to recommend, and printing the reason as small + grey basis text -- which is what the `rec.note` path below does, and is + right for "a single support line is not a pick" -- would file "nobody + looked" among the footnotes. Same rule as a stage with `enabled: false`: + not run is never silent. + + It is no longer LOUD, though, and that is a deliberate reversal. When the + search was the default, a report that skipped it was the exception and the + warning box was right. Skipping it is now the ordinary way this runs, and + a warning box on every report is a warning box nobody reads. So the + section is present, closed like its siblings, flagged NOT RUN on the line + you can see without opening it, and completely honest the moment you do. + """ + if not getattr(rec, 'searched', True): + return ('

Not computed — the ' + 'position search did not run.

{0}.

' + '

Every check on this report was made on the ' + 'inserts exactly as drawn and is unaffected; what is missing ' + 'is only the answer to "then where?". Run the piece on its ' + 'own, without --no-recommend, to get it.

' + '
'.format(esc(rec.note))) + if rec.note and not rec.moves: + return '

{0}.

'.format(esc(rec.note)) + caveat = ('

{0}.

'.format(esc(rec.note)) + if rec.note else '') + + stage_rows = ''.join( + '
' + '' + .format(esc(s.name), esc(n(s.as_built_psi)), + '{0:.0%}'.format(s.as_built_ratio), esc(n(s.recommended_psi)), + '{0:.0%}'.format(s.recommended_ratio)) + for s in rec.stages) + + rows = [] + for move in rec.moves: + cls = ' class="moved"' if move.moved else '' + rows.append( + '' + '' + '' + '' + .format(cls, esc(move.mark), esc(move.family), + esc(fmt_ft_in(move.from_x)), esc(fmt_ft_in(move.from_y)), + esc(fmt_ft_in(move.to_x)), esc(fmt_ft_in(move.to_y)), + _signed(move.dx_in), _signed(move.dy_in))) + + notes = ['
  • {0}: {1}
  • '.format(esc(fmt_ft_in(r.to_x)), esc(r.note)) + for r in rec.rows if r.note] + def _rows_note(rows, what): + if not rows: + return None + return ('
  • {0} row{1} {2}, and {3} held there.
  • '.format( + len(rows), '' if len(rows) == 1 else 's', what, + 'is' if len(rows) == 1 else 'are')) + + for note in (_rows_note([r for r in rec.rows if r.fixed], + 'cannot move at all'), + _rows_note([r for r in rec.rows + if r.pinned and not r.pinned_y], + 'cannot move along the piece'), + _rows_note([r for r in rec.rows + if r.pinned_y and not r.pinned], + 'sits on an edge of the piece and cannot come ' + 'off it, though it may slide along')): + if note: + notes.append(note) + + if not rec.worth_moving: + headline = ('The inserts as drawn are already at the best position ' + 'this search can find.') + else: + headline = ('Moving them takes the governing stage from {0} of ' + 'allowable to {1}.' + .format('{0:.0%}'.format(rec.as_built_ratio), + '{0:.0%}'.format(rec.recommended_ratio))) + + return """

    {headline}

    +{caveat} +
    {0}{1}
    {0}{1}{2}{3}{4}
    {1}{2}{3}{4}{5}{6}{7}{8}
    + + +{stage_rows}
    stageas drawn, psiof allowablerecommended, psiof allowable
    + + + + +{rows}
    insertfamilyas drawn xas drawn yrecommended xrecommended yΔx, inΔy, in
    +
      {notes} +
    • One position per insert, solved for every stage at once. An insert is +installed once and has to survive all of them, so the objective is the worst +stage's utilisation rather than any single stage's stress.
    • +
    • x is searched to minimise that utilisation; y is geometry only — a +beam model has no transverse mechanics, so whether the piece hangs level is +stated from the layout and not calculated.
    • +
    • Positions are checked against the openings and the catalogue's minimum +edge distance before being offered.
    • +
    """.format(headline=headline, caveat=caveat, stage_rows=stage_rows, + rows=''.join(rows), notes=''.join(notes)) + + +# ------------------------------------------------------- the station table +# +# Eriksson Wall prints, after each stage's plots, a station-by-station table +# of the same numbers -- about twenty rows sampled at engineering stations +# (tenth points plus every discontinuity, each opening edge listed once per +# side) with the governing row bolded. This is that table, filled with the +# columns THIS analysis actually computes and with no others. Six capacity +# columns landed 2026-08-31 (`capacity.py`); f_se followed 2026-09-01, on the +# owner's own column list ("加上" -- add it); the ones still missing are named +# in STATION_OMITTED, under the table, rather than being quietly absent. + +# How many evenly spaced stations survive the thinning before the ones that +# must always be shown are put back. Twenty intervals is Eriksson's own +# density, one notch finer. +STATION_INTERVALS = 20 + +# What a capacity column is called when its own reason is printed in a note +# cell, and what a demand/capacity ratio is called when it reaches 1.0. Short, +# because they share a cell with the station's other tags. +CAPACITY_WORD = { + capacity.MCR: 'M_cr', + capacity.PHI_MN: 'phi-Mn', + capacity.PHI_VN: 'phi-Vn', + # The ratio columns dash for reasons of their own -- a 0/0 station, where + # both ingredients were computed and neither means anything. Without a + # word here that dash would be silent, which is the one thing this table + # has never been allowed to be. + capacity.M_OVER_PHI_MN: 'M/phi-Mn', + capacity.V_OVER_PHI_VN: 'V/phi-Vn', + # f_se, added 2026-09-01 (owner: "加上"). Not a capacity, but it rides the + # same dash-with-reason mechanism -- see capacity.FSE's own comment for + # exactly which three cases leave it None. + capacity.FSE: 'f_se', +} + +# Every column that can dash with a reason: the three capacities, the two +# ratios, and f_se. One tuple, so the note cell, the "holds everywhere" hoist +# and the footnote cannot drift apart. +REASON_COLUMNS = (capacity.MCR, capacity.PHI_MN, capacity.PHI_VN, + capacity.M_OVER_PHI_MN, capacity.V_OVER_PHI_VN, + capacity.FSE) + +RATIO_WORD = { + capacity.PHI_MN: 'M/phi-Mn', + capacity.PHI_VN: 'V/phi-Vn', +} + +STATION_OMITTED = ( + 'What this table now carries, and what it still does not. ' + 'Mcr, φ, φMn, M/φMn, ' + 'φVn and V/φVn are computed here as of ' + '2026-08-31, and fse as of 2026-09-01. Eriksson also tabulates ' + 'Vc separately from φVn (identical here — ' + 'no stirrups, so Vn = Vc), the prestressed detailed ' + 'shear pair Vci/Vcw, Vs required and ' + 'Av minimum (all three are stirrup quantities and this panel ' + 'has none in the handling direction), bwd, the neutral-axis ' + 'depth c, As/A\'s (mild steel, which no job file ' + 'here can express), axial Pu/φPn, a ' + 'station-varying loss column, and deflection. Those remain left out ' + 'rather than filled with zeros — a zero in a capacity column is ' + 'a number somebody would divide by. Deflection and the detailed shear ' + 'pair are the two that would change what this report can say; the rest ' + 'are display or are zero by construction on these pieces.') + + +def _nearest(xs: Sequence[float], x: float) -> int: + """The index of the computed station closest to x. Never a new station. + + A pick line or a dunnage line lands where the crane put it, which is + almost never exactly on a sample; the row that gets shown for it is the + nearest station the engine actually solved, not an interpolation nobody + reviewed. + """ + i = bisect.bisect_left(xs, x) + if i <= 0: + return 0 + if i >= len(xs): + return len(xs) - 1 + return i if (xs[i] - x) < (x - xs[i - 1]) else i - 1 + + +def station_indices(analysis: Analysis, points=()): + """Which of the ~2000 computed stations get a row, and why. + + The analysis runs on 2000-odd stations. A table with 2000 rows is a table + nobody reads, and thinning it by stride alone would step straight over + the stations the whole report is about. So: every nth station by stride, + and then everything that must never be thinned away is put back -- + + both ends of the piece, + every opening edge (`analysis.stations`, where the section jumps and + where both sides are already sampled separately), + every pick or dunnage line (where the shear jumps), + the peak and the trough of V and of M, + the worst compression, and + the governing tension station. + + Returns (rows, governing_index): rows as [(index, [tags])] in station + order, and the index of the governing station, or None when no station + had a section to check. + """ + xs = analysis.diagram.xs + total = len(xs) + at = {} + + def mark(i, why=None): + tags = at.setdefault(i, []) + if why and why not in tags: + tags.append(why) + + step = max(1, (total - 1) // STATION_INTERVALS) + for i in range(0, total, step): + mark(i) + mark(0) + mark(total - 1) + + for x in analysis.stations: + mark(_nearest(xs, x), 'opening edge') + line_word = ('support' if analysis.stage.supports is not None + else 'pick line') + for line in analysis.rig.lines: + mark(_nearest(xs, line.x), line_word) + + shear, moment = analysis.diagram.shear_lb, analysis.diagram.moment_lbft + mark(max(range(total), key=lambda i: shear[i]), 'max V') + mark(min(range(total), key=lambda i: shear[i]), 'min V') + mark(max(range(total), key=lambda i: moment[i]), 'max M') + mark(min(range(total), key=lambda i: moment[i]), 'min M') + + governing = None + if points: + # StressPoint.x IS one of diagram.xs -- stress_profile walks that + # list -- so the lookup is exact, with the nearest station only as a + # fallback that should never fire. + pos = dict((x, i) for i, x in enumerate(xs)) + + def index_of(pt): + return pos.get(pt.x, _nearest(xs, pt.x)) + + governing = index_of(max(points, key=lambda p: p.stress_psi)) + mark(governing, 'governing') + comp = max(points, key=lambda p: p.compression_psi) + if comp.compression_psi > 0.0: + mark(index_of(comp), 'peak f_c') + + return sorted(at.items()), governing + + +def _cell(value, digits=0) -> str: + """A number for a table cell, and never a negative zero. + + Same rule as the hover readout: a moment that rounds to nothing reads + '0.0', because '-0.0' on a calculation report is a sign somebody will try + to interpret. + """ + if value is None: + return '—' + s = n(value, digits) + if s.startswith('-') and float(s.replace(',', '')) == 0.0: + s = s[1:] + return s + + +def _cap_cell(value, digits=1) -> str: + """A capacity for a table cell, and never a rounded-down zero. + + `_cell` would print 0.005 kip-ft as `0.0`, and a zero in a capacity column + is the one thing this table has always refused: somebody divides by it, or + reads it as "no capacity" where the truth is "capacity below the + resolution of this column". A positive value too small to print says + `<0.1` instead, which is a statement about the column and not about the + section. None is still an em dash with its reason in the note. + """ + if value is None: + return '—' + smallest = 10.0 ** (-digits) + if 0.0 < value < smallest / 2.0: + return '<{0:.{1}f}'.format(smallest, digits) + return _cell(value, digits) + + +def _ratio(value) -> str: + """A demand/capacity ratio as a percentage, or an em dash. + + Percent rather than Eriksson's bare 0.16, because the column beside it + -- f_t/allow -- has printed percentages since this table existed, and one + table speaking two dialects of the same quantity is worse than either. + """ + if value is None: + return '—' + return '{0:.0%}'.format(value) + + +def _capacity_cells(cap): + """The seven capacity-block cells for one row, keyed by name, and the + reasons any of them dash. + + Returns (cells, reasons). `cells` is a dict -- 'mcr', 'phi', 'phi_mn', + 'm_ratio', 'phi_vn', 'v_ratio', 'fse' -- rather than a fixed-width list, + so the caller can place each one wherever the table's column order puts + it without the two ever drifting apart again. Every value and its + formatting are unchanged; only how the caller reaches them moved. A + reason is the column's own sentence out of `capacity.CapacityPoint.why`; + the caller decides whether it belongs in this row's note or, if it holds + on every row, in the footnote. + + `fse` reads `cap.fse_ksi` straight -- no re-derivation, no conversion. + It is the one column here already stated in ksi rather than psi or kip: + that is the unit effective prestress is stated in everywhere else in this + package (`prestress.py`, the hand-check fixtures in `test_capacity.py`), + so the header names it rather than silently converting it to match the + psi-stated stress columns beside it. `_cap_cell`, not `_cell`: f_se rides + the same REASON_COLUMNS dash mechanism the other capacity numbers do (see + `capacity.FSE`), and one decimal matches the psi stress cluster (f_c, + f_t, allow) it sits beside in the table -- 176.5 reads the same way + 474.3 does two columns over. + """ + reasons = {} + for column in REASON_COLUMNS: + why = cap.reason(column) + if why: + reasons[column] = why + mcr = _cap_cell(cap.mcr_lbft if cap.mcr_lbft is None + else cap.mcr_lbft / LB_PER_KIP) + phi = '{0:.2f}'.format(cap.phi) if cap.phi is not None else '—' + phi_mn = _cap_cell(cap.phi_mn_lbft if cap.phi_mn_lbft is None + else cap.phi_mn_lbft / LB_PER_KIP) + phi_vn = _cap_cell(cap.phi_vn_lb if cap.phi_vn_lb is None + else cap.phi_vn_lb / LB_PER_KIP) + values = { + 'mcr': mcr, 'phi': phi, 'phi_mn': phi_mn, + 'm_ratio': _ratio(cap.m_over_phi_mn), + 'phi_vn': phi_vn, 'v_ratio': _ratio(cap.v_over_phi_vn), + 'fse': _cap_cell(cap.fse_ksi, 1), + } + cells = dict((k, '{0}'.format(v)) + for k, v in values.items()) + return cells, reasons + + +def _with_exceeded_stations(rows_at, profile): + """Put the station a capacity FAIL came from into the table, if thinning + dropped it. + + Since the owner's 2026-09-01 ruling a ratio over 1.0 is a FAIL, and a FAIL + whose row is not printed is a number the reader has to take on trust. The + worst station of each ratio is added -- one row each at most, because the + exceedances of one stage cluster at one end or one opening edge and adding + every one of them would bury the twenty-five rows the table is for. + + The station table keeps about 25 of 2000 stations. It is the CHECK that + scans all of them; this only makes sure the answer is visible. + """ + at = dict(rows_at) + for attribute, word in (('m_over_phi_mn', 'M/phi-Mn'), + ('v_over_phi_vn', 'V/phi-Vn')): + index, worst, _judged, _total = capacity.worst_ratio(profile, + attribute) + if worst is None: + continue + if getattr(worst, attribute) <= capacity.RATIO_FAIL_ABOVE: + continue + tags = at.setdefault(index, []) + tag = 'worst {0}'.format(word) + if tag not in tags: + tags.append(tag) + return sorted(at.items()) + + +def station_table(analysis: Analysis, points=None, + common_tension_basis: Optional[str] = None) -> str: + """The stage's numbers at every station that earned a row. + + `common_tension_basis`: see `build()`'s parameter of the same name. When + it is given and matches this stage's own basis, the tension-limit note + below the table refers back to the top-of-report statement instead of + restating the full "N*sqrt(f'c), source" sentence. + + Eight of the fifteen columns -- x, V, M, S, f_t, f_c, allow, f_t/allow -- + are calculated nowhere in this function: every one is read out of the + same arrays the diagrams above were drawn from, converted at the point of + display by the one constant (LB_PER_KIP) and spelled to the same number + of decimals as the hover readout, so a value read off the curve with the + cursor and the same value read out of this table are the same characters. + Since 2026-09-01's reorder (owner's ruling; see the note above the body + loop below) they are no longer the first eight cells written -- the two + ratio columns, M_cr, V and phi-Vn, and (added later the same day) f_se + from the capacity block are interleaved among them now -- so "first + eight" stopped being true the day the columns got reordered. What is + still true, and load-bearing: no column's VALUE or formatting changed, + only where each `` is placed among the others. + + The capacity block -- M_cr, phi, phi*M_n, phi*V_n, the two ratios, and + f_se -- is calculated by `capacity.capacity_at`, at every station of the + stage -- `capacity.profile_for`, the same list the verdict check reads, + so the page and the Checks table cannot disagree -- and read out here at + the stations that earned a row. + + Since 2026-09-01 they are NOT informational. A ratio over 1.0 is a FAIL + finding (owner's ruling; see `capacity.py`), so the station it happened at + is forced into this table whether or not the thinning would have kept it: + a verdict whose evidence is not on the page is a verdict nobody can check. + """ + if points is None: + points = checks.stress_profile(analysis) + xs = analysis.diagram.xs + rows_at, governing = station_indices(analysis, points) + profile = capacity.profile_for(analysis) + rows_at = _with_exceeded_stations(rows_at, profile) + by_x = dict((p.x, p) for p in points) + allowable = analysis.stage.allowable_tension_psi + + caps = [profile[i] for i, _t in rows_at] + # A reason that holds at EVERY station is a fact about the piece, not + # about a station, and repeating it down twenty-five note cells buries the + # ones that are really local. Those are hoisted into the footnote. + everywhere = {} + for column in REASON_COLUMNS: + said = set(c.reason(column) for c in caps) + if len(said) == 1 and None not in said and caps: + everywhere[column] = said.pop() + + body = [] + for (i, tags), cap in zip(rows_at, caps): + x = xs[i] + pt = by_x.get(x) + # Station position is a LENGTH -- feet-inches per the owner's + # ruling, not the decimal `_cell` every other column here still + # uses. Clamp a floating epsilon to exactly 0.0 first, the same + # "never a negative zero" discipline `_cell` applies to its own + # column, so a station that lands a hair below 0 from arithmetic + # noise never prints as "-0'-0\"". + x_td = '{0}'.format( + fmt_ft_in(0.0 if abs(x) < 1e-9 else x)) + v_td = '{0}'.format( + _cell(analysis.diagram.shear_lb[i] / LB_PER_KIP, 2)) + m_td = '{0}'.format( + _cell(analysis.diagram.moment_lbft[i] / LB_PER_KIP, 1)) + note = list(tags) + if pt is None: + # The section is entirely opened out here: there is no S, so + # there is no stress. Saying so beats printing a zero. + s_td = ft_td = fc_td = allow_td = util_td = ( + '—') + note.append('no section') + else: + util = ('{0:.0%}'.format(pt.stress_psi / allowable) + if allowable else '—') + s_td, ft_td, fc_td, allow_td, util_td = ( + '{0}'.format(v) for v in + (_cell(pt.s_in3, 0), _cell(pt.stress_psi, 1), + _cell(pt.compression_psi, 1), _cell(allowable, 1), util)) + cap_cells, reasons = _capacity_cells(cap) + # Column order, owner's 2026-09-01 ruling: the verdict-driving + # ratios right after X, then the moment values, then the phi + # factors, then section and stress, the tension cluster together, + # note last. V and phi-Vn are not in his list -- he named their + # ratio but not them -- so they are kept and placed the same way + # the moment pair is: values right after the ratio they feed. + # f_se landed later the same day, on his own list -- "...S; FT; + # fc; fse; T/T_allow..." -- which puts it right after f_c, ahead of + # the f_t/allow cluster; that is where it sits below. + cells = [x_td, cap_cells['m_ratio'], cap_cells['v_ratio'], + m_td, cap_cells['phi_mn'], cap_cells['mcr'], + v_td, cap_cells['phi_vn'], cap_cells['phi'], + s_td, fc_td, cap_cells['fse'], util_td, ft_td, allow_td] + for column, why in sorted(reasons.items()): + if everywhere.get(column) != why: + note.append('{0}: {1}'.format(CAPACITY_WORD[column], why)) + for column, ratio in ((capacity.PHI_MN, cap.m_over_phi_mn), + (capacity.PHI_VN, cap.v_over_phi_vn)): + if ratio is None or ratio < capacity.RATIO_FAIL_ABOVE: + continue + if ratio > capacity.RATIO_FAIL_ABOVE: + # The note cell is escaped, so this is plain text on purpose. + said = ('{0} over 1.0 -- FAIL, and it is in Checks: {1}' + .format(RATIO_WORD[column], capacity.RULING)) + else: + # Exactly at the capacity is not over it, and the ruling is + # about what the tool may bless above 1.0. Saying so beats a + # row that looks like it was let off. + said = ('{0} exactly 1.0 -- at the capacity, not over it' + .format(RATIO_WORD[column])) + if column == capacity.PHI_MN: + # phi-Mn is strand only, so inside a transfer or development + # length it falls towards zero while M_cr does not. Printing + # the ratio without saying that invites it to be read as a + # section with no capacity, which is not what it is. It is + # context on the FAIL and never an exclusion from it -- the + # owner was told all four known exceedances sat here and ruled + # FAIL anyway. + development = cap.development_note() + if development: + said += ' -- ' + development + note.append(said) + # notate(), not esc(): the note column is built from CAPACITY_WORD + # ('phi-Mn', 'M_cr', ...), CapacityPoint.why reasons and + # development_note(), all of them the same ASCII this package writes + # everywhere else. See the architecture note above n(). + body.append('{1}{2}'.format( + ' class="gov"' if i == governing else '', + ''.join(cells), notate(', '.join(note)))) + + kept, total = len(rows_at), len(xs) + rule = ( + 'Rows are the analysis\'s own stations, thinned — not a second ' + 'sampling. Every {step}th of the {total} computed ' + 'stations is kept, and then the ones a thinned table must never drop ' + 'are put back: both ends, every opening edge, every {line}, the peak ' + 'and trough of V and of M, the worst compression and the governing ' + 'station. {kept} rows out of {total}.').format( + step=max(1, (total - 1) // STATION_INTERVALS), total=total, + kept=kept, + line=('support' if analysis.stage.supports is not None + else 'pick line')) + if analysis.stations: + rule += (' An opening edge is sampled on both sides, ' + 'Eriksson\'s own habit, so two rows a ten-thousandth of a ' + 'foot apart read as the same station and differ in S: ' + 'that pair is the ' + 'section jumping, and it is the whole reason the edge is ' + 'sampled that way.') + gov_note = ( + 'The governing row — the station with the highest ' + 'ft/allow — is tinted, bold, and marked ' + 'governing in the note column. It is the station the stage\'s ' + 'verdict is taken from and the one the red dot sits on in the stress ' + 'plot above.') + if (common_tension_basis and + analysis.stage.tension_basis == common_tension_basis): + tension_clause = ( + 'allow and ft/allow are the tension limit ' + 'stated at the top of this report, {allow} psi at this stage\'s ' + '{fprimec}.').format(allow=n(allowable, 1), fprimec=F_PRIME_C) + else: + tension_clause = ( + 'allow and ft/allow are the tension limit, ' + '{limit}{radfc} = {allow} psi ({limit_from}).').format( + limit=n(analysis.stage.tension_limit_sqrt_fc, 1) + .rstrip('0').rstrip('.'), + allow=n(allowable, 1), radfc=RADICAL_FC, + limit_from=LIMIT_FROM.get( + analysis.stage.tension_limit_source, + analysis.stage.tension_limit_source)) + comp_note = ( + '{tension_clause} Compression is a separate check against ' + '0.6{fprimec} = {comp} psi and its ratio is in Checks; it is not ' + 'the ratio in this table.').format( + tension_clause=tension_clause, fprimec=F_PRIME_C, + comp=n(analysis.stage.allowable_compression_psi)) + + cap_notes = capacity_basis(analysis, caps, everywhere, + common_tension_basis=common_tension_basis) + + # Column order, owner's 2026-09-01 ruling (see the reordering note above + # the body loop): X, then the two verdict ratios, then the moment + # values, then M_cr, then the shear values he didn't name but which + # follow the same "value after its ratio" placement, then the single + # phi this table computes (flexure -- see STATION_OMITTED and + # capacity_basis for why there is no per-station phi-shear column), + # then section and stress, f_se (his own list put it right after f_c), + # the tension cluster together, note last. + return ("""
    + + + + + + + + + + + + + + + +{body}
    x, ftM/φMnV/φVnM, kip-ftφMn, kip-ftMcr, kip-ftV, kipφVn, kipφ, flexureS, in³fc, psifse, ksift/allowft, psiallow, psinote
    +
    • {rule}
    • {gov}
    • {comp}
    • +{caps}
    • {omitted}
    """.format(body=''.join(body), rule=rule, + gov=gov_note, comp=comp_note, + caps=cap_notes, + omitted=STATION_OMITTED)) + + +def capacity_basis(analysis: Analysis, caps, everywhere, + common_tension_basis: Optional[str] = None) -> str: + """The
  • items behind the six capacity columns. Every number's basis. + + Nothing here is decoration. Each of these numbers rests on an assumption + somebody could reasonably make differently -- which f'c, which lambda, + which f_ps method, how much prestress loss, which f_pu -- and a capacity + printed without them cannot be argued with, only believed. The provenance + discipline is the one the tension limit already follows two paragraphs + up: state the value AND who chose it. + + `common_tension_basis`: see `build()`'s parameter of the same name -- + when it is given and matches this stage's own basis, the ratio-ruling + item below refers back to the top-of-report statement instead of + restating the full basis a second time in this same table's own notes + (`station_table`'s `comp_note` already shortens the first one). + """ + panel, stage = analysis.panel, analysis.stage + lam = pci.lambda_factor(panel.unit_weight_pcf) + fr = pci.modulus_of_rupture_psi(stage.fc_psi, lam) + items = [] + + # Owner's ruling, given in Chinese and kept here (comment only, not in + # the rendered page) for provenance -- 2026-09-01: "M/pMn + # 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail,我做最后决策。" + # The report itself is English-only per the owner's later instruction + # (also 2026-09-01: he demos to colleagues before the weekend and wants + # no Chinese anywhere on the page, including inside his own quotes), so + # the translation below is what actually renders. + items.append( + 'A demand/capacity ratio over 1.0 is a FAIL, as of 2026-09-01. ' + 'M/φMn and V/φVn are checked at ' + 'every station of this stage — not only the rows printed ' + 'here — and the worst of each is in Checks as ' + 'FLEX-CAP / SHEAR-CAP; where one is over ' + '1.0 that station is forced into this table so the number behind the ' + 'verdict is on the page. The owner\'s ruling: “M/φM' + 'n — to me, over 1.0 is fine, I can stamp it. You ' + 'may not: over 1.0 you report it as a FAIL. I make the final call.' + '” — over 1.0 he can stamp it and this ' + 'tool may not, so it reports and he decides. A station inside a ' + 'development length is not excused: the note says so because it is ' + 'what the decision rests on, never because it withholds the FAIL. ' + 'The stress check above — ft against {limit} — ' + 'is untouched and still carries its own verdict; cracking and ' + 'flexural capacity are two questions and neither replaces the other. ' + 'A station that could not be judged dashes with its reason and is ' + 'not a pass.'.format( + limit=('the limit stated at the top of this report' + if (common_tension_basis and + stage.tension_basis == common_tension_basis) + else notate(stage.tension_basis)))) + + items.append( + 'Mcr = S·(fr + P/A) ∓ ' + 'P·ez, with fr = ' + '{coef}λ{radfc} = {fr} psi — the code ' + 'modulus of rupture, ACI 318-19 19.2.3.1, λ = {lam} for ' + '{pcf} pcf concrete (ACI Table 19.2.4.1(a)). That 7.5 is not the ' + '7.5 in the tension allowable above it. The allowable is a ' + 'handling limit somebody chose between two schools (5 = the rupture ' + 'modulus over a 1.5 safety factor, 7.5 = the rupture modulus itself); ' + 'this one is the material property the cracking moment is defined ' + 'from, and it does not move when the allowable does. The sign follows ' + 'the sense of M at the station: strand below mid-depth raises the ' + 'sagging Mcr and lowers the hogging one. Mcr is ' + 'taken about the bending axis only, so it does not carry the ' + 'lateral prestress term that ft does — where a ' + 'blockout has moved the prestress across the piece, a station can ' + 'show ft over the allowable with M under Mcr, ' + 'and the two are answering different questions.'.format( + coef=trim(pci.MODULUS_OF_RUPTURE_SQRT_FC), fr=n(fr, 1), + lam=trim(lam), pcf=n(panel.unit_weight_pcf), radfc=RADICAL_FC)) + + items.append( + 'φMn is strand only, by ACI 318-19 ' + 'Eq. 20.3.2.3.1 (the approximate fps) with the equivalent ' + 'rectangular stress block. These panels carry no mild flexural steel ' + 'in the handling direction, and that is checked, not assumed: ' + 'the job file has no field that can express a bar layer, so the ' + '(ω − ω\') term of the equation has nothing to ' + 'carry. Strain compatibility was not used because it needs a strand ' + 'stress-strain curve no layer in this tool holds, and a curve invented ' + 'to make the answer look exact is worse than a code equation whose ' + 'range is stated. φ = {phi_f} where the section is ' + 'tension-controlled (εt ≥ {ec}, ACI Table ' + '21.2.2); a station that is not prints a dash and its reason rather ' + 'than a φ off the transition, which is not built.'.format( + phi_f='{0:.2f}'.format(pci.PHI_FLEXURE_TENSION_CONTROLLED), + ec='{0:.3f}'.format(pci.TENSION_CONTROLLED_STRAIN))) + + items.append(_fpu_note(panel)) + items.append(_development_note(panel, caps)) + + items.append( + 'A ratio that blows up near an end or an opening edge is the ' + 'strand, not the piece. φMn here is strand only, ' + 'so inside a transfer or development length it falls towards zero ' + '— while Mcr, which rests on the concrete\'s own ' + 'rupture modulus, does not. On one real piece a station 3½ in. ' + 'from a free end showed M = 1.9 kip-ft against φMn = ' + '0.005 kip-ft. That is arithmetic, and it is true, and it is a ' + 'statement about an undeveloped strand rather than about a section ' + 'with no capacity. Such a row says so in its note.') + + items.append( + 'Where both numbers have vanished there is no ratio at all. At ' + 'a free end the closure sends M to zero and strand development sends ' + 'φMn to zero; dividing what is left of one by what is ' + 'left of the other is 0/0, and it printed as high as 5,853,000% ' + 'before 2026-09-01. A station whose M and φMn ' + 'are both under {floor:g} lb-ft — the moment of a one-pound ' + 'weight on a one-foot lever — therefore dashes with its reason ' + 'in the note and takes no part in the verdict. It is not a pass and ' + 'it is not a development-length exclusion: the demand has to ' + 'have vanished too, so a real M over a nearly vanished ' + 'φMn is judged exactly as before and still FAILs above ' + '1.0. On the 282-piece reference sweep the smallest φMn ' + 'at a station carrying any real demand is 38 lb-ft, forty times the ' + 'floor.'.format(floor=capacity.NEGLIGIBLE_MOMENT_LBFT)) + + items.append( + 'φVn = φ·2λ{radfc}·' + 'bwd, ACI 318-19 22.5.5.1 simplified, φ = {phi_v} ' + '(ACI Table 21.2.1). Concrete alone: these panels have no shear ' + 'reinforcement in the handling direction, so Vn = ' + 'Vc and there is no Vs to add. bw is ' + 'the effective width at the station — the concrete actually ' + 'there, openings removed, the same width S is taken on. d is ' + 'dp, floored at 0.80h = {floor} in. by ACI 22.5.2.2, which ' + 'is what carries a station whose strand is on the compression side or ' + 'not yet developed.'.format( + phi_v='{0:.2f}'.format(pci.PHI_SHEAR), radfc=RADICAL_FC, + floor=n(pci.MIN_D_OVER_H_PRESTRESSED * panel.thickness_in, 2))) + + items.append( + '{fprimec} for every capacity above is this stage\'s ' + '{fc} psi — {fprimeci} at release and stripping, {fprimec} later ' + '— and never the 28-day figure applied to a stage that has not ' + 'reached it. M and V in the ratio columns are the table\'s own M and ' + 'V, which are self weight times the stage\'s PCI Table 8.3.1 ' + 'multiplier of {mult} and no further strength-design load ' + 'factor on top of it. A program that also applies an ultimate ' + 'load factor to the handling moment — the 1.4 that ' + 'Material.handling_load_factor holds here and nothing ' + 'applies — is dividing a different demand by the same ' + 'capacity. Which is right is a ruling nobody has made, and it is the ' + 'third part of the open question.' + .format(fc=n(stage.fc_psi), mult=trim(stage.multiplier), + fprimec=F_PRIME_C, fprimeci=F_PRIME_CI)) + + for column, why in sorted(everywhere.items()): + items.append( + '{col} is a dash at every station of this stage: ' + '{why}. It is stated once here rather than repeated down the note ' + 'column, and it is never a zero and never the neighbouring ' + 'station\'s value.'.format(col=notate(CAPACITY_WORD[column]), + why=notate(why))) + + return ''.join('
  • {0}
  • '.format(item) for item in items) + + +def _fpu_note(panel) -> str: + """Where f_pu came from, and the arithmetic that audits it.""" + pre = panel.prestress + if pre is None or not pre.strands: + return ('fpu is not stated because this piece has no ' + 'pretensioning, and φMn is dashed accordingly.') + fpi = pre.jacking_stress_ksi + if pre.fpu_source == 'job': + provenance = ('as the job file states it ' + '(panel.prestress.fpu_ksi)') + else: + provenance = ('panellift\'s own default — no layer stated ' + 'one. The routing when somebody wires it is ' + 'panel.prestress.fpu_ksi, written by ' + 'liftexport from ' + 'spec.reinforcing.strand_groups[].grade_ksi,' + ' which the design-summary reader already gets. ASTM ' + 'A416 defines Grade 250 as well as Grade 270, so this ' + 'is a choice and not a constant') + audit = '' + if fpi: + audit = (' The measured jacking stress is {fpi} ksi, which is ' + '{ratio} fpu against that figure — ' + 'the check on the assumption: a piece whose strand is really ' + 'Grade 250 shows a jacking ratio above the 0.75 fpu' + ' limit here rather than hiding behind a plausible ' + 'capacity.'.format(fpi=n(fpi, 1), + ratio='{0:.3f}'.format(fpi / pre.fpu_ksi))) + return ('fpu = {fpu} ksi, {prov}.{audit} Loss is ' + '{loss}%, and it is a stated assumption, not a ' + 'calculation — design_assumptions.' + 'prestress_loss_pct in the project config. Every point of ' + 'it moves fse, so it moves fps, ' + 'Mcr and φMn together.'.format( + fpu=trim(pre.fpu_ksi), prov=provenance, audit=audit, + loss=trim(pre.loss_pct))) + + +def _development_note(panel, caps) -> str: + """How the transfer and development lengths enter phi-Mn.""" + pre = panel.prestress + if pre is None or not pre.strands: + return ('No strand, so no transfer or development length reduction ' + 'applies to this piece.') + lt = prestress.transfer_length_in(pre) + stated = pre.transfer_length_in is not None + return ( + 'Inside a strand\'s transfer and development length fps is ' + 'reduced, and it is reduced from the same lengths the ' + 'stress path uses — there is one transfer length in this ' + 'package, not two. lt = {lt} in. ({source}); ' + 'ld = lt + (fps − ' + 'fse)db, ACI 318-19 25.4.8.1, whose first term ' + 'is that transfer length. Available strand stress ramps ' + 'linearly from zero at the free end to fse at ' + 'lt and on to fps at ld. The free ' + 'end is not only the end of the piece: a full-depth blockout cuts ' + 'every strand it passes over, so the run restarts at each opening ' + 'edge, exactly as the P/A columns already see it, and a strand ' + 'standing in an opening carries nothing at all.'.format( + lt=n(lt, 2), + source=('stated in the job file' if stated else + 'the ACI estimate fse·db/3, ' + 'worked out from the pull actually on the design ' + '— and it is the first term of ld ' + 'below, not a separate length'))) + + +def trim(value) -> str: + """`0.90 -> '0.9'`, `1.0 -> '1'`. A coefficient as it is spoken.""" + return ('{0:.4f}'.format(float(value)).rstrip('0').rstrip('.')) or '0' + + +STATION_TITLE = 'Station table' + + +def _findings_table(findings: List[Finding], + strip_tension_basis: Optional[str] = None) -> str: + """`strip_tension_basis`: the tensile basis already stated once at the + top of the report, or None. CRACK / EDGE-PATH / ROW-FLEX findings all + end their message in `checks.py` with `... psi allowable ({0})`.format( + stage.tension_basis) -- the exact same clause repeated in every one of + them, since it is the same job-wide limit every time. `Finding.message` + itself is never touched (it is the same ASCII string that goes to the + console, `sweep/problems.txt` and the JSON summary -- see the + architecture note above `n()`); this substitutes on a LOCAL copy, for + this one HTML sink, and only when the substring is the exact clause this + stage's basis actually produced -- a plain, safe no-op on any message + that does not carry it.""" + body = [] + for f in findings: + at = fmt_ft_in(f.at_ft) if f.at_ft is not None else '' + message = f.message + if strip_tension_basis: + clause = ' ({0})'.format(strip_tension_basis) + if clause in message: + message = message.replace(clause, ' (stated above)') + # notate(), not esc(): a finding's message is the same ASCII string + # that goes to the console and to sweep/problems.txt (see the + # architecture note above n()), and this is the one sink that gets + # to dress it up. + body.append('{0}{1}' + '{2}{3}' + .format(f.severity, esc(f.code), notate(message), + esc(at))) + return ('' + '{0}
    resultcheckdetailat
    ' + .format(''.join(body))) + + +# ------------------------------------------------- which end of it is up +# +# The word `liftexport.RULED_UP_END_BASIS` writes when the assembly-origin +# rule actually answered. Anything else in `source.up_end_basis` is the +# global-elevation FALLBACK, which is what the exporter uses when the ruled +# mechanism declines -- and the ruled mechanism declines on every vertical +# wall panel by declaration (`orientation.up_end.applies_to: +# horizontal_panels`). +RULED_UP_END_BASIS = 'assembly origin' + +# Which stages depend on it. Stripping and transportation are analysed FLAT +# and are invariant under mirroring x: reverse the piece end for end and the +# beam problem is its own mirror image, so the same peak moment comes out at +# the mirrored station. Erection is not: which end is up decides which row +# `exclude_top_rows` drops and which edge the anchors are on. +UP_END_AFFECTS = ('erection -- which row of face lifters the crane cannot ' + 'reach, and which edge the anchors are on') + + +def up_end_disclosure(panel) -> Optional[dict]: + """What to print about which end of the piece is up, or None. + + THE FAILURE THIS EXISTS FOR. `knowledge.up_end_from_origin` is this + project's most carefully argued refusal -- three measured conditions, and + it never returns False. When it declines, `liftexport.erected_orientation` + reads global elevation instead and answers anyway, writing the refusal's + own reason into `source.up_end_basis_why`. Nothing read that key. So on + exactly the pieces where the rule stood down for cause, the analysis + proceeded on a fallback whose stated reason was discarded -- a guess + wearing the refusal's credibility. + + THE DELIBERATE DECISION, because "never fall back" is not the answer + either. The fallback is ALLOWED to answer, and here is the argument for + letting it: + + * The rule declines on a vertical wall panel because it has only been + confirmed for horizontal ones. Global elevation is the mechanism that + is SOUND for a piece erected on end -- its two ends are genuinely at + different heights -- so on that decline the fallback is not a guess, + it is the right tool for the case the rule handed over. + * It is meaningless for a piece erected long-ways, where both ends sit + at the same height and the winner is the model's rounding. That case + is caught before it gets here: `MIN_VERTICAL_SHARE` refuses any piece + with no axis convincingly up, and the aspect-ratio gate refuses a + near-square one. + * And it only reaches one stage. See UP_END_AFFECTS. + + So: the fallback answers, and it is STATED on every report it touched, + naming the rule's own reason for standing down. That is the difference + between a disclosed fallback and an undisclosed one, and it is the whole + of what was wrong. + + Returns None when the ruled mechanism answered -- there is nothing to + disclose then, and a page that shouts about a rule that worked teaches + the reader to skip the block on the day it did not. + """ + source = panel.source or {} + basis = source.get('up_end_basis') + if not basis or str(basis) == RULED_UP_END_BASIS: + return None + return {'basis': str(basis), + 'why': str(source.get('up_end_basis_why') or ''), + 'affects': str(source.get('up_end_affects') or UP_END_AFFECTS)} + + +# `up_end_from_origin()`'s FIRST refusal (WillTools.extension/lib/vibe/ +# knowledge.py, ~line 3240) always writes this exact clause -- it is the +# ONLY one of the three refusals that means the origin's own up vector does +# not point up, as opposed to the other two (near-square; not yet confirmed +# for this piece's orientation), which are gaps in what the rule COVERS, not +# defects in what the origin SAYS. The owner's green-arrow rule -- BasisY is +# world up, confirmed for horizontal panels 2026-08-30 and, 2026-09-01, for +# vertical wall panels too (`knowledge/catalogue.yaml`, +# `orientation.up_end.also_seen_as`) -- holds on EITHER frame, so this +# marker is reliable regardless of which way the piece stands: whenever it +# fires, the model's assembly origin is wrong, not merely unconfirmed, and +# that is a defect on the piece for the owner to go fix, not a caveat about +# this analysis. +ORIGIN_DEFECT_MARKER = 'does not stand the piece up' + + +def _up_end_is_origin_defect(why: str) -> bool: + return ORIGIN_DEFECT_MARKER in (why or '') + + +def _up_end_block(panel) -> str: + """The disclosure: a few lines always visible, the reasoning folded. + + UNTIL 2026-09-01 THIS WAS ALWAYS-OPEN PROSE, ~700 CHARACTERS, THAT SAID + THE SAME THING TWICE. The owner read a real report and cut it, verbatim: + "把这一段从lift的结果里面删掉,或者缩短...太长了压根不会看的" -- delete + this section or shorten it, it is so long nobody will read it at all. + The old text opened with "Which end is up came from a fallback, not the + rule" and then, one paragraph later, the producer string it quoted + ENDED with "That is a fallback, not the rule" again -- the same six + words twice in one paragraph is not emphasis, it is why he stopped + reading. A caveat nobody reads protects nobody. + + So: THREE SHORT LINES, always visible -- + + the red TOT call to action, when it applies + (`_up_end_is_origin_defect`; unchanged from before), + the one fact (global elevation, not the origin), stated once, and + what it affects (erection only) -- + + and the full reasoning -- the measured "one assembly in 282" argument, + the mirror-image explanation, and the producer's own "a fallback, not + the rule" -- folded behind `_sec()`, the SAME `
    ` mechanism + every other section of this report already uses, not a bespoke one. + Closed by default like its siblings: no longer the one exception to the + report's own idiom. (The old "never folded, because pyRevit's IE7 + output panel can't render
    " reasoning does not apply here -- + that panel shows plain-ASCII console text, never this HTML file, which + the owner reads in a real browser and has confirmed uses `
    ` + throughout already.) + + The PRODUCER string (`said['why']`, from `up_end_from_origin()` in + `WillTools.extension/lib/vibe/knowledge.py`) is UNCHANGED here on + purpose -- it also reaches `sweep/problems.txt` and the console, so + this function shortens only its OWN presentation of it, inside the + fold, rather than editing text shared with those other sinks. + """ + said = up_end_disclosure(panel) + if not said: + return '' + tot_flag = '' + if _up_end_is_origin_defect(said['why']): + tot_flag = ('
    Double check TOT on this ' + 'assembly.
    ') + fact = ( + 'Which end is up: read from global elevation, not the ' + 'assembly origin. Affects erection only — ' + 'stripping and transportation are analysed flat and unaffected.') + why_detail = '

    Read from {0}. {1}

    '.format( + esc(said['basis']), esc(said['why'])) + fold = _sec('Why', why_detail, cls='section upend-why') + return '
    {0}{1}{2}
    '.format(tot_flag, fact, fold) + + +def _recess_rows(panel): + """The blind-pocket line for the Piece table, or no line at all. + + Written only when the piece HAS recesses, so a page for a piece without + them is byte for byte what it always was, and absence never has to be + read as a claim. + + What it says is the approximation, in the sentence the reader needs: a + recess is carried, counted, and then treated as solid concrete by every + number on the page. That is unconservative for the section modulus over + the pocket, and it is stated rather than left to be discovered -- the + silent version of this is what the whole file contract is against. + """ + if not panel.recesses: + return [] + count = len(panel.recesses) + return [('Recesses', + '{0} blind recess{1} recorded, and NOT reflected in the section ' + 'properties or in strand development -- every number on this ' + 'page treats the piece as solid concrete there. A stated ' + 'approximation, unconservative over the pocket.' + .format(count, '' if count == 1 else 'es'))] + + +def _prestress_line(panel) -> str: + """What the strands are worth, in one line, or why they are worth nothing. + + A reader has to be able to see how much precompression was credited + without opening the job file. It is the single largest number in a + prestressed handling check and the one most easily wrong, because the + loss it rests on is an assumption somebody typed. + """ + note = (panel.source or {}).get('prestress_not_credited') + if note: + return 'not credited — {0}'.format(esc(note)) + pre = panel.prestress + if pre is None: + return 'none' + full = sum(s.initial_pull_lb for s in pre.strands) * \ + (1.0 - pre.loss_pct / 100.0) + area = pre.total_area_in2 + gross = panel.width_ft * 12.0 * panel.thickness_in + cut = prestress.cut_strand_count(panel) + bits = ['{0} strands, Aps {1} in²'.format(len(pre.strands), + n(area, 2)), + 'P {0} lb after {1}% loss'.format(n(full), + n(pre.loss_pct, 2) + .rstrip('0').rstrip('.')), + '{0} psi on the gross section'.format(n(full / gross) + if gross else '-'), + 'transfer {0}"'.format( + n(prestress.transfer_length_in(pre), 1))] + if cut: + bits.append('{0} cut by a blockout'.format(cut)) + return '
    '.join(bits) + + +def _limit_why(coefficient: float) -> str: + """Which of the two tensile-limit schools a coefficient belongs to, in + words. Pulled out on its own so the one-time top-of-report statement + (`_tension_note`) and each stage's own basis paragraph (`build`) say + exactly the same thing about the same number rather than two prose + sentences that could quietly drift apart.""" + if coefficient <= 5.0: + return ('That is the modulus of rupture over a 1.5 safety factor, ' + 'ie. no discernible cracking.') + if coefficient >= 7.5: + return ('That is the modulus of rupture itself — first ' + 'cracking, with no margin on it.') + return ('Between 5{0} (no discernible cracking) and ' + '7.5{0} (first cracking).').format(RADICAL_FC) + + +def _common_tension_basis(cases) -> Optional[str]: + """The `Stage.tension_basis` shared by every case here, or None. + + `Stage.tension_limit_sqrt_fc` is one decision the job file makes ONCE and + every export writes into every stage (see that field's own docstring) -- + not one decision per stage. Measured on a real report: the resulting + "7.5*sqrt(f'c), job" was restated, in full prose, in the summary table, + every stage box, every stage's own basis paragraph and every tension + finding -- dozens of times for one fact. + + So: when every case AGREES, this returns that one string, and the call + sites that print it fall back to a short reference instead of the full + sentence (see `_tension_note`, and `common_tension_basis` in `build()` / + `station_table()` / `_findings_table()`). The moment one case disagrees + -- a stage that set its own limit, or simply a different job -- this + returns None and EVERY site goes back to stating its own basis in full, + because a stage whose limit differs from the rest is exactly the thing + that must be visible at the point of difference, never folded into a + summary that no longer describes it. + """ + bases = set(analysis.stage.tension_basis for analysis, _r, _f in cases) + if len(bases) == 1: + return next(iter(bases)) + return None + + +def _tension_note(cases) -> str: + """The tensile allowable, stated once, when every stage of this piece + agrees on it -- or nothing at all when they do not (see + `_common_tension_basis`). Printed once near the top of `build_many`'s + summary, before any stage's own detail, so a reader meets the doctrine + exactly once rather than re-deriving it from N restatements of the same + sentence.""" + if len(cases) < 2: + # Nothing to fold: a one-stage report has no repetition to save, and + # printing this ABOVE a basis paragraph that is about to say the + # same thing in full reads as the report contradicting its own + # "once" by saying it twice in a row. + return '' + common = _common_tension_basis(cases) + if not common: + return '' + stage = cases[0][0].stage + return ( + '

    Tensile allowable for every stage below: ' + '{limit}{radfc} ({limit_from}). {limit_why} The psi ' + 'figure differs by stage because {fprimec} does; see each stage\'s ' + 'own number in the table and boxes below.

    ' + ).format(limit=n(stage.tension_limit_sqrt_fc, 1).rstrip('0').rstrip('.'), + radfc=RADICAL_FC, fprimec=F_PRIME_C, + limit_from=LIMIT_FROM.get(stage.tension_limit_source, + 'from the job file'), + limit_why=_limit_why(stage.tension_limit_sqrt_fc)) + + +def _compression_limit_why(source: str) -> str: + """The one line behind the compression ratio, when there is one to add. + + Mirrors `_limit_why` for tension, but the compression story is shorter: + only the DEFAULT value has a citation worth stating. A job or stage + value is simply the number an engineer typed into Project Config, and + does not need ACI quoted back at them -- the citation is for the number + nobody set.""" + if source != 'default': + return '' + return ("0.6*f'c per ACI 318-19 Table 24.5.3.1 (transfer) / Sec. " + "24.5.4.1 (service). ACI permits 0.70*f'ci at member ends " + "(same table); that distinction is not implemented here, so " + "this flat ratio is deliberately the more conservative one " + "everywhere on the piece.") + + +def _compression_note(cases) -> str: + """The compression allowable's ratio and citation, stated once near the + top of the report -- mirrors `_tension_note`, but does not need that + function's stage-agreement folding: the only thing ever worth adding + once is the ACI citation behind the 0.6 default, and only when the + default is actually in force somewhere in this report. A job or stage + override needs no citation restated beside it; `Stage.compression_basis` + already names it, inline, wherever the allowable itself is printed (see + `checks.check_compression`).""" + defaults = [a.stage for a, _r, _f in cases + if a.stage.compression_limit_source == 'default'] + if not defaults: + return '' + stage = defaults[0] + return ( + '

    Compression allowable: {ratio} ' + '({limit_from}). {limit_why}

    ' + ).format(ratio=notate(stage.compression_limit_text), + limit_from=LIMIT_FROM.get(stage.compression_limit_source, + 'from the job file'), + limit_why=_compression_limit_why( + stage.compression_limit_source)) + + +def build(analysis: Analysis, rigging: Rigging, findings: List[Finding], + title: Optional[str] = None, recommendation=None, + standalone: bool = True, + common_tension_basis: Optional[str] = None) -> str: + """One stage, in full. + + recommendation: a layout.Recommendation to print, None to work one out for + this stage alone, or False to leave the section out -- which is what + build_many does, because the recommendation is one answer for the piece + and printing it under every stage would suggest otherwise. + + standalone: True returns a whole document (title, style, main -- what + write() puts on disk for one stage by itself). build_many passes False + and gets back just the verdict banner plus this stage's own collapsed + sections, ready to be nested inside its own outer
    + alongside the other stages and the one summary that stays open. + + common_tension_basis: the tensile basis already stated once at the top + of the enclosing document (`_tension_note`), or None. None means either + "this is a standalone single-stage report" (build_many never leaves it + None when it has more than one case) or "the stages disagree" -- and + both mean this stage states its own basis in full, exactly as it always + has. When it is given AND equals THIS stage's own basis, the basis + paragraph, the station table's tension-limit note and this stage's + tension findings all shorten to a reference instead of repeating it. + """ + panel = analysis.panel + stage = analysis.stage + verdict = checks.worst_severity(findings) + verdict_text = {'OK': 'All checks satisfied', + 'WARN': 'Satisfied, with warnings', + 'FAIL': 'One or more checks not satisfied'}[verdict] + + allowable = stage.allowable_tension_psi + supported = stage.supports is not None + rxn_rows = [] + mode = ('dunnage supports' if supported else + 'rolling blocks (equalised, determinate)' + if rigging.mode == 'rolling' + else 'static lift (continuous beam on rigid supports)') + if supported: + rxn_rows = [( + '{0}{1}' + .format(esc(fmt_ft_in(line.x)), esc(n(line.reaction_lb)))) + for line in analysis.rig.lines] + rxn_table = ('' + '' + '{0}
    dunnage linereaction, lb
    ').format(''.join(rxn_rows)) + rxn_basis = ( + '

    Transportation supports are dunnage lines, not ' + 'rigging: the reactions come from a continuous beam on rigid ' + 'supports, with no sling amplification and no insert check. The ' + 'positions are the plant rule.

    ') + else: + for line in analysis.rig.lines: + service = (line.reaction_lb / (stage.multiplier or 1.0)) / max(1, line.count) + sling = sling_forces(service, rigging) + rxn_rows.append( + '{0}{1}{2}' + '{3}{4}' + '{5}' + .format(esc(fmt_ft_in(line.x)), line.count, + esc(n(line.reaction_lb)), esc(n(service)), + esc(n(sling.line_force_lb)), esc(n(sling.horizontal_lb)))) + rxn_table = ('' + '' + '' + '{0}
    pick lineinsertsfactored, lbservice each, lbline force, lbhorizontal, lb
    ').format(''.join(rxn_rows)) + rxn_basis = ( + '

    Factored reactions carry the {0} handling ' + 'multiplier and are what the panel is designed for. The insert ' + 'check uses the service column: PCI Table 8.3.1 note (a) says ' + 'those multipliers are for flexural design and are not to be ' + 'applied to lifting-device safety factors. A face lifter is ' + 'checked against its tension rating and an anchor ' + 'installed on edge against its shear rating, because with ' + 'the panel flat the line pulls straight out of one and across ' + 'the other.

    ').format(n(stage.multiplier, 2).rstrip('0').rstrip('.')) + amp = n(sling_forces(1.0, rigging).amplification, 3) + angle = n(rigging.sling_angle_deg) + + # Read in kips, computed in pounds. See LB_PER_KIP. + moment = [m / LB_PER_KIP for m in analysis.diagram.moment_lbft] + shear = [v / LB_PER_KIP for v in analysis.diagram.shear_lb] + + # One stress profile for the stage, solved once and handed to both the + # plot and the table under it. Two calls would give two lists that agree + # today and are a bug the day they stop agreeing. + stress_points = checks.stress_profile(analysis) + + if recommendation is False: + rec_html = '' + else: + if recommendation is None: + recommendation = layout.recommend([(analysis, rigging, findings)]) + rec_html = _recommendation(recommendation) + + # Transportation is not a lift. Until dunnage positions arrive from the + # plant layer, the stage runs through the lift path, and the report has + # to say that out loud: a support condition nobody can see on the page is + # a number wearing the wrong clothes. + transport_note = ( + '

    Transportation is currently checked as a ' + 'lift — the panel hanging from its inserts. On a trailer ' + 'it sits on dunnage, so these reactions and moments are the rigging ' + 'model, not the support condition on the road.

    \n' + if (stage.pci_case == 'transportation' + and stage.supports is None) else '') + if supported: + path_basis = ( + '
  • Load path: dunnage supports at {0}. A continuous ' + 'beam on rigid supports -- no sling, no cable group, no insert ' + 'check applies to this stage.
  • ' + .format(', '.join(fmt_ft_in(x) for x in stage.supports))) + plumb_basis = '' + solve_basis = ( + '
  • Moments come from a beam solve on the dunnage support lines, ' + 'not from the Figure 8.3.2 coefficients and not from the ' + 'inserts.
  • ') + else: + path_basis = ( + '
  • Load path: {0}. Sling angle {1}° from the ' + 'plane of the panel, giving a line force of {2}× the ' + 'vertical reaction.
  • '.format(mode, angle, amp)) + plumb_basis = ( + '
  • A stage that uses anchors on edge is checked twice: this ' + 'pick, with the panel flat and the anchors in shear, and then the ' + 'panel standing plumb on those anchors alone, in tension, ' + 'carrying the whole piece (PLUMB above). The second ' + 'has no rig, no sling angle and no load multiplier in it — ' + 'there is no distribution question in a piece hanging straight ' + 'down.
  • ') + solve_basis = ( + '
  • Moments come from a beam solve at the actual insert ' + 'positions, not from the Figure 8.3.2 coefficients. Those ' + 'coefficients are used as a regression test on the solver, ' + 'because they only hold for the moment-equalising layout.
  • ') + + + + # WHERE the coefficient came from. The limit is the largest single + # disagreement between this and any other program checking the same + # piece, so a page that states the number without stating who chose it + # leaves the one question a reviewer actually has unanswered -- that + # question does not go away because the job's other stages already + # answered it, so even the short form below still names the psi figure + # and the coefficient; only the repeated PROSE (who chose it, and which + # of the two schools it is) is what a matching stage refers back to. + if common_tension_basis and stage.tension_basis == common_tension_basis: + allow_tension_li = ( + '
  • Allowable tension is {limit}{radfc} = ' + '{allow} psi at {fprimec} = {fc} psi — the ' + 'job-wide limit stated at the top of this report.
  • ' + ).format(limit=n(stage.tension_limit_sqrt_fc, 1) + .rstrip('0').rstrip('.'), + radfc=RADICAL_FC, fprimec=F_PRIME_C, + allow=n(allowable), fc=n(stage.fc_psi)) + else: + allow_tension_li = ( + '
  • Allowable tension is {limit}{radfc} = ' + '{allow} psi at {fprimec} = {fc} psi, {limit_from}. ' + '{limit_why}
  • ' + ).format(limit=n(stage.tension_limit_sqrt_fc, 1) + .rstrip('0').rstrip('.'), + radfc=RADICAL_FC, fprimec=F_PRIME_C, + allow=n(allowable), fc=n(stage.fc_psi), + limit_from=LIMIT_FROM.get(stage.tension_limit_source, + 'from the job file'), + limit_why=_limit_why(stage.tension_limit_sqrt_fc)) + + basis_html = """
      +
    • PCI Design Handbook section 8.3. Equivalent static load multiplier from +Table 8.3.1 for a {ptype} product with a {finish} finish: +{mult}.
    • +{allow_tension_li} +{solve_basis} +{path_basis}{plumb_basis} +
    • Flexural capacity (φMn) is checked, and separately +from cracking: M/φMn and V/φVn are computed at +every station of this stage and a ratio over 1.0 is a FLEX-CAP / +SHEAR-CAP FAIL above (owner's ruling, 2026-09-01). The +station table carries every one of them, with the basis under it. This line +said the capacity check was “still owed” until 2026-08-31, when the +columns landed, and until 2026-09-01, when they became a verdict.
    • +
    """.format( + ptype=esc(pci.PRODUCT_TYPE_LABELS.get(panel.product_type, + panel.product_type)), + finish=esc(pci.FINISH_LABELS.get(panel.finish, panel.finish)), + mult=n(stage.multiplier, 2).rstrip('0').rstrip('.'), + allow_tension_li=allow_tension_li, + solve_basis=solve_basis, path_basis=path_basis, + plumb_basis=plumb_basis) + + # Everything below the verdict is detail: it exists so a reader who wants + # to check the working can, but it is not what the page opens to. Nine + # collapsed disclosures, same order the sections have always been in. + sections = [ + ('Piece', '
    {0}
    ' + '{1}
    '.format( + _rows([ + ('Length', fmt_ft_in(panel.length_ft)), + ('Width', fmt_ft_in(panel.width_ft)), + ('Thickness', '{0}"'.format(n(panel.thickness_in, 2) + .rstrip('0').rstrip('.'))), + ('Blockouts', len(panel.blockouts) or 'none'), + ] + _recess_rows(panel)) + + # Not _rows(): _prestress_line() already returns real markup + # (
    , ) it built and escaped itself -- running that + # through notate() would escape those tags a second time, same + # as _tension_note()'s splice below. + 'Prestress{0}'.format( + _prestress_line(panel)), + _rows([ + ('Weight (stated)', '{0} lb'.format(n(panel.weight_lb))), + ('Unit weight', '{0} pcf'.format(n(panel.unit_weight_pcf))), + # Plain ASCII on purpose -- _rows() runs every label through + # notate(), which is what turns "f'c" into real typography; + # see the architecture note above n(). + ("f'c at this stage", '{0} psi'.format(n(stage.fc_psi))), + ('Inserts', ('{0} supports, {1} on the piece'.format( + len(analysis.rig.lines), len(panel.lifters)) + if supported else + '{0} picked, {1} on the piece'.format( + analysis.rig.points, len(panel.lifters)))), + ]))), + ('Geometry and pick', '
    {0}
    '.format( + panel_plan(analysis))), + ('Free body', '
    {0}
    '.format(free_body(analysis))), + ('Reactions', '{0}\n{1}'.format(rxn_table, rxn_basis)), + ('Shear', '
    {0}
    '.format( + diagram_plot(analysis, shear, 'Shear V(x)', 'kip', + digits=2, symbol='V'))), + ('Moment', '
    {0}
    '.format( + diagram_plot(analysis, moment, 'Moment M(x)', 'kip-ft', + digits=1, symbol='M'))), + ('Tensile stress', '
    {0}
    '.format( + stress_plot(analysis, stress_points))), + # Eriksson's own order: the plots, then the numbers behind them. It + # sits after Tensile stress rather than at the end because it is the + # table form of the three diagrams above it, and it is closed like + # every other section -- a reader who wants the curve reads the curve. + (STATION_TITLE, station_table( + analysis, stress_points, + common_tension_basis=common_tension_basis), '', + 'section stationsec'), + ('Checks', _findings_table( + findings, + strip_tension_basis=( + common_tension_basis + if (common_tension_basis and + stage.tension_basis == common_tension_basis) + else None))), + ] + if rec_html: + sections.append((REC_TITLE, rec_html, _rec_tag(recommendation))) + sections.append(('Basis', basis_html)) + + body = ('

    {mark} — handling check

    \n' + '

    {design}Stage: {stage} · ' + 'generated {now}

    \n\n' + '
    {verdict}' + '{verdict_text}
    \n{transport_note}\n' + .format(mark=esc(panel.mark), + design=('Design {0} · ' + .format(esc(panel.design_number)) + if panel.design_number else ''), + stage=esc(stage.name), + now=datetime.datetime.now().strftime('%Y-%m-%d %H:%M'), + verdict=verdict, verdict_text=esc(verdict_text), + transport_note=transport_note) + + ''.join(_sec(*item) for item in sections)) + + if not standalone: + # build_many nests this inside its own document and emits the script + # once, at the foot. Emitting it here too would give a three-stage + # report three copies of the same listener. + return body + return ('\n{mark} handling check\n' + '\n
    \n{body}\n
    \n{js}'.format( + mark=esc(panel.mark), css=CSS, body=body, + js=hover_script())) + + +def _stage_box(stage_name: str, load_path: str, verdict: str, + peak_psi: Optional[float], allowable_psi: float, + basis: str = '') -> str: + """One Eriksson-style util box: a ratio, coloured, with its numerator and + denominator beside it so nobody has to trust the colour on its own. + + `basis` is the limit the denominator came from -- `5*sqrt(f'c), job`. + These boxes are the part of the page a reader takes in and moves on + from, so the percentage that decides the piece carries the doctrine it + was measured against, not just the psi it worked out to. + """ + if peak_psi is not None and allowable_psi: + ratio = '{0:.0%}'.format(abs(peak_psi) / allowable_psi) + else: + ratio = '-' + return ("""
    +
    {name}{verdict}
    +
    {ratio} of allowable tension
    +
    {peak} / {allow} psi{basis} · {path}
    +
    """.format(verdict=verdict, name=esc(stage_name), ratio=ratio, + peak=n(abs(peak_psi)) if peak_psi is not None else '-', + allow=n(allowable_psi), path=esc(load_path), + basis=' ({0})'.format(notate(basis)) if basis else '')) + + +NOT_RUN_SENTENCE = '{0} analysis not performed.' + + +def _sentence_case(name: str) -> str: + """'erection' -> 'Erection', and 'WP erection' left alone. + + Python's own capitalize() lowercases everything after the first letter, + which turns a plant's stage called 'WP erection' into 'Wp erection'. A + stage name is the plant's word and the report does not restyle it. + """ + return name[:1].upper() + name[1:] if name else name + + +def _skipped_box(stage_name: str, reason: str) -> str: + """The same box, for a stage nobody analysed. A sentence, not a number. + + Deliberately the shape of the others -- same grid cell, same head, same + place in the row -- so it is read as one of the stages rather than as a + note about them. What it does not have is a ratio: there is no ratio, and + printing '-' where the others print '96%' invites the eye to score it as + the best one on the page. + """ + return ("""
    +
    {name}not analysed
    +

    {sentence}

    +{why}
    """.format( + name=esc(stage_name), + sentence=esc(NOT_RUN_SENTENCE.format(_sentence_case(stage_name))), + why=('

    {0}

    '.format(esc(reason)) + if reason else + '

    No reason was recorded.

    '))) + + +def _skipped_detail(stage_name: str, reason: str) -> str: + """The skipped stage's own disclosure, so it sits in the same list as the + stages that have one. Closed like the rest -- the sentence that matters is + already open, up in the summary; this is only where the reason and its + consequence are spelled out for whoever goes looking.""" + body = ( + '

    {sentence}

    ' + '

    {why}

    ' + '

    This stage was switched off in the job file ' + '("enabled": false). No load was applied, no stress was ' + 'computed and no check was run, so there is no result here to pass ' + 'or fail. The overall verdict on this report covers the stages that ' + 'were analysed and does not cover this one.

    '.format( + sentence=esc(NOT_RUN_SENTENCE.format(_sentence_case(stage_name))), + why=(esc(reason) if reason else + 'No reason was recorded in the job file.'))) + return ('
    ' + '{name}' + 'not analysed' + '
    {body}
    ' + .format(name=esc(stage_name), body=body)) + + +def build_many(cases: Sequence[Tuple[Analysis, Rigging, List[Finding]]], + title: Optional[str] = None, recommendation=None, + skipped=None) -> str: + """One document covering every stage of one piece. + + Stripping, transportation and erection are three analyses but they are one + review: whoever reads them needs to see the governing case, and they + cannot if the stages arrive as separate files. The stage that governs is + rarely the one you expected, and on a panel with openings it moves. + + The page opens on a summary -- an overall verdict and one box per stage, + all of it always visible. Everything below that is real content (the same + nine sections `build()` always produced, per stage) sitting inside + collapsed `
    `, closed on load. + + `skipped` is the other half: stages the job file switched off with + `"enabled": false`, as `model.SkippedStage`. They are threaded in by the + index they had in the job's stage list, so a piece whose erection stage + was not run shows stripping, transportation and erection in that order -- + the first two with their numbers, the third saying in words that it was + not analysed. That is the state this function was left permanently-open + for; it is no longer hypothetical. It defaults to whatever `cases` is + carrying (`cli.Cases.skipped`), so a caller that just passes the cases + along gets it right without knowing it exists. + + A skipped stage never becomes a verdict. It cannot: nothing was measured. + What it does is stop the overall verdict from claiming the whole piece -- + see the banner below. + """ + if not cases: + raise ValueError('build_many needs at least one stage to report') + if skipped is None: + skipped = getattr(cases, 'skipped', ()) + skipped = sorted(skipped, key=lambda s: getattr(s, 'index', 0)) + # One recommendation for the piece, solved across every stage, printed + # once. Per-stage recommendations would each be right about their own + # stage and collectively ask for a panel nobody can build. + rec = recommendation if recommendation is not None \ + else layout.recommend(cases) + + panel = cases[0][0].panel + worst = 'OK' + boxes = [] + rows = [] + stage_details = [] + # See `_common_tension_basis`: one string when every stage of this piece + # agrees on its tensile limit, None the moment one disagrees. Threaded + # into every per-stage call below so the summary states it once and the + # stage boxes, the summary table and each stage's own detail all refer + # back to it instead of repeating the same sentence per stage. + common_basis = _common_tension_basis(cases) if len(cases) > 1 else None + for analysis, rigging, findings in cases: + verdict = checks.worst_severity(findings) + if verdict == 'FAIL' or (verdict == 'WARN' and worst == 'OK'): + worst = verdict + stage = analysis.stage + sp = checks.stress_profile(analysis) + peak = max(sp, key=lambda s: abs(s.stress_psi)) if sp else None + allowable = stage.allowable_tension_psi + load_path = ('dunnage' if stage.supports is not None + else rigging.mode) + matches_common = bool(common_basis and + stage.tension_basis == common_basis) + boxes.append(_stage_box( + stage.name, load_path, verdict, + peak.stress_psi if peak else None, allowable, + '' if matches_common else stage.tension_basis)) + # The load path is in the table because a stage may rig differently + # from the ones around it, and two stages of one piece solved on + # different mechanics with nothing on the page saying so is exactly + # the kind of quiet difference this report exists to prevent. + rows.append( + '{0}{1}{2}' + '{3}{4}' + '{5}{7}' + '{6}' + .format(esc(stage.name), esc(load_path), + esc(n(stage.multiplier, 2)), + esc(n(stage.fc_psi)), + esc(n(abs(peak.stress_psi)) if peak else '-'), + esc(n(allowable)), verdict, + # Under the psi, not beside it: the allowable column is + # the one number two reports of the same piece can + # differ on for a reason that is nowhere else on the + # page. A stage that AGREES with the job-wide limit + # stated once above the table says nothing here on + # purpose (see `_tension_note`); a stage that carries + # its own, different limit still says so in full, right + # at the number it belongs to. + '' if matches_common else notate(stage.tension_basis))) + stage_body = build(analysis, rigging, findings, + recommendation=False, standalone=False, + common_tension_basis=common_basis) + stage_details.append( + '
    ' + '{name}' + '{verdict}' + '
    {body}
    ' + .format(name=esc(stage.name), verdict=verdict, body=stage_body)) + + # Put the switched-off stages back where the engineer wrote them, among + # the ones that ran -- Eriksson's arrangement, and the reason for it is + # that "erection was not analysed" only means anything when it is read + # next to "stripping passed at 91%". + for item in skipped: + at = min(getattr(item, 'index', len(boxes)), len(boxes)) + reason = getattr(item, 'reason', '') or '' + boxes.insert(at, _skipped_box(item.name, reason)) + rows.insert(at, + '{0}' + 'not ' + 'analysed{1}' + 'not run' + .format(esc(item.name), + ' — {0}'.format(esc(reason)) + if reason else '')) + stage_details.insert(at, _skipped_detail(item.name, reason)) + + text = {'OK': 'All checks satisfied', + 'WARN': 'Satisfied, with warnings', + 'FAIL': 'One or more checks not satisfied'}[worst] + # The load-bearing sentence on the page. The verdict letters keep their + # meaning -- they are the answer for the stages that were analysed, and a + # stage nobody analysed cannot make a piece pass or fail. What changes is + # the claim next to them: "All checks satisfied" is a statement about the + # piece, and it is false the moment a stage was not looked at. So the + # banner says what it covers, and carries a PARTIAL tag so the shape of + # the banner is different too, for the reader who takes in the colour and + # moves on. + partial = '' + if skipped: + analysed = len(cases) + text = {'OK': 'All checks satisfied for the {0} stage{1} analysed', + 'WARN': 'Satisfied with warnings for the {0} stage{1} ' + 'analysed', + 'FAIL': 'One or more checks not satisfied in the {0} stage{1} ' + 'analysed'}[worst].format( + analysed, '' if analysed == 1 else 's') + text += ' — {0} stage{1} ({2}) {3} not analysed. This report ' \ + 'does not say whether {4} would pass.'.format( + len(skipped), '' if len(skipped) == 1 else 's', + ', '.join(esc(s.name) for s in skipped), + 'was' if len(skipped) == 1 else 'were', + 'it' if len(skipped) == 1 else 'they') + partial = 'partial' + + summary = """

    {mark} — handling

    +

    {design}{count} stage{s} · generated {now}

    +
    {worst}{text}{partial}
    +{upend} +{tension_note} +{compression_note} +

    Stages

    +
    {boxes}
    + + + +{rows}
    stageload pathmultiplier{fprimec}, psipeak tension, psiallowable, psiresult
    + +{recommend} + +

    Each stage below opens to its full working -- geometry, free +body, reactions, shear, moment, stress, checks and basis.

    +""".format(mark=esc(panel.mark), + recommend=_sec(REC_TITLE, _recommendation(rec), _rec_tag(rec)), + design=('Design {0} · '.format(esc(panel.design_number)) + if panel.design_number else ''), + count=len(cases) + len(skipped), + s='' if len(cases) + len(skipped) == 1 else 's', + now=datetime.datetime.now().strftime('%Y-%m-%d %H:%M'), + worst=worst, text=text, partial=partial, + upend=_up_end_block(panel), tension_note=_tension_note(cases), + compression_note=_compression_note(cases), + fprimec=F_PRIME_C, boxes=''.join(boxes), rows=''.join(rows)) + + head = ('\n{mark} handling check\n' + ''.format(mark=esc(panel.mark), css=CSS)) + return '{head}\n
    \n{summary}\n{stages}\n
    \n{js}'.format( + head=head, summary=summary, stages=''.join(stage_details), + js=hover_script()) + + +def write_many(path: str, + cases: Sequence[Tuple[Analysis, Rigging, List[Finding]]], + recommendation=None, skipped=None) -> str: + """Every stage of one piece, in one file -- including the ones that were + switched off, which are taken off `cases` when not passed.""" + import io + with io.open(path, 'w', encoding='utf-8') as fh: + fh.write(build_many(cases, recommendation=recommendation, + skipped=skipped)) + return path + + +def write(path: str, analysis: Analysis, rigging: Rigging, + findings: List[Finding]) -> str: + import io + with io.open(path, 'w', encoding='utf-8') as fh: + fh.write(build(analysis, rigging, findings)) + return path diff --git a/panellift/src/panellift/sandwich.py b/panellift/src/panellift/sandwich.py new file mode 100644 index 0000000..52f398e --- /dev/null +++ b/panellift/src/panellift/sandwich.py @@ -0,0 +1,1118 @@ +# -*- coding: utf-8 -*- +# Owner, verbatim (2026-08-31): "把夹芯板当实心算并不 conservative。因为 +# insulation 并不会 100% composite,你忽视了中间的 composite ratio 的计算和 +# 可能出现的问题。这是一个 insulated panel design 的时候非常大的一个问题。" +"""Insulated (sandwich) wall panels: what the section is, and what it is not. + +A sandwich panel is two concrete wythes with a layer of rigid insulation +between them, tied by discrete connectors. Everything else in this package +idealises a panel as ONE prism of concrete at the envelope thickness, and on +a sandwich that idealisation is **not conservative** -- which is the whole +reason this module exists. The owner's words, 2026-08-31: + + "Treating a sandwich panel as solid is not conservative, because the + insulation will not be 100% composite -- you are ignoring the + composite-ratio calculation in the middle and the problems that can + come out of it. This is a very big problem when doing an insulated + panel design." + +Measured on a real 16 in. piece -- 4 in. architectural wythe, 4 in. EPS, +8 in. structural wythe, 64 in. of clear width: + + solid at 16 in. S = 2731 in3 + fully composite, bottom fibre S = 2324 in3 the solid is 1.18x this + non-composite (each wythe + about its own axis) S = 768 in3 the solid is 3.56x this + +So treating that piece as 16 in. of solid concrete overstates the section +modulus by between 1.18 and 3.56 and understates the tensile stress by the +same factor. The optimistic direction, on a safety analysis. Note that the +solid idealisation is unconservative even against the FULLY COMPOSITE bound: +a sandwich has a hole in the middle of its depth, so it is weaker than a +solid piece of the same envelope however well the connectors work. + +--------------------------------------------------------------------------- +WHAT THIS MODULE CLAIMS, AND WHAT IT REFUSES TO + +Two section states are computable from geometry alone, with no connector +data whatever, and they bracket the real panel: + + non-composite the wythes bend about their own axes and share the moment + in proportion to their own stiffness. I = sum(b*t_i^3/12). + No interface shear is transferred. Maximises wythe flexural + tension. + fully composite the two wythes act as one transformed section about the + sandwich's own neutral axis. Maximises the interface shear + flow the connectors must carry, v = VQ/I. + +**THE BOUNDS ARE SCAFFOLDING, NOT THE PRODUCT.** The real panel is partially +composite, and the partial-composite middle is the thing insulated-panel +design is actually about. It cannot be computed here today because it needs +per-connector STIFFNESS (lb/in of slip) and per-failure-mode CAPACITY, and +neither exists in any layer yet. So the bounds ship as an interim envelope +with the missing computation NAMED, and `composite_ratio()` stands down +rather than printing a percentage nobody can check. + +What the bounds do and do NOT bracket is `concerns()`, and reading it is the +point of this module -- three of the quantities that matter most in a +sandwich panel are not bracketed by the two bounds at all. + +--------------------------------------------------------------------------- +UNITS, and they are this package's, not Revit's + + z, thickness, wythe depths inches, measured UP FROM THE FORM FACE + width b inches + I in^4 + S in^3 + Q (first moment) in^3 + shear V pounds + shear flow q pounds per inch OF SPAN, across the + whole width b + moment pound-inches where a name says in, + pound-feet otherwise +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple + +# What `choose_concrete` on the Revit side calls a solid it counted as +# concrete, and what it calls one it left out. The job file carries both. +CONCRETE = 'concrete' +NOT_CONCRETE = 'other' + + +# =========================================================================== +# the layup +# =========================================================================== + +@dataclass +class Layer: + """One layer through the thickness, bottom (form face) up. + + `z0_in` / `z1_in` are measured up from the form face. They are None when + the exporter could not measure them -- which is NOT zero and is not a + layer of no thickness; it is a layer whose position nobody read, and + every section property below refuses rather than assuming one. + """ + kind: str # CONCRETE | NOT_CONCRETE + z0_in: Optional[float] = None + z1_in: Optional[float] = None + material: Optional[str] = None + density_pcf: Optional[float] = None + volume_ft3: Optional[float] = None + + @property + def thickness_in(self) -> Optional[float]: + if self.z0_in is None or self.z1_in is None: + return None + return abs(self.z1_in - self.z0_in) + + @property + def mid_in(self) -> Optional[float]: + if self.z0_in is None or self.z1_in is None: + return None + return 0.5 * (self.z0_in + self.z1_in) + + @property + def label(self) -> str: + return self.material or ('concrete' if self.kind == CONCRETE + else 'not concrete') + + +@dataclass +class Layup: + """The makeup of the piece through its thickness. + + `measured` False means the layers are known to EXIST but their positions + through the thickness were not read -- the state every job file written + before the exporter learned to measure them is in. A layup that is not + measured can say "this is a sandwich"; it cannot say what its section is, + and `section_bounds()` refuses on it rather than inventing a layup that + would look ordinary in a table. + """ + layers: List[Layer] = field(default_factory=list) + envelope_in: Optional[float] = None + measured: bool = False + why: str = '' + + @property + def wythes(self) -> List[Layer]: + return [l for l in self.layers if l.kind == CONCRETE] + + @property + def insulation(self) -> List[Layer]: + return [l for l in self.layers if l.kind != CONCRETE] + + @property + def wythe_count(self) -> int: + return len(self.wythes) + + @property + def structural_wythe(self) -> Optional[Layer]: + """The THICKEST concrete wythe. + + Which wythe is the structural one is a design decision this package + cannot read. The trade convention is that the thicker wythe is + structural and the thinner one is the architectural face, and that + convention is what this returns -- named on every number it touches, + and on the owner-question list. It is NOT measured. + """ + thick = [w for w in self.wythes if w.thickness_in] + if not thick: + return None + return max(thick, key=lambda w: w.thickness_in) + + @property + def complete(self) -> bool: + """Every layer has a position, and there are at least two wythes.""" + return (self.measured and self.wythe_count >= 2 and + all(l.thickness_in for l in self.layers)) + + +# =========================================================================== +# detection -- and it travels in the job file, never re-derived here +# =========================================================================== + +@dataclass +class Detection: + """Is this piece a sandwich? Three answers, and None is one of them. + + `sandwich` is True, False, or **None for "nobody could tell"**. None is + never read as False anywhere below: the difference between "this is one + prism of concrete" and "nothing measured the body" is the difference + between a check that ran and a check that did not. + """ + sandwich: Optional[bool] + basis: Optional[str] # which signal answered + why: str + layup: Optional[Layup] = None + + +_NO_SIGNAL = ( + 'This job file carries no per-solid breakdown (source.solid_parts) and ' + 'no layup (source.layup), so nothing in it says whether the body draws ' + 'one prism of concrete or two wythes with insulation between them. A ' + 'hand-written job file is the author\'s statement about the piece and is ' + 'taken as written; an exported one has carried solid_parts since ' + '2026-08-31, so a file without it was written by an exporter that could ' + 'not weigh a sandwich either -- and on those the weight gate refused ' + 'before this package was ever reached.') + + +def _layer_from_part(part: Dict[str, Any]) -> Layer: + def num(key): + value = part.get(key) + try: + return None if value is None else float(value) + except (TypeError, ValueError): + return None + return Layer( + kind=CONCRETE if part.get('counted') else NOT_CONCRETE, + z0_in=num('z0_in'), z1_in=num('z1_in'), + material=(None if part.get('material') is None + else str(part['material'])), + density_pcf=num('density_pcf'), + volume_ft3=num('volume_ft3')) + + +def layup_from_source(source: Dict[str, Any]) -> Optional[Layup]: + """The layup the exporter measured, or None where it wrote none. + + Reads `source.solid_parts` -- the per-solid breakdown the Revit side has + written since 2026-08-31 -- and orders the layers through the thickness + where the exporter measured their positions. Where it did not, the + layers are still returned, in the order they came, with `measured` + False and a sentence saying so. + """ + parts = source.get('solid_parts') + if not isinstance(parts, list) or not parts: + return None + layers = [_layer_from_part(p) for p in parts if isinstance(p, dict)] + if not layers: + return None + + positioned = [l for l in layers if l.thickness_in] + if len(positioned) == len(layers): + layers.sort(key=lambda l: l.z0_in) + lo = min(l.z0_in for l in layers) + hi = max(l.z1_in for l in layers) + envelope = source.get('envelope_thickness_in') + try: + envelope = float(envelope) + except (TypeError, ValueError): + envelope = hi - lo + return Layup(layers=layers, envelope_in=envelope, measured=True, + why=('layup read from the {0} solids the body draws, ' + 'each positioned through the thickness' + .format(len(layers)))) + + return Layup( + layers=layers, envelope_in=None, measured=False, + why=('the body draws {0} solids and the job file lists them, but ' + 'none carries a position through the thickness (z0_in/z1_in). ' + 'Which of them is the near wythe, how thick each is and how ' + 'much insulation is between them are therefore unread, and no ' + 'section property can be computed from a list of volumes.' + .format(len(layers)))) + + +def detect(source: Optional[Dict[str, Any]]) -> Detection: + """Sandwich or not, decided from what the JOB FILE says. + + The signal travels in the file; nothing here re-derives it, because the + file is the boundary and re-deriving a geometry fact on this side of it + would be a second opinion about a model this process cannot see. + + The ladder, and what each rung refuses to do: + + no solid_parts and no layup None -- could not tell. Never False. + one concrete solid, nothing + left out False. This is every solid panel in the + reference job, and it is the only rung + that may return False. + anything left out as not + concrete True. A body that draws concrete AND + something lighter than concrete is a + sandwich; that is what the insulation + IS. + two or more concrete solids True, conservatively. It might be a + wythe pair; it might be a panel and a + separately drawn ledge. Nothing here can + tell those apart without positions, and + the safe direction is to refuse -- an + over-refusal costs an answer, and + under-refusing prints a stress that is + a third of the real one. + """ + source = source or {} + layup = layup_from_source(source) + if layup is None: + return Detection(sandwich=None, basis=None, why=_NO_SIGNAL) + + wythes = layup.wythe_count + others = len(layup.insulation) + + if others: + return Detection( + sandwich=True, basis='solid_parts', layup=layup, + why=('the panel body draws {0} concrete solid(s) and {1} solid(s) ' + 'the weight sum left out as not concrete ({2}) -- that is a ' + 'sandwich: wythes with insulation between them' + .format(wythes, others, + ', '.join(sorted(set(l.label + for l in layup.insulation)))))) + if wythes >= 2: + return Detection( + sandwich=True, basis='solid_parts', layup=layup, + why=('the panel body draws {0} separate concrete solids. Nothing ' + 'in this file can tell two wythes of a sandwich from a panel ' + 'and a separately drawn ledge, so it is treated as a ' + 'sandwich: over-refusing costs an answer, and the other way ' + 'round prints a tensile stress a third of the real one' + .format(wythes))) + if wythes == 1: + return Detection( + sandwich=False, basis='solid_parts', layup=layup, + why=('the panel body draws one concrete solid and nothing was ' + 'left out of the weight, so the section is one prism and ' + 'the solid idealisation is the right one')) + return Detection( + sandwich=None, basis='solid_parts', layup=layup, + why=('the job file lists {0} solid(s) and none of them was counted ' + 'as concrete, so what this piece is made of is not established ' + 'here'.format(len(layup.layers)))) + + +# =========================================================================== +# section properties -- geometry alone, no connector data anywhere +# =========================================================================== + +@dataclass +class Fibre: + """One place a stress is reported, and its distance from the axis.""" + label: str + z_in: float + c_in: float # signed: +ve above the axis + s_in3: Optional[float] + + +@dataclass +class Bound: + """One idealisation of the section, with its assumption spelled out. + + `assumption` is not decoration and is not optional. Every number this + package prints for a sandwich panel is conditional on which of the three + idealisations produced it, and a psi figure with no assumption beside it + is a number nobody can check -- which is the same rule the tensile-limit + provenance already follows. + """ + name: str + assumption: str + i_in4: float + axis_z_in: float # neutral axis, up from form face + fibres: List[Fibre] = field(default_factory=list) + + @property + def s_governing_in3(self) -> Optional[float]: + """The SMALLEST section modulus among the fibres -- the one that + decides the peak stress, and therefore the one that governs.""" + values = [f.s_in3 for f in self.fibres if f.s_in3] + return min(values) if values else None + + def stress_psi(self, moment_lbin: float) -> List[Tuple[str, float]]: + """f = M*c/I at every reported fibre. Tension positive, sagging M + positive (tension on the form face, which is z = 0).""" + if not self.i_in4: + return [] + return [(f.label, -moment_lbin * f.c_in / self.i_in4) + for f in self.fibres] + + +def solid_idealisation(b_in: float, envelope_in: float) -> Bound: + """What the rest of this package does today: one prism at the envelope. + + Computed so the report can print how far wrong it is, never so anything + can use it on a sandwich. + """ + i = b_in * envelope_in ** 3 / 12.0 + c = envelope_in / 2.0 + s = i / c if c else None + return Bound( + name='solid at the envelope', + assumption=('the whole {0:.3g} in. envelope is solid concrete -- what ' + 'this package assumes on every panel, and what a sandwich ' + 'is not'.format(envelope_in)), + i_in4=i, axis_z_in=envelope_in / 2.0, + fibres=[Fibre('form face', 0.0, -c, s), + Fibre('far face', envelope_in, c, s)]) + + +def non_composite(b_in: float, layup: Layup) -> Bound: + """Wythes about their own axes, sharing the moment by stiffness. + + I = sum(b * t_i^3 / 12), which is the section property the trade means by + "non-composite". The load sharing that goes with it is the convention + this package chose and it is NAMED on every number: the wythes are tied + by the connectors, so they deflect together, so each carries the moment + in proportion to its own stiffness -- + + M_i = M * I_i / sum(I_j) and therefore + f_i = M_i * (t_i/2) / I_i = M * (t_i/2) / sum(I_j) + + The extreme fibre stress is then governed by the THICKEST wythe, and the + effective section modulus is sum(I_j) / (t_max / 2). + + WHY THAT AND NOT "the structural wythe carries everything": both are + defensible and the second is more conservative, so it is computed too -- + `structural_wythe_alone()` -- and the report prints both. What is not + available is a guess: which wythe is structural, and whether the face + wythe may be relied on at all, are the owner's to rule. + """ + wythes = [w for w in layup.wythes if w.thickness_in] + i_total = sum(b_in * w.thickness_in ** 3 / 12.0 for w in wythes) + fibres = [] + for w in wythes: + half = w.thickness_in / 2.0 + s = (i_total / half) if half else None + fibres.append(Fibre('{0:.3g} in. wythe, near face'.format( + w.thickness_in), w.z0_in, -half, s)) + fibres.append(Fibre('{0:.3g} in. wythe, far face'.format( + w.thickness_in), w.z1_in, half, s)) + return Bound( + name='non-composite', + assumption=('each wythe bends about its own axis and carries the ' + 'moment in proportion to its own stiffness; no shear is ' + 'transferred across the insulation'), + i_in4=i_total, axis_z_in=0.0, fibres=fibres) + + +def structural_wythe_alone(b_in: float, layup: Layup) -> Optional[Bound]: + """The thickest wythe carries the WHOLE piece; the other is dead load. + + The conservative reading of the open question "is the face wythe + structural". A sandwich whose thin wythe is architectural cladding is + carried by the connectors and resists nothing, and then the section is + one wythe against the weight of two. It is the smallest of the three + section moduli and it is printed for that reason. + """ + w = layup.structural_wythe + if w is None or not w.thickness_in: + return None + t = w.thickness_in + i = b_in * t ** 3 / 12.0 + s = b_in * t ** 2 / 6.0 + others = [o for o in layup.wythes if o is not w] + return Bound( + name='structural wythe alone', + assumption=( + 'the {0:.3g} in. wythe is the structural one and carries the ' + 'whole piece in flexure; the {1} is architectural, carried by ' + 'the connectors and resisting nothing. WHICH WYTHE IS STRUCTURAL ' + 'IS NOT MEASURED -- the thickest is assumed, which is the trade ' + 'convention and is on the owner-question list' + .format(t, ', '.join('{0:.3g} in. wythe'.format(o.thickness_in) + for o in others if o.thickness_in) + or 'other wythe')), + i_in4=i, axis_z_in=w.mid_in, + fibres=[Fibre('{0:.3g} in. wythe, near face'.format(t), + w.z0_in, -t / 2.0, s), + Fibre('{0:.3g} in. wythe, far face'.format(t), + w.z1_in, t / 2.0, s)]) + + +def fully_composite(b_in: float, layup: Layup) -> Bound: + """The transformed section about the sandwich's own neutral axis. + + Both wythes are cast from the same mix on every piece this has been + measured on, so the modular ratio is 1 and no transformation is applied; + where the densities differ the report says so rather than this quietly + scaling one of them. The insulation contributes nothing -- its modulus is + three orders of magnitude below concrete's -- and leaving it out is the + standard treatment, not an approximation this made up. + + A = sum(b * t_i) + zbar= sum(A_i * zmid_i) / A + I = sum(b*t_i^3/12 + A_i * (zmid_i - zbar)^2) + + This bound MAXIMISES the interface shear flow the connectors have to + carry, which is why it is computed even on a panel the plant calls + non-composite: it is the demand envelope, not a claim about behaviour. + """ + wythes = [w for w in layup.wythes if w.thickness_in] + area = sum(b_in * w.thickness_in for w in wythes) + if not area: + return Bound(name='fully composite', + assumption='no wythe has a measured thickness', + i_in4=0.0, axis_z_in=0.0) + zbar = sum(b_in * w.thickness_in * w.mid_in for w in wythes) / area + i = sum(b_in * w.thickness_in ** 3 / 12.0 + + b_in * w.thickness_in * (w.mid_in - zbar) ** 2 for w in wythes) + lo = min(w.z0_in for w in wythes) + hi = max(w.z1_in for w in wythes) + return Bound( + name='fully composite', + assumption=('the two wythes act as one section about their common ' + 'neutral axis; the connectors transfer the whole ' + 'interface shear and the insulation carries none of it'), + i_in4=i, axis_z_in=zbar, + fibres=[Fibre('form face', lo, lo - zbar, + (i / abs(lo - zbar)) if abs(lo - zbar) > 1e-9 else None), + Fibre('far face', hi, hi - zbar, + (i / abs(hi - zbar)) if abs(hi - zbar) > 1e-9 else None)]) + + +# =========================================================================== +# horizontal shear flow, and what the connectors are being asked for +# =========================================================================== + +@dataclass +class ShearFlow: + """Interface shear demand under the FULLY COMPOSITE bound. + + q = V * Q / I pounds per inch of span, across the width + Q = A_wythe * (zmid - zbar) + + Q is the same at either wythe-insulation plane, as equilibrium requires, + so there is one number and not two. + + This is the UPPER bound on interface shear: full composite action needs + the whole of it. A panel designed non-composite does not develop it -- + but the demand a connector sees is not zero either, and nothing here can + say where between the two it falls without connector stiffness. See + `composite_ratio`. + """ + q_in3: float # the first moment Q + i_in4: float + per_lb_of_shear: float # q per pound of V, per inch + v_lb: Optional[float] = None + q_lb_per_in: Optional[float] = None + + +def shear_flow(bound: Bound, layup: Layup, b_in: float, + v_lb: Optional[float] = None) -> Optional[ShearFlow]: + """VQ/I at the connector plane, from the fully composite bound.""" + wythes = [w for w in layup.wythes if w.thickness_in] + if len(wythes) < 2 or not bound.i_in4: + return None + # Either wythe gives the same Q; the lower one is used so the sign is the + # one a reader expects for a sagging moment. + lower = min(wythes, key=lambda w: w.mid_in) + q = b_in * lower.thickness_in * abs(bound.axis_z_in - lower.mid_in) + per = q / bound.i_in4 + return ShearFlow(q_in3=q, i_in4=bound.i_in4, per_lb_of_shear=per, + v_lb=v_lb, + q_lb_per_in=(None if v_lb is None else per * abs(v_lb))) + + +@dataclass +class ConnectorDemand: + """What one connector is asked to carry, and what is still unknown. + + `per_connector_lb` is the fully-composite interface shear divided evenly + among the connectors in one row -- and **that even division is itself an + assumption, not a bound**. Under partial interaction the end connectors + carry a peak that the even division understates; see `concerns()`. + + `capacity_lb` is None until a datasheet exists. `verdict` is then None + too -- never OK, never a pass. A sufficiency check with no capacity is a + check that did not run. + """ + per_connector_lb: Optional[float] + spacing_in: Optional[float] + per_row: Optional[int] + capacity_lb: Optional[float] + verdict: Optional[str] + why: str + distribution_assumption: str + + +UNIFORM_DISTRIBUTION = ( + 'demand spread evenly over the connectors in one row, at the ' + 'fully-composite VQ/I. This is a DISTRIBUTION ASSUMPTION and not a ' + 'bound: under partial interaction the connectors nearest each end of a ' + 'shear span, and nearest every discontinuity, carry more than the mean. ' + 'The peak-to-mean ratio needs the connector slip stiffness k, which no ' + 'layer holds.') + + +def connector_demand(flow: Optional[ShearFlow], + spacing_in: Optional[float] = None, + per_row: Optional[int] = None, + capacity_lb: Optional[float] = None) -> ConnectorDemand: + """Interface shear per connector against a capacity that does not exist. + + Stands down with a written reason wherever an input is missing, and the + stand-down is never a pass: `verdict` stays None and the report prints + words rather than a ratio. + """ + if flow is None or flow.q_lb_per_in is None: + return ConnectorDemand( + None, spacing_in, per_row, capacity_lb, None, + 'no interface shear flow was computed, so there is no demand to ' + 'compare anything against', UNIFORM_DISTRIBUTION) + if not spacing_in or not per_row: + return ConnectorDemand( + None, spacing_in, per_row, capacity_lb, None, + 'the connector spacing along the span and the number across the ' + 'width were not read from the model, so the shear flow cannot be ' + 'turned into a force per connector', UNIFORM_DISTRIBUTION) + demand = flow.q_lb_per_in * spacing_in / float(per_row) + if capacity_lb is None: + return ConnectorDemand( + demand, spacing_in, per_row, None, None, + 'no capacity is published for this connector in any layer, so ' + 'whether {0:,.0f} lb per connector is enough is NOT KNOWN. This ' + 'is a demand, not a check.'.format(demand), + UNIFORM_DISTRIBUTION) + ratio = demand / capacity_lb + return ConnectorDemand( + demand, spacing_in, per_row, capacity_lb, + 'FAIL' if ratio > 1.0 else ('WARN' if ratio > 0.9 else 'OK'), + '{0:,.0f} lb per connector against {1:,.0f} lb, {2:.0%}' + .format(demand, capacity_lb, ratio), UNIFORM_DISTRIBUTION) + + +# =========================================================================== +# the composite ratio -- two of them, and both stand down +# =========================================================================== + +@dataclass +class CompositeRatio: + """Percent composite. There is more than one, and an unlabelled one is + a number nobody can check. + + `stiffness` and `strength` are DIFFERENT QUANTITIES in the trade's own + methods and a panel does not have one number: + + stiffness / deflection kappa_I = (I_eff - I_nc) / (I_full - I_nc). + What governs camber, bowing and deflection. + Needs the connector slip stiffness k. + strength the degree of shear connection, + eta = sum(connector capacities over the shear + span) / (the interface force full composite + action requires). What governs flexural + capacity. Needs a capacity per failure mode. + + Both are None here, and both say why. A computed ratio is IMPOSSIBLE + without connector data -- not hard, not approximate: impossible -- and + printing the position of a panel between two bounds as if it were + measured is the thing this dataclass exists to prevent. + """ + stiffness: Optional[float] + strength: Optional[float] + definition_stiffness: str + definition_strength: str + why: str + i_non_composite_in4: Optional[float] = None + i_full_composite_in4: Optional[float] = None + unblocked_by: Sequence[str] = () + + +RATIO_STIFFNESS_DEF = ('kappa_I = (I_eff - I_nc) / (I_full - I_nc): where the ' + 'piece sits between the two bounding inertias. 0% is ' + 'the non-composite bound, 100% the fully composite ' + 'one. This is the DEFLECTION ratio.') +RATIO_STRENGTH_DEF = ('eta = (interface force the connectors can develop over ' + 'the shear span) / (the interface force full composite ' + 'action requires). This is the STRENGTH ratio, and it ' + 'is not the same number as the stiffness ratio.') + + +def composite_ratio(non_comp: Optional[Bound] = None, + full_comp: Optional[Bound] = None, + connector_stiffness_lb_per_in: Optional[float] = None, + connector_capacity_lb: Optional[float] = None + ) -> CompositeRatio: + """Both ratios, and today both of them stand down. + + The inputs are named in the signature deliberately: they are exactly what + the owner is being asked for, so when the datasheets arrive the shape + they have to land in is already written down. Until then this returns + None twice, with the reason, and nothing prints a percentage. + """ + missing = [] + if connector_stiffness_lb_per_in is None: + missing.append('connector slip stiffness k, in lb/in per connector ' + '(for the stiffness ratio)') + if connector_capacity_lb is None: + missing.append('connector capacity per failure mode -- interface ' + 'shear AND normal/tension -- in lb per connector (for ' + 'the strength ratio)') + return CompositeRatio( + stiffness=None, strength=None, + definition_stiffness=RATIO_STIFFNESS_DEF, + definition_strength=RATIO_STRENGTH_DEF, + i_non_composite_in4=(non_comp.i_in4 if non_comp else None), + i_full_composite_in4=(full_comp.i_in4 if full_comp else None), + unblocked_by=tuple(missing), + why=('NOT COMPUTED. A percent-composite figure needs connector data ' + 'that no knowledge layer holds: {0}. The two bounding inertias ' + 'ARE computed and printed, so the width of the band is on the ' + 'page; where in it this piece sits is not, and a number invented ' + 'for that position would be the least checkable thing in the ' + 'report.'.format('; '.join(missing) if missing else + 'nothing -- inputs are present but the partial ' + 'interaction solve is not built'))) + + +# =========================================================================== +# 可能出现的问题 -- what the two bounds do NOT bracket +# =========================================================================== + +@dataclass +class Concern: + title: str + bracketed: Optional[bool] # None = not a bounds question + sentence: str + + +def concerns(layup: Optional[Layup] = None) -> List[Concern]: + """The enumeration the owner asked for: where bounding stops working. + + Two of the quantities a sandwich panel is checked on ARE bracketed by + the two bounds, one bound each. The rest are not, and reading a two-bound + report as an envelope over everything is the trap. + """ + out = [ + Concern( + 'wythe flexural tension', True, + 'Maximal at the NON-COMPOSITE bound and least at the fully ' + 'composite one, and monotone in between, so the non-composite ' + 'number is a true upper bound. Evaluate it at BOTH faces of ' + 'BOTH wythes, not only at the envelope faces: the inner face of ' + 'a wythe is in compression non-composite and in tension fully ' + 'composite, so it changes sign between the bounds and a check ' + 'that reads only the outer fibres never sees it.'), + Concern( + 'interface shear flow', True, + 'Maximal at the FULLY COMPOSITE bound (VQ/I) and zero at the ' + 'non-composite one. The composite number is a true upper bound ' + 'on the TOTAL interface force, which is why it is computed even ' + 'where the plant designs the panel non-composite.'), + Concern( + 'deflection and bowing', True, + 'Deflection is maximal at the non-composite bound, which is the ' + 'softer section, so it is bracketed. Differential bowing from ' + 'temperature and shrinkage between the wythes is NOT in this ' + 'package at all -- it is a service-condition problem, not a ' + 'handling one, and it is declared out of scope rather than ' + 'silently omitted.'), + Concern( + 'connector force distribution along the span', None, + 'NOT A BOUNDS QUESTION. Both bounds are distribution-free: the ' + 'non-composite one puts zero on every connector and the fully ' + 'composite one spreads demand smoothly as V(x). Real partial ' + 'interaction concentrates slip -- and therefore connector force ' + '-- at the ends of each shear span, decaying over a length set ' + 'by the connector stiffness k against the wythes\' axial ' + 'stiffness EA. A uniform-demand assumption UNDERSTATES the end ' + 'connectors, and by how much is exactly what k buys. Until k ' + 'exists this package reports the mean and names it as a mean.'), + Concern( + 'connector force at discontinuities', None, + 'The same mechanism one step worse: an opening edge, a pick ' + 'point or a change of section restarts the slip profile, so the ' + 'connectors beside each one see a local peak that neither bound ' + 'shows. Named, not computed.'), + Concern( + 'connector NORMAL (tension) demand', None, + 'A flat pick hangs one wythe off the other through the ' + 'connectors in direct tension, and an erection pick peels the ' + 'face wythe as the piece rotates. That demand is not an ' + 'interface-shear question at all, so neither bound speaks to it ' + '-- and where the plant designs the panel non-composite it is ' + 'likely the GOVERNING connector demand. A capacity is needed per ' + 'failure mode, shear and tension, not one number.'), + Concern( + 'local flexure of the face wythe between connectors', None, + 'The architectural wythe spans between connectors carrying its ' + 'own weight normal to the panel. That is a slab strip on ' + 'discrete supports at the connector spacing, a different ' + 'mechanism from anything in this package, and it is not ' + 'computed here.'), + Concern( + 'percent composite is not one number', None, + 'The stiffness ratio and the strength ratio are different ' + 'quantities and a panel has both. Any percentage this report ' + 'prints will say which one it is.'), + ] + if layup is not None and layup.wythe_count >= 2: + thicks = sorted(w.thickness_in for w in layup.wythes + if w.thickness_in) + if len(thicks) >= 2 and thicks[0] and thicks[-1] / thicks[0] > 1.2: + out.append(Concern( + 'the wythes are unequal', None, + 'The wythes differ ({0}), so the composite neutral axis is ' + 'not at mid-depth and the two envelope faces have different ' + 'section moduli. Which wythe is the structural one is a ' + 'design decision this package cannot read; the thickest is ' + 'assumed, and that assumption is named on every number it ' + 'touches.'.format( + ' / '.join('{0:.3g} in.'.format(t) for t in thicks)))) + return out + + +# =========================================================================== +# the whole answer for one piece +# =========================================================================== + +@dataclass +class SectionBounds: + """Everything computable about a sandwich section with no connector data. + + `usable` False means the layup was not measured well enough to compute + anything, and then only `why` is worth reading. It is never a zero. + """ + usable: bool + why: str + width_in: Optional[float] = None + layup: Optional[Layup] = None + solid: Optional[Bound] = None + non_composite: Optional[Bound] = None + wythe_alone: Optional[Bound] = None + composite: Optional[Bound] = None + flow: Optional[ShearFlow] = None + ratio: Optional[CompositeRatio] = None + concerns: Sequence[Concern] = () + + @property + def solid_overstates_by(self) -> Optional[Tuple[float, float]]: + """(against fully composite, against non-composite) -- how many times + larger the solid idealisation's governing section modulus is. + + Both numbers above 1.0 is the finding: the solid idealisation is + unconservative against BOTH bounds, so there is no reading of a + sandwich under which treating it as solid is safe. + """ + if not (self.solid and self.composite and self.non_composite): + return None + s = self.solid.s_governing_in3 + c = self.composite.s_governing_in3 + n = self.non_composite.s_governing_in3 + if not (s and c and n): + return None + return s / c, s / n + + +def section_bounds(layup: Optional[Layup], width_in: Optional[float], + shear_lb: Optional[float] = None, + envelope_in: Optional[float] = None) -> SectionBounds: + """The bounding analysis for one station of one sandwich piece. + + `width_in` is the CLEAR concrete width at the station -- the same + `b_eff` the rest of this package uses, so an opening reduces both + bounds together. It is assumed to be the same in both wythes, which is + stated rather than measured: an opening usually runs through the whole + sandwich, and where it does not the near wythe's own opening set would + have to be read separately. Named, on the owner-question list. + """ + if layup is None: + return SectionBounds(False, 'no layup was read for this piece') + if not layup.complete: + return SectionBounds(False, layup.why or ( + 'the layup is not measured well enough to compute a section'), + layup=layup, concerns=concerns(layup)) + if not width_in: + return SectionBounds(False, 'no clear concrete width at this station', + layup=layup, concerns=concerns(layup)) + + envelope = envelope_in or layup.envelope_in + solid = (solid_idealisation(width_in, envelope) if envelope else None) + nc = non_composite(width_in, layup) + alone = structural_wythe_alone(width_in, layup) + fc = fully_composite(width_in, layup) + flow = shear_flow(fc, layup, width_in, shear_lb) + return SectionBounds( + True, + ('two bounds computed from geometry alone; where the piece sits ' + 'between them needs connector data that does not exist yet'), + width_in=width_in, layup=layup, solid=solid, non_composite=nc, + wythe_alone=alone, composite=fc, flow=flow, + ratio=composite_ratio(nc, fc), concerns=concerns(layup)) + + +# =========================================================================== +# how it prints +# =========================================================================== + +REFUSAL_CODE = 'SANDWICH-SECTION' + +REFUSAL_REASON = ( + 'insulated body: the solid-section idealisation overstates the section ' + 'modulus; sandwich mechanics not yet implemented') + + +def _fmt(value, digits=0, width=0): + if value is None: + return 'not computed'.rjust(width) + return '{0:,.{1}f}'.format(value, digits).rjust(width) + + +def _bound_row(bound: Optional[Bound], label: str) -> str: + if bound is None: + return ' {0:<26} {1}'.format(label, 'not computed') + return ' {0:<26} {1} in4 {2} in3'.format( + label, _fmt(bound.i_in4, 0, 12), _fmt(bound.s_governing_in3, 0, 9)) + + +def refusal_text(bounds: SectionBounds, detection: Detection, + mark: str = '', spacing_in: Optional[float] = None, + per_row: Optional[int] = None, + declared_action: Optional[str] = None, + connector_note: Optional[str] = None) -> str: + """The refusal, and everything that CAN be said about the section. + + A refusal that prints nothing but "no" is a refusal nobody can act on. + The weight gate set the shape: say what was refused, say why, and print + the evidence -- here the section-property comparison that IS the reason, + so the reader can see how far wrong the solid idealisation would have + been on this piece rather than taking it on trust. + + Nothing in here is a verdict. No stage was analysed, no stress was + computed against an allowable, and the text says so before any number. + """ + lines = [] + head = 'Handling check REFUSED' + if mark: + head += ' -- {0}'.format(mark) + lines.append(head) + lines.append('') + lines.append(' {0}: {1}'.format(REFUSAL_CODE, REFUSAL_REASON)) + lines.append('') + lines.append(' Detected from the job file ({0}): {1}.'.format( + detection.basis or 'no signal', detection.why)) + lines.append('') + lines.append('NO STAGE WAS ANALYSED. Nothing below is a verdict: there is') + lines.append('no stress against an allowable and no pass or fail here.') + lines.append('') + + if not bounds.usable: + lines.append('The section could not be bounded either: {0}' + .format(bounds.why)) + lines.append('') + lines.append(_standdown_block(bounds)) + lines.append('') + lines.append(_concern_block(bounds)) + return '\n'.join(lines) + + layup = bounds.layup + lines.append('The layup, read from the model:') + for layer in layup.layers: + lines.append(' {0:>6.3g} to {1:<6.3g} in {2:>6.3g} in {3}{4}' + .format(layer.z0_in, layer.z1_in, layer.thickness_in, + layer.label, + '' if layer.density_pcf is None + else ' ({0:.0f} pcf)'.format(layer.density_pcf))) + lines.append(' envelope {0:.3g} in, clear width {1:.4g} in' + .format(layup.envelope_in or 0.0, bounds.width_in)) + lines.append('') + lines.append('Section properties at the full clear width, geometry alone:') + lines.append(_bound_row(bounds.solid, 'solid at the envelope')) + lines.append(_bound_row(bounds.composite, 'fully composite')) + lines.append(_bound_row(bounds.non_composite, 'non-composite')) + lines.append(_bound_row(bounds.wythe_alone, 'structural wythe alone')) + lines.append('') + over = bounds.solid_overstates_by + if over: + lines.append(' The solid idealisation overstates the governing ' + 'section modulus by') + lines.append(' {0:.2f}x against the fully composite bound and ' + '{1:.2f}x against the'.format(over[0], over[1])) + lines.append(' non-composite one -- so it is unconservative under ' + 'BOTH readings, and') + lines.append(' understates the tensile stress by the same factors.') + lines.append('') + if declared_action: + lines.append('The plant describes these connectors as: {0}' + .format(declared_action)) + lines.append(' That is EVIDENCE ABOUT DESIGN INTENT and it is not a ' + 'structural ruling.') + lines.append(' Partial composite action exists physically whatever ' + 'the label says, so') + lines.append(' both bounds stay. What the declaration would settle, ' + 'if the owner') + lines.append(' confirms it, is which of them the pass/fail line is ' + 'drawn against.') + lines.append('') + lines.append("Each bound's assumption, which travels with every number " + 'above:') + for bound in (bounds.composite, bounds.non_composite, bounds.wythe_alone): + if bound is not None: + lines.append(' {0}:'.format(bound.name)) + lines.append(' {0}'.format(bound.assumption)) + lines.append('') + + if bounds.flow is not None: + lines.append('Horizontal shear flow at the connector plane ' + '(fully composite bound):') + lines.append(' Q = {0:,.0f} in3, I = {1:,.0f} in4' + .format(bounds.flow.q_in3, bounds.flow.i_in4)) + lines.append(' q = VQ/I = {0:.5f} lb per inch of span, per pound of ' + 'shear V'.format(bounds.flow.per_lb_of_shear)) + if spacing_in and per_row: + lines.append(' at {0:.3g} in. o.c. and {1} connectors across the ' + 'width: {2:.5f} lb per' + .format(spacing_in, per_row, + bounds.flow.per_lb_of_shear * spacing_in / + float(per_row))) + lines.append(' connector, per pound of shear V.') + lines.append(' V is not stated: no stage was solved, so there is no ' + 'shear to multiply by.') + lines.append(' This is the demand full composite action requires. It ' + 'is an upper bound on') + lines.append(' the TOTAL interface force and NOT a bound on the force ' + 'in any ONE') + lines.append(' connector -- see the concerns below.') + lines.append('') + + lines.append(_standdown_block(bounds)) + lines.append('') + lines.append(_concern_block(bounds)) + return '\n'.join(lines) + + +def _standdown_block(bounds: SectionBounds, + connector_note: Optional[str] = None) -> str: + ratio = bounds.ratio or composite_ratio() + lines = ['Stood down, with what would unblock each:', + ' composite ratio NOT COMPUTED', + ' stiffness ratio: {0}'.format(ratio.definition_stiffness), + ' strength ratio: {0}'.format(ratio.definition_strength), + ' {0}'.format(ratio.why), + ' connector sufficiency NOT CHECKED', + ' no connector capacity is published in any layer, and a ' + 'sufficiency', + ' number with no capacity behind it is a check that did not ' + 'run. Needed:', + ' capacity per FAILURE MODE (interface shear, and ' + 'normal/tension), and', + ' slip stiffness k in lb/in per connector.'] + return '\n'.join(lines) + + +def _concern_block(bounds: SectionBounds) -> str: + items = bounds.concerns or concerns(bounds.layup) + lines = ['What the two bounds do and do not bracket:'] + for item in items: + tag = 'BRACKETED ' if item.bracketed else 'NOT BRACKETED' + lines.append(' [{0}] {1}'.format(tag, item.title)) + lines.append(' {0}'.format(item.sentence)) + return '\n'.join(lines) + + +def refusal_json(bounds: SectionBounds, detection: Detection, + mark: str = '') -> Dict[str, Any]: + """The same refusal, for anything reading the summary rather than the + page. + + `verdict` is REFUSED and is never one of OK/WARN/FAIL. Nothing was + checked, so none of those three is available to it -- and a caller that + keys off the three it already knows gets a word it does not recognise + rather than a pass it was never given. + """ + def bound(b): + if b is None: + return None + return {'name': b.name, 'assumption': b.assumption, + 'i_in4': b.i_in4, 's_governing_in3': b.s_governing_in3, + 'neutral_axis_in': b.axis_z_in, + 'fibres': [{'label': f.label, 'z_in': f.z_in, 'c_in': f.c_in, + 's_in3': f.s_in3} for f in b.fibres]} + + over = bounds.solid_overstates_by + ratio = bounds.ratio or composite_ratio() + items = bounds.concerns or concerns(bounds.layup) + return { + 'mark': mark or None, + 'verdict': 'REFUSED', + 'code': REFUSAL_CODE, + 'reason': REFUSAL_REASON, + 'complete': False, + 'detected_by': detection.basis, + 'detected_why': detection.why, + 'section': { + 'usable': bounds.usable, + 'why': bounds.why, + 'width_in': bounds.width_in, + 'envelope_in': (bounds.layup.envelope_in if bounds.layup + else None), + 'layers': ([{'kind': l.kind, 'z0_in': l.z0_in, 'z1_in': l.z1_in, + 'thickness_in': l.thickness_in, + 'material': l.material, + 'density_pcf': l.density_pcf} + for l in bounds.layup.layers] + if bounds.layup else []), + 'solid': bound(bounds.solid), + 'fully_composite': bound(bounds.composite), + 'non_composite': bound(bounds.non_composite), + 'structural_wythe_alone': bound(bounds.wythe_alone), + 'solid_overstates_vs_composite': None if not over else over[0], + 'solid_overstates_vs_non_composite': None if not over else over[1], + 'shear_flow': (None if bounds.flow is None else { + 'q_first_moment_in3': bounds.flow.q_in3, + 'i_in4': bounds.flow.i_in4, + 'lb_per_in_per_lb_of_shear': bounds.flow.per_lb_of_shear, + 'v_lb': bounds.flow.v_lb, + 'q_lb_per_in': bounds.flow.q_lb_per_in}), + }, + 'composite_ratio': { + 'stiffness': ratio.stiffness, + 'strength': ratio.strength, + 'definition_stiffness': ratio.definition_stiffness, + 'definition_strength': ratio.definition_strength, + 'why': ratio.why, + 'unblocked_by': list(ratio.unblocked_by), + }, + 'connector_sufficiency': { + 'verdict': None, + 'why': ('no connector capacity is published in any layer; a ' + 'sufficiency number with no capacity behind it is a ' + 'check that did not run'), + 'unblocked_by': ['connector capacity per failure mode (interface ' + 'shear, and normal/tension) in lb', + 'connector slip stiffness k in lb/in'], + }, + 'concerns': [{'title': c.title, 'bracketed': c.bracketed, + 'sentence': c.sentence} for c in items], + } diff --git a/panellift/src/panellift/section.py b/panellift/src/panellift/section.py new file mode 100644 index 0000000..8299b82 --- /dev/null +++ b/panellift/src/panellift/section.py @@ -0,0 +1,385 @@ +# -*- coding: utf-8 -*- +"""Section properties along the panel, with blockouts taken out. + +A panel stripped flat bends about its weak axis: the resisting section at any +station is the remaining width times the thickness. So every property here is +a function of x, and a blockout is simply width that is not there. + + S = b_eff * t^2 / 6 + +That is the whole of it, and it reproduces both worked examples in the ASPIRE +article exactly -- 1531 in^3 solid, 1148 in^3 with five 6 in. pockets. +""" +from __future__ import annotations + +from typing import List, Tuple + +from . import sandwich +from .model import Panel + + +# ---------------------------------------------------------------- sandwich +# +# An insulated (sandwich) body is two (or more) concrete wythes with an +# insulation gap between them. Everything in this module used to assume ONE +# prism of concrete `panel.thickness_in` deep, and on a sandwich that is the +# `cli._refuse_sandwich` defect: the insulation carries next to nothing, so the +# section (b*d, I, S) and the shear depth d_v must come from the CONCRETE +# WYTHES with the insulation EXCLUDED, never from the full envelope. +# +# WHICH treatment, and why THIS one (stated because a psi that comes out of it +# is conditional on it): the section here is the FULLY-COMPOSITE transformed +# section of the concrete wythes about their common centroid, insulation +# excluded. That is what Eriksson Wall's own section table prints for these +# pieces -- hand-checked against the WPB3.0 golden data (4" + 4" insulation + +# 3", 288 in clear): Area 2016 in2 (= both wythes, insulation out), Cy 5.79 in, +# Izz 29955 in4, Sb 5177 / St 5745 in3 all reproduce to the reported figure. +# Eriksson then applies a %composite factor (95% for stress/strength on the WPB +# runs, 35% for in-place deflection); that partial-composite knock-down and the +# strand development-at-opening layer are OUT OF SCOPE here and are flagged, not +# faked -- see `sandwich.py`'s `composite_ratio`/`concerns`. So this section is +# the fully-composite bound (Eriksson's ~95% sits just under it); the more +# conservative non-composite and structural-wythe-alone bounds live in +# `sandwich.py` and are the owner's to draw a pass/fail line against. +# +# NOTE the task framed Eriksson's wall runs as NON-composite (structural wythe +# alone). The WPB extracted samples do not bear that out: the reported section +# geometry is fully-composite (both wythes) and the %composite setting is 95%, +# not 0. The shear residual is the tell -- it closes at -36% (= 1 - 7/11) only +# when d_v uses the SUM of the wythe thicknesses (7 in here), which is the +# composite concrete depth, not one wythe. So this implements the composite +# concrete section; a structural-wythe-alone default would over-refuse the +# stress by ~13x on Izz and re-open the "over-conservative" half of the defect. + + +def _sandwich_layup(panel: Panel): + """The concrete layup to analyse as a composite section, or None. + + None means "treat as one solid prism" -- either the body is genuinely + solid, or nothing in the job file measured a layup (in which case + `cli._refuse_sandwich` has already refused before any station is reached, + so a None here is only ever a real solid). Returns the `sandwich.Layup` + only for a body the file says is a sandwich AND whose layers are all + positioned through the thickness (`layup.complete`) with two or more + concrete wythes -- the exact makeup a composite section CAN be built from. + A contiguous two-concrete body (an architectural face cast monolithically + on a structural wythe, no insulation gap) satisfies this too and its + composite section is simply the merged solid, which is the right answer. + """ + src = getattr(panel, 'source', None) or {} + det = sandwich.detect(src) + if det.sandwich is not True: + return None + layup = det.layup + if layup is None or not layup.complete: + return None + if len([w for w in layup.wythes if w.thickness_in]) < 2: + return None + return layup + + +class _Wythes: + """Through-thickness properties of the concrete, PER INCH of plan width. + + `a_per` in2/in (= concrete depth), `i_per` in4/in, `s_per` in3/in are the + section constants that scale linearly with the width actually present at a + station, so an opening reduces every one of them together exactly as it + does for a solid. `zbar_in` is the concrete centroid up from the form face, + `c_form`/`c_far` the distances to the two extreme concrete fibres (equal, + = t/2, for a solid), and `shear_t` the concrete depth d_v is built on + (0.8*shear_t), which is the sum of the wythe thicknesses for a sandwich and + the envelope for a solid. + """ + + __slots__ = ('a_per', 'i_per', 's_per', 'zbar_in', 'c_form', 'c_far', + 'shear_t', 'is_sandwich') + + def __init__(self, panel: Panel): + layup = _sandwich_layup(panel) + if layup is None: + t = panel.thickness_in + self.a_per = t + self.i_per = t ** 3 / 12.0 + self.s_per = t ** 2 / 6.0 if t else 0.0 + self.zbar_in = t / 2.0 + self.c_form = self.c_far = t / 2.0 + self.shear_t = t + self.is_sandwich = False + return + wythes = [w for w in layup.wythes if w.thickness_in] + t_conc = sum(w.thickness_in for w in wythes) + zbar = (sum(w.thickness_in * w.mid_in for w in wythes) / t_conc + if t_conc else 0.0) + i_per = sum(w.thickness_in ** 3 / 12.0 + + w.thickness_in * (w.mid_in - zbar) ** 2 for w in wythes) + z_lo = min(w.z0_in for w in wythes) + z_hi = max(w.z1_in for w in wythes) + c_form = zbar - z_lo + c_far = z_hi - zbar + c_gov = max(c_form, c_far) + self.a_per = t_conc + self.i_per = i_per + self.s_per = (i_per / c_gov) if c_gov else 0.0 + self.zbar_in = zbar + self.c_form = c_form + self.c_far = c_far + self.shear_t = t_conc + self.is_sandwich = True + + +# How close to the panel edge an opening has to land before it is taken as +# running out to that edge. A sixteenth of an inch: the model is drawn to +# real dimensions and this is the noise floor, not an engineering judgement. +# +# It exists because the alternative is worse than it sounds. An opening that +# stops 0.001 in short of the edge leaves a strip of concrete 0.001 in wide, +# and that strip is then the OUTERMOST concrete in the section -- so every +# in-plane stress gets reported at a fibre 110 in from the centroid instead of +# 23 in, on a sliver a bar could not be cast into. On one real piece that was +# the whole difference between 432 psi and a commercial check's 162 psi, and +# it was the reason two of the 194 pieces in a full sweep failed. +EDGE_SNAP_FT = (1.0 / 16.0) / 12.0 + + +def voids_at(panel: Panel, x: float) -> List[Tuple[float, float]]: + """Merged (y0, y1) intervals of missing width at station x, in feet. + + An opening within EDGE_SNAP_FT of an edge is taken to reach it, so that a + drafting-tolerance sliver never becomes the section's extreme fibre. + """ + intervals = [] + for b in panel.blockouts: + if not (b.full_depth and b.spans(x)): + continue + lo, hi = b.y0, b.y0 + b.width + if lo <= EDGE_SNAP_FT: + lo = 0.0 + if hi >= panel.width_ft - EDGE_SNAP_FT: + hi = panel.width_ft + intervals.append((lo, hi)) + if not intervals: + return [] + intervals.sort() + merged = [list(intervals[0])] + for start, end in intervals[1:]: + if start > merged[-1][1]: + merged.append([start, end]) + else: + merged[-1][1] = max(merged[-1][1], end) + return [(lo, hi) for lo, hi in merged] + + +def strips_at(panel: Panel, x: float) -> List[Tuple[float, float]]: + """The concrete that IS there at station x, as (y0, y1) in feet. + + An opening does not just make the section narrower, it makes it two + strips of unequal size, and where those strips sit decides where the + section's centroid is. On a panel whose only opening is off to one side + the centroid moves several inches, and the prestress -- which has not + moved -- becomes eccentric about it. + """ + out = [] + edge = 0.0 + for lo, hi in voids_at(panel, x): + lo = max(0.0, min(lo, panel.width_ft)) + hi = max(0.0, min(hi, panel.width_ft)) + if lo > edge: + out.append((edge, lo)) + edge = max(edge, hi) + if edge < panel.width_ft: + out.append((edge, panel.width_ft)) + return [(a, b) for a, b in out if b - a > 1e-9] + + +class NetSection: + """Properties of the concrete remaining at one station. + + Two bending axes, because a prestressed panel needs both: + + z through the thickness -- the axis the piece bends about while it + hangs, and the only one the beam model loads. + y across the width -- nothing loads it, but a prestress that is + off-centre across a section bends it anyway, and that stress is + real and lands on the corners. + """ + + __slots__ = ('area_in2', 'cy_in', 'iy_in4', 'iz_in4', 'width_in', + 'thickness_in', 'y_min_in', 'y_max_in', + 'zbar_in', 'c_form_in', 'c_far_in', 'shear_thickness_in', + 'is_sandwich') + + def __init__(self, panel: Panel, x: float): + strips = strips_at(panel, x) + # Through-thickness constants come from the concrete only: for a solid + # `w.a_per` is the full thickness, for a sandwich it is the summed + # wythe depth with the insulation excluded and the composite inertia. + w = _Wythes(panel) + t_area = w.a_per # concrete depth carrying area + area = 0.0 + first = 0.0 + for a, b in strips: + wid = (b - a) * 12.0 + area += wid * t_area + first += wid * t_area * ((a + b) / 2.0 * 12.0) + self.area_in2 = area + self.cy_in = (first / area) if area > 0.0 else 0.0 + iy = 0.0 + for a, b in strips: + wid = (b - a) * 12.0 + mid = (a + b) / 2.0 * 12.0 + iy += t_area * wid ** 3 / 12.0 + wid * t_area * (mid - self.cy_in) ** 2 + self.iy_in4 = iy + self.width_in = area / t_area if t_area else 0.0 + # thickness_in stays the ENVELOPE (what the piece is drawn as); the + # through-thickness section uses the concrete constants below. + self.thickness_in = panel.thickness_in + self.iz_in4 = self.width_in * w.i_per + self.zbar_in = w.zbar_in + self.c_form_in = w.c_form + self.c_far_in = w.c_far + self.shear_thickness_in = w.shear_t + self.is_sandwich = w.is_sandwich + # The outermost CONCRETE, which is not the outermost panel: an opening + # that runs out to an edge takes that edge with it, and a stress + # reported at a fibre that is not there is a stress nobody has. + self.y_min_in = strips[0][0] * 12.0 if strips else 0.0 + self.y_max_in = strips[-1][1] * 12.0 if strips else 0.0 + + @property + def sz_in3(self) -> float: + """The GOVERNING through-thickness section modulus, I_z / c_max. + + For a solid `c_form == c_far == t/2`, so this is the familiar + `b*t^2/6`. For a sandwich the two concrete faces sit at different + distances from the composite centroid (Sb != St), and the smaller + modulus -- the larger lever -- is the one that decides the peak stress, + so it is the one returned. + """ + c = max(self.c_form_in, self.c_far_in) + return (self.iz_in4 / c) if c else 0.0 + + @property + def y_extremes_in(self) -> Tuple[float, float]: + """Signed distance from the centroid to the outermost concrete.""" + return self.y_min_in - self.cy_in, self.y_max_in - self.cy_in + + +def blockout_width_at(panel: Panel, x: float) -> float: + """Total width, in feet, removed by full-depth blockouts at station x. + + Overlapping blockouts are merged rather than summed. Double-counting an + overlap would make the section look weaker than it is, which sounds + conservative but is really just wrong, and wrong in a way that sends + someone chasing a failure that is not there. + """ + intervals = [(b.y0, b.y0 + b.width) + for b in panel.blockouts + if b.full_depth and b.spans(x)] + if not intervals: + return 0.0 + intervals.sort() + total = 0.0 + lo, hi = intervals[0] + for start, end in intervals[1:]: + if start > hi: + total += hi - lo + lo, hi = start, end + else: + hi = max(hi, end) + return total + (hi - lo) + + +def b_eff_ft(panel: Panel, x: float) -> float: + """Effective width at station x, never below zero.""" + return max(0.0, panel.width_ft - blockout_width_at(panel, x)) + + +def area_in2(panel: Panel, x: float) -> float: + return b_eff_ft(panel, x) * 12.0 * _Wythes(panel).a_per + + +def section_modulus_in3(panel: Panel, x: float) -> float: + return b_eff_ft(panel, x) * 12.0 * _Wythes(panel).s_per + + +def inertia_in4(panel: Panel, x: float) -> float: + return b_eff_ft(panel, x) * 12.0 * _Wythes(panel).i_per + + +def unit_weight_psf(panel: Panel) -> float: + return panel.unit_weight_pcf * panel.thickness_ft + + +def line_load_plf(panel: Panel, x: float) -> float: + """Unfactored self weight per foot of length at station x.""" + return unit_weight_psf(panel) * b_eff_ft(panel, x) + + +def geometric_weight_lb(panel: Panel, stations: int = 2001) -> float: + """Self weight from the geometry we think we have. + + extra_cast_ft3 is concrete outside the rectangle this rebuilds -- a + ledge, a haunch. It belongs to no station, so it is added as the lump + it is; the distributed load is rescaled to the stated weight anyway, + and this is what makes that reconciliation come out honest. + """ + xs, dx = grid(panel.length_ft, stations) + rectangle = sum(line_load_plf(panel, x) for x in xs) * dx + return rectangle + panel.extra_cast_ft3 * panel.unit_weight_pcf + + +def weight_reconciliation(panel: Panel) -> Tuple[float, float, float]: + """Our geometric weight against the weight the model reports. + + A mismatch is the cheapest available signal that a blockout was missed or + the thickness is wrong. It is reported rather than silently corrected, + because we do not know which of the two numbers is the liar. + + Returns (geometric_lb, stated_lb, relative_error). stated is 0.0 when the + model did not give one. + """ + geometric = geometric_weight_lb(panel) + stated = panel.weight_lb + if not stated: + return geometric, 0.0, 0.0 + return geometric, stated, (geometric - stated) / stated + + +def grid(length: float, stations: int) -> Tuple[List[float], float]: + """Midpoint stations, so a blockout edge never lands exactly on a sample.""" + dx = length / stations + return [(i + 0.5) * dx for i in range(stations)], dx + + +def jump_stations(panel: Panel) -> List[float]: + """The x where the section really changes -- a full-depth blockout edge. + + `discontinuities` below samples a hair either side of each of these, + because that is what a station table wants. A MESH wants the jump itself: + a node offset 1e-4 ft from the edge leaves a finite element 1e-4 ft long + beside one 0.19 ft long, and a stiffness ratio of 6e9 across two adjacent + elements is what a dense Gaussian solve loses its digits to. Both callers + read the same edges from here so they cannot drift apart. + """ + edges = set() + for b in panel.blockouts: + if not b.full_depth: + continue + for x in (b.x0, b.x1): + if 0.0 < x < panel.length_ft: + edges.add(x) + return sorted(edges) + + +def discontinuities(panel: Panel) -> List[float]: + """Stations where the section jumps -- blockout edges, always checked. + + Sampled a hair inside each edge on both sides: the moment is continuous + there but S is not, and the reduced side is the one that governs. + """ + eps = 1e-4 + out = set() + for x in jump_stations(panel): + out.add(min(panel.length_ft, x + eps)) + out.add(max(0.0, x - eps)) + return sorted(out) diff --git a/panellift/src/panellift/statics.py b/panellift/src/panellift/statics.py new file mode 100644 index 0000000..1f9e29d --- /dev/null +++ b/panellift/src/panellift/statics.py @@ -0,0 +1,686 @@ +# -*- coding: utf-8 -*- +"""Reactions and the moment diagram for a panel hanging from its inserts. + +The panel is idealised as a beam spanning its long direction, supported at the +lifter lines, carrying its own factored weight. That is not a simplification +bolted on for convenience -- it is what PCI Figure 8.3.2 itself is. Putting +two supports at (sqrt(2)-1)/2 of the span and solving this beam gives +0.02145 w a b^2, which is PCI's 0.0214. test_pci_fig832 asserts exactly that. + +Two load paths, because the engineer chooses between them and they are +different mechanics, not different numbers: + + rolling block equalising sheaves, so every line in a cable group carries + the same force. Two groups, two equations, determinate -- + and independent of stiffness, so blockouts do not move the + reactions around. + static lift fixed-length slings. The panel is a continuous beam on rigid + supports; the reactions follow flexural stiffness and a + blockout near a support genuinely changes them. + +Sign conventions, fixed once here: x runs from one end of the panel, load acts +downward and is positive, reactions act upward and are positive, and sagging +moment (tension on the form face) is positive. +""" +from __future__ import annotations + +import bisect +import math +from dataclasses import dataclass, field +from typing import Any, List, Optional, Sequence, Tuple + +from . import section +from .linalg import solve +from .model import Lifter, Panel, Rigging, Stage +from .pci import elastic_modulus_psi + + +# ------------------------------------------------------------------ layout + +@dataclass +class SupportLine: + """One line of lifters across the panel, acting as a single beam support.""" + x: float + lifters: List[Lifter] + group: int = 0 + reaction_lb: float = 0.0 + + @property + def count(self) -> int: + return len(self.lifters) + + @property + def per_lifter_lb(self) -> float: + return self.reaction_lb / self.count if self.count else 0.0 + + +@dataclass +class Rig: + """The pick, as inferred from where the inserts actually are.""" + lines: List[SupportLine] + points: int + mode: str + grouped_at: Optional[float] = None # gap the cable groups split across + + @property + def description(self) -> str: + return '{0}-point pick on {1} support line{2} ({3})'.format( + self.points, len(self.lines), '' if len(self.lines) == 1 else 's', + 'rolling blocks' if self.mode == 'rolling' else 'static lift') + + +def group_lifters(panel: Panel, rigging: Rigging, + role: str = 'strip') -> Rig: + """Cluster inserts into support lines, then into two cable groups. + + Lifters within group_tolerance_ft of each other along the span are one + line -- they hang from the same point of the rigging, so the beam sees one + support. The lines are then split into a left and a right cable group at + the widest gap between them, which is how a two-, three- or four-point + pick is actually rigged: one gap per side, or none. + """ + picked = sorted(panel.lifters_for(role), key=lambda l: l.x) + if len(picked) < 2: + raise ValueError( + 'need at least two {0!r} lifters to analyse a pick, found {1}' + .format(role, len(picked))) + + lines: List[SupportLine] = [] + for lifter in picked: + if lines and abs(lifter.x - lines[-1].x) <= rigging.group_tolerance_ft: + line = lines[-1] + line.lifters.append(lifter) + line.x = sum(l.x for l in line.lifters) / len(line.lifters) + else: + lines.append(SupportLine(x=lifter.x, lifters=[lifter])) + + if len(lines) < 2: + raise ValueError( + 'all {0} lifters fall on one line at x={1:.2f} ft; a single ' + 'support line cannot be analysed as a pick' + .format(len(picked), lines[0].x)) + + gap_at = None + if len(lines) == 2: + lines[0].group, lines[1].group = 0, 1 + else: + gaps = [(lines[i + 1].x - lines[i].x, i) for i in range(len(lines) - 1)] + _, split = max(gaps) + gap_at = 0.5 * (lines[split].x + lines[split + 1].x) + for i, line in enumerate(lines): + line.group = 0 if i <= split else 1 + + return Rig(lines=lines, points=len(picked), mode=rigging.mode, + grouped_at=gap_at) + + +# ------------------------------------------------------------------ loading + +@dataclass +class LoadProfile: + """Factored self weight sampled on a uniform grid, plus its integrals. + + W(x) = integral of w, and Q(x) = integral of x*w, both cumulative. Holding + them lets the moment at any station come out of a closed form instead of a + nested integral, which keeps the diagram O(n) and exact for a piecewise + constant load. + """ + xs: List[float] + w: List[float] + W: List[float] + Q: List[float] + dx: float + + @property + def total_lb(self) -> float: + return self.W[-1] + + @property + def centroid_ft(self) -> float: + return self.Q[-1] / self.W[-1] if self.W[-1] else 0.0 + + +def load_profile(panel: Panel, multiplier: float, + stations: int = 2000) -> LoadProfile: + xs, dx = section.grid(panel.length_ft, stations) + w = [section.line_load_plf(panel, x) * multiplier for x in xs] + + W, Q = [], [] + running_w = running_q = 0.0 + for x, wi in zip(xs, w): + running_w += wi * dx + running_q += wi * dx * x + W.append(running_w) + Q.append(running_q) + return LoadProfile(xs=xs, w=w, W=W, Q=Q, dx=dx) + + +@dataclass +class PointMoment: + """A concentrated external moment applied to the beam at station x. + + ADDITIVE -- a proper FE point-moment load, not two opposed point forces a + small distance apart (that "couple" trick is used only in the derivation + below and in `test_a_point_moment_matches_the_hand_solution`, never at + runtime). The whole of `moment_lbft` is assembled onto the ROTATION + degree of freedom of the node forced exactly at `x` (see `_nodes`' + `point_moment_xs` and `solve_static`'s force-vector assembly), so an + indeterminate multi-support beam redistributes its reactions around it + exactly the way it would around a real applied couple. + + Sign convention matches the module's own sagging-positive M(x) (see the + module docstring: "sagging moment (tension on the form face) is + positive"), the same convention `capacity._cracking_moment` / + `extreme_fibre_stresses` use for an eccentric axial force (`+P*e` adds + directly to Mg): a positive `moment_lbft` behaves, for every station + beyond x, exactly like extra sagging moment added there and held -- the + diagram picks up a PERMANENT STEP of +moment_lbft crossing x, the same way + a real external couple's moment about any point is independent of where + that point is measured from. + + Derived (limit of two opposite point loads eps apart, worked in full in + the test) for a 2-support span of length L with the moment at x0: + + R0 = -moment_lbft / L (the support BEHIND x0 loses reaction) + RL = +moment_lbft / L + M(x) = -moment_lbft * x / L for x < x0 + M(x) = moment_lbft * (1 - x / L) for x > x0 + + The FE assembly forces `force[rotation_dof] += -moment_lbft` (the + element stiffness/load-vector convention already in this module is the + standard Euler-Bernoulli (v up-positive, theta CCW-positive) DOF pair -- + verified against the module's own UDL consistent-load-vector comment, + `[L/2, L^2/12, L/2, -L^2/12]` -- so a CCW-positive applied moment adds + directly to the theta DOF's force entry, and the sagging-M(x) convention + is the NEGATIVE of that CCW-about-the-cut sense; see the test for the + numeric check this pins). + """ + x: float + moment_lbft: float + + +def scale_to_stated_weight(panel: Panel, profile: LoadProfile, + multiplier: float) -> Tuple[LoadProfile, float]: + """Rescale the load so it integrates to the weight the model reports. + + The geometry we scraped is a reconstruction; the model's own cast-weight + parameter is what the piece actually weighs. Where they differ the model wins for magnitude, + but the *shape* of w(x) still comes from the geometry, so a blockout still + lands in the right place. The discrepancy is returned so it can be + reported rather than buried. + + The returned error is (geometric - stated) / stated, the same definition + and the same sign as section.weight_reconciliation: negative means our + geometry came out lighter than the piece really is, so something -- an + extra blockout, a thin thickness -- is missing from what we read. + """ + if not panel.weight_lb: + return profile, 0.0 + target = panel.weight_lb * multiplier + if profile.total_lb <= 0.0: + return profile, 0.0 + ratio = target / profile.total_lb + error = 1.0 / ratio - 1.0 + if abs(ratio - 1.0) < 1e-9: + return profile, 0.0 + scaled = LoadProfile( + xs=profile.xs, + w=[wi * ratio for wi in profile.w], + W=[v * ratio for v in profile.W], + Q=[v * ratio for v in profile.Q], + dx=profile.dx) + return scaled, error + + +# ---------------------------------------------------------------- reactions + +def solve_rolling(rig: Rig, profile: LoadProfile) -> None: + """Two cable groups, equal force within each. Statics, no stiffness. + + Resolving about x=0: the resultant of a group acts at the mean of its + line positions, because the lines in it carry equal force by construction. + """ + left = [l for l in rig.lines if l.group == 0] + right = [l for l in rig.lines if l.group == 1] + if not left or not right: + raise ValueError('cable grouping produced an empty group') + + xl = sum(l.x for l in left) / len(left) + xr = sum(l.x for l in right) / len(right) + if abs(xr - xl) < 1e-9: + raise ValueError('both cable groups resolve to the same position') + + total = profile.total_lb + moment = profile.Q[-1] + # total = Rl + Rr ; moment = Rl*xl + Rr*xr + rr = (moment - total * xl) / (xr - xl) + rl = total - rr + + for line in left: + line.reaction_lb = rl / len(left) + for line in right: + line.reaction_lb = rr / len(right) + + +def solve_static(rig: Rig, profile: LoadProfile, panel: Panel, + fc_psi: float, + point_moments: Optional[Sequence[PointMoment]] = None) -> None: + """Continuous beam on rigid supports: Euler-Bernoulli, variable EI. + + Two supports give the same answer as statics, which test_statics checks. + Three or more are indeterminate and this is the only way to get them + right; a blockout beside a support really does shed load off it. The same + is true of a `PointMoment` -- see its own docstring -- which is why it is + assembled INTO this solve (a node forced at its x, the moment added to + that node's rotation-DOF force entry) rather than bolted on afterward: + only the FE solve sees how an indeterminate beam's reactions redistribute + around it. + + The mesh and the element loads both come off the SAME station profile the + diagram integrates -- see `_nodes` and `_element_load`. Until 2026-09-01 + they did not: the loads were one sample of w per element and the mesh put + 1e-4 ft slivers beside 0.19 ft elements, so the reactions missed the load + by up to 4.05% and M at the free end came back at 70% of peak M instead of + zero. `test_statics` now asserts the closure on every fixture. + """ + point_moments = list(point_moments or []) + xs_nodes = _nodes(panel, rig, + point_moment_xs=[pm.x for pm in point_moments]) + n_nodes = len(xs_nodes) + n_dof = 2 * n_nodes + + e_psi = elastic_modulus_psi(fc_psi) + stiff = [[0.0] * n_dof for _ in range(n_dof)] + force = [0.0] * n_dof + + for i in range(n_nodes - 1): + x0, x1 = xs_nodes[i], xs_nodes[i + 1] + length = x1 - x0 + if length <= 0.0: + continue + mid = 0.5 * (x0 + x1) + # lb-ft^2: inertia in^4 -> ft^4 by /12^4, modulus psi -> psf by *144 + ei = e_psi * 144.0 * section.inertia_in4(panel, mid) / 20736.0 + + k = _element_stiffness(ei, length) + f = _element_load(profile, x0, x1) + dofs = [2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 3] + for a in range(4): + force[dofs[a]] += f[a] + for b in range(4): + stiff[dofs[a]][dofs[b]] += k[a][b] + + for pm in point_moments: + node = min(range(n_nodes), key=lambda j: abs(xs_nodes[j] - pm.x)) + if abs(xs_nodes[node] - pm.x) > 1e-9: + # `_nodes` forces a node at every point-moment x the same way it + # does for a support line; this can only fire for a moment off + # the piece. + raise ValueError( + 'point moment at x={0:.4f} ft is outside the piece (0 to ' + '{1:.4f} ft), so it cannot be placed' + .format(pm.x, panel.length_ft)) + # CCW-positive rotation DOF; the module's sagging convention is the + # negative of that sense about the cut -- see `PointMoment`. + force[2 * node + 1] += -pm.moment_lbft + + fixed = [] + for line in rig.lines: + node = min(range(n_nodes), key=lambda j: abs(xs_nodes[j] - line.x)) + if abs(xs_nodes[node] - line.x) > 1e-9: + # `_nodes` puts a node on every support line, so this can only + # happen when the line is off the piece. Refuse rather than + # extract the reaction at one x while `diagram` applies it at + # another: the two would disagree, M(L) would stop closing, and + # every number on the page would still look ordinary. + raise ValueError( + 'support line at x={0:.4f} ft is outside the piece (0 to ' + '{1:.4f} ft), so its reaction cannot be placed' + .format(line.x, panel.length_ft)) + fixed.append(2 * node) + fixed_set = set(fixed) + free = [d for d in range(n_dof) if d not in fixed_set] + + reduced = [[stiff[r][c] for c in free] for r in free] + rhs = [force[r] for r in free] + solved = solve(reduced, rhs) + + disp = [0.0] * n_dof + for dof, value in zip(free, solved): + disp[dof] = value + + for line, dof in zip(rig.lines, fixed): + internal = sum(stiff[dof][c] * disp[c] for c in range(n_dof)) + line.reaction_lb = internal - force[dof] + + +# The shortest element the mesh is allowed to make, as a fraction of the +# uniform step. An element's stiffness goes as 1/L^3, so two neighbours whose +# lengths differ by a factor r hand the dense solve a matrix with a spread of +# r^3, and Gaussian elimination gives up roughly that many digits. At a tenth +# of the step the spread is 1e3 and the reactions close to twelve figures. The +# 1e-4 ft nodes this used to take from `section.discontinuities` made a spread +# of 6e9 -- which is how WP313's transportation reactions came out 4.05% over +# the load they carry while every element load was right to five decimals. +MIN_ELEMENT_FRACTION = 0.1 + + +def _place(nodes: List[float], x: float, floor: float) -> None: + """Add x to a sorted node list unless it would leave a sliver element.""" + i = bisect.bisect_left(nodes, x) + if i > 0 and x - nodes[i - 1] <= floor: + return + if i < len(nodes) and nodes[i] - x <= floor: + return + nodes.insert(i, x) + + +def _nodes(panel: Panel, rig: Rig, target: int = 120, + point_moment_xs: Sequence[float] = ()) -> List[float]: + """Uniform nodes, with a node forced onto every support and section jump. + + Three rules, in the order they are applied, and each of the last two is + there because breaking it was measured: + + * a node AT every support line, never merely near one. `solve_static` + reads the reaction off that degree of freedom and `diagram` applies it + at `line.x`; a node a hair away puts the reaction somewhere the + diagram does not, and M(L) stops closing. A `PointMoment`'s x gets the + SAME exact-placement treatment, for the same reason: `solve_static` + assembles it onto one node's rotation DOF and a node a hair away would + put the couple somewhere the diagram does not. + * a node on every full-depth blockout edge, at the edge itself -- so no + element straddles a jump and has neither section. + * nothing else within `MIN_ELEMENT_FRACTION` of the uniform step of a + node already placed. A blockout edge that lands on a support is the + one dropped, because refining the mesh is optional and putting the + reaction in the right place is not. + """ + span = panel.length_ft + step = span / target + floor = step * MIN_ELEMENT_FRACTION + + nodes = [0.0, span] + for x in list(line.x for line in rig.lines) + list(point_moment_xs): + if not (0.0 <= x <= span): + continue # solve_static refuses it by name + i = bisect.bisect_left(nodes, x) + if i < len(nodes) and abs(nodes[i] - x) <= 1e-12: + continue + if i > 0 and abs(nodes[i - 1] - x) <= 1e-12: + continue + nodes.insert(i, x) + for x in section.jump_stations(panel): + _place(nodes, x, floor) + for i in range(1, target): + _place(nodes, i * step, floor) + return nodes + + +def _element_stiffness(ei: float, length: float) -> List[List[float]]: + l, l2, l3 = length, length ** 2, length ** 3 + c = ei / l3 + return [ + [12 * c, 6 * l * c, -12 * c, 6 * l * c], + [6 * l * c, 4 * l2 * c, -6 * l * c, 2 * l2 * c], + [-12 * c, -6 * l * c, 12 * c, -6 * l * c], + [6 * l * c, 2 * l2 * c, -6 * l * c, 4 * l2 * c], + ] + + +def _shape_integrals(length: float, t0: float, t1: float) -> List[float]: + """Integrals of the four Hermite shape functions over t in [t0, t1]. + + t is the natural coordinate (x - x0)/length, and the results are in x, so + over a whole element they come out [L/2, L^2/12, L/2, -L^2/12] -- the udl + consistent load vector, which is the check `test_statics` makes on them. + """ + def at(t): + t2, t3, t4 = t * t, t ** 3, t ** 4 + return (t - t3 + 0.5 * t4, + length * (0.5 * t2 - 2.0 * t3 / 3.0 + 0.25 * t4), + t3 - 0.5 * t4, + length * (0.25 * t4 - t3 / 3.0)) + hi, lo = at(t1), at(t0) + return [length * (hi[k] - lo[k]) for k in range(4)] + + +def _element_load(profile: LoadProfile, x0: float, x1: float) -> List[float]: + """Consistent nodal loads over one element, in the up-positive frame. + + ONE load representation. The diagram integrates the station profile; so + does this, over the same piecewise-constant cells, rather than taking a + single sample of w at the element midpoint and multiplying by the length. + That is the whole of the closure fix: the Hermite shape functions + reproduce a linear field exactly, so f = -integral(w * N) preserves both + the total load and its first moment about any point, and the reactions the + solve returns therefore satisfy sum(R) = integral(w) and + sum(R*x) = integral(x*w) to numerical precision -- which is exactly what + M(L) = 0 asks of them. + + The midpoint rule it replaces preserved neither wherever w was not + constant over the element, and w is never constant over a panel with a + blockout in it. + """ + length = x1 - x0 + if length <= 0.0: + return [0.0, 0.0, 0.0, 0.0] + dx = profile.dx + last = len(profile.w) - 1 + lo = min(last, max(0, int(x0 / dx))) + hi = min(last, max(0, int(x1 / dx))) + out = [0.0, 0.0, 0.0, 0.0] + for j in range(lo, hi + 1): + w = profile.w[j] + if w == 0.0: + continue + a = max(x0, j * dx) + b = min(x1, (j + 1) * dx) + if b <= a: + continue + parts = _shape_integrals(length, (a - x0) / length, (b - x0) / length) + for k in range(4): + out[k] -= w * parts[k] + return out + + +# ------------------------------------------------------------- the diagram + +@dataclass +class Diagram: + xs: List[float] + shear_lb: List[float] + moment_lbft: List[float] + + def moment_at(self, x: float) -> float: + return _interp(self.xs, self.moment_lbft, x) + + def shear_at(self, x: float) -> float: + return _interp(self.xs, self.shear_lb, x) + + @property + def max_positive(self) -> Tuple[float, float]: + i = max(range(len(self.xs)), key=lambda j: self.moment_lbft[j]) + return self.xs[i], self.moment_lbft[i] + + @property + def max_negative(self) -> Tuple[float, float]: + i = min(range(len(self.xs)), key=lambda j: self.moment_lbft[j]) + return self.xs[i], self.moment_lbft[i] + + +def diagram(rig: Rig, profile: LoadProfile, + extra_stations: Sequence[float] = (), + point_moments: Optional[Sequence[PointMoment]] = None) -> Diagram: + """V and M from the reactions, whichever way they were found. + + V(x) = sum(R_i for x_i <= x) - integral_0^x w + M(x) = sum(R_i (x - x_i)) - [ x*W(x) - Q(x) ] + sum(M0_j for x_j <= x) + + Deriving the diagram from equilibrium rather than from the element forces + means the rolling and static paths share one routine, and M(L) coming back + to zero is then a real check on the reactions rather than a tautology. The + last term is `PointMoment`'s own contribution: a real external couple's + moment about any point is independent of where that point is measured + from, so M(x) picks up the WHOLE of `moment_lbft`, undiminished, the + instant x passes it -- no lever arm, unlike a reaction or a distributed + load. `rig.lines`' reactions already carry the point moments' effect on + THEM (from `solve_static`'s assembly), so this is additive on top, not a + substitute. + """ + point_moments = list(point_moments or []) + xs = sorted(set(list(profile.xs) + [0.0] + list(extra_stations) + + [pm.x for pm in point_moments] + + [profile.xs[-1] + profile.dx / 2.0])) + + # Nearly every station in xs IS one of the profile's own stations, where W + # and Q are already known by index. Interpolating there is a binary search + # for a number we are holding. It costs nothing on one diagram and it cost + # nine million calls on one position search, so both lists are walked with + # a single pointer instead: exact where they coincide, interpolated only + # at the handful of blockout edges that fall between. + shear, moment = [], [] + j, n = 0, len(profile.xs) + for x in xs: + while j < n and profile.xs[j] < x: + j += 1 + if j < n and profile.xs[j] == x: + w_int, q_int = profile.W[j], profile.Q[j] + else: + w_int = _interp_cumulative(profile.W, profile, x) + q_int = _interp_cumulative(profile.Q, profile, x) + v = -w_int + m = -(x * w_int - q_int) + for line in rig.lines: + if line.x <= x: + v += line.reaction_lb + m += line.reaction_lb * (x - line.x) + for pm in point_moments: + if pm.x <= x + 1e-9: + m += pm.moment_lbft + shear.append(v) + moment.append(m) + return Diagram(xs=xs, shear_lb=shear, moment_lbft=moment) + + +def _interp_cumulative(values: List[float], profile: LoadProfile, + x: float) -> float: + if x <= profile.xs[0]: + return values[0] * (x / profile.xs[0]) if profile.xs[0] else 0.0 + if x >= profile.xs[-1]: + return values[-1] + return _interp(profile.xs, values, x) + + +def _interp(xs: Sequence[float], ys: Sequence[float], x: float) -> float: + if x <= xs[0]: + return ys[0] + if x >= xs[-1]: + return ys[-1] + lo, hi = 0, len(xs) - 1 + while hi - lo > 1: + mid = (lo + hi) // 2 + if xs[mid] <= x: + lo = mid + else: + hi = mid + span = xs[hi] - xs[lo] + if span <= 0.0: + return ys[lo] + t = (x - xs[lo]) / span + return ys[lo] + t * (ys[hi] - ys[lo]) + + +# ------------------------------------------------------------------ sling + +@dataclass +class SlingForces: + angle_deg: float + amplification: float + line_force_lb: float + horizontal_lb: float + + +def sling_forces(reaction_lb: float, rigging: Rigging) -> SlingForces: + """Force in an inclined line, and the horizontal it drags into the panel. + + LECWall states outright that it does not account for secondary moments + from inclined lines. We cannot fix that without a second-order analysis, + but we can at least put the horizontal component in front of the engineer + instead of leaving it invisible. + """ + theta = math.radians(rigging.sling_angle_deg) + sin_t = math.sin(theta) + if sin_t <= 1e-6: + raise ValueError('sling angle must be greater than zero degrees') + amplification = 1.0 / sin_t + line = reaction_lb * amplification + return SlingForces(angle_deg=rigging.sling_angle_deg, + amplification=amplification, + line_force_lb=line, + horizontal_lb=line * math.cos(theta)) + + +# ------------------------------------------------------------- the analysis + +@dataclass +class Analysis: + panel: Panel + stage: Stage + rigging: Rigging + rig: Rig + profile: LoadProfile + diagram: Diagram + weight_error: float = 0.0 + stations: List[float] = field(default_factory=list) + + # Filled by `capacity.profile_for`, which the verdict check and the + # station table both read so the two cannot disagree. Declared here and + # not attached ad hoc so it is visible on the object it belongs to; + # `capacity_stamp` is a fingerprint of the diagram it was measured on, and + # a profile whose stamp no longer matches is recomputed rather than + # returned. `statics` does not import `capacity` -- nothing here computes + # these, and the annotation stays untyped for exactly that reason. + capacity_points: Optional[List[Any]] = None + capacity_stamp: Optional[tuple] = None + + + +def _support_rig(supports: Sequence[float]) -> Rig: + """Support lines with no lifters behind them -- dunnage, not a pick.""" + lines = [SupportLine(x=float(x), lifters=[], group=0) for x in supports] + return Rig(lines=lines, points=0, mode='static') + +def analyse(panel: Panel, stage: Stage, rigging: Rigging, + role: str = 'strip', reconcile_weight: bool = True) -> Analysis: + if stage.supports is not None: + if len(stage.supports) < 2: + raise ValueError('a supported stage needs at least two supports') + rig = _support_rig(stage.supports) + else: + rig = group_lifters(panel, rigging, role=role) + profile = load_profile(panel, stage.multiplier) + error = 0.0 + if reconcile_weight: + profile, error = scale_to_stated_weight(panel, profile, stage.multiplier) + + if stage.supports is not None: + # Dunnage is a continuous beam on rigid supports. With two lines the + # reactions are statically determinate; with more they follow + # stiffness, exactly like solve_static -- which is what a trailer + # actually is, and never an equalising rig. + solve_static(rig, profile, panel, stage.fc_psi) + elif rigging.mode == 'rolling': + solve_rolling(rig, profile) + elif rigging.mode == 'static': + solve_static(rig, profile, panel, stage.fc_psi) + else: + raise ValueError( + "rigging mode must be 'rolling' or 'static', got {0!r}" + .format(rigging.mode)) + + jumps = section.discontinuities(panel) + dia = diagram(rig, profile, extra_stations=jumps) + return Analysis(panel=panel, stage=stage, rigging=rigging, rig=rig, + profile=profile, diagram=dia, weight_error=error, + stations=jumps) diff --git a/panellift/src/panellift/units.py b/panellift/src/panellift/units.py new file mode 100644 index 0000000..82545ec --- /dev/null +++ b/panellift/src/panellift/units.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""Feet-inches formatting and parsing, shared by every 前端/后端 in this repo. + +Owner ruling, 2026-09-05: every length shown on any input page or results +page -- in `panellift`'s CPython half and in the pyRevit/IronPython half -- +prints as `8'-4 1/2"`, rounded to the nearest 1/8", never as a decimal. +Internal storage and every calculation keep using decimal feet or decimal +inches exactly as before; this module is only the edge where a number +becomes text a person reads, and the edge where text a person typed becomes +a number again. + +Deliberately written in the INTERSECTION of Python 2 and 3 syntax -- no +f-strings, no type annotations, no `from __future__ import annotations` -- +because this exact file is meant to be importable from the IronPython 2.7 +side of the project too (see `WillTools.extension/lib/vibe/pa_extents.py`, +which has its own `_feet_str` today and is expected to switch to this module +later). Do not add anything here that only CPython 3 can parse. + +Four functions: + + fmt_ft_in(feet, precision=8) -> "8'-4 1/2\"" (a length, in feet) + fmt_in(inches, precision=8) -> "6 3/8\"" (a length, in inches) + parse_length(text, default_unit='ft') -> float feet + parse_length_in(text, default_unit='in') -> float inches + +`parse_length` / `parse_length_in` accept every form the owner named: +"8'-4 1/2\"", "8'4\"", "8' 4-1/2\"", "4 1/2\"", "100\"", "-0'-6\"", and a +bare number (read in `default_unit` -- feet unless the caller says +otherwise). A string that matches none of those raises `ValueError` naming +the offending text. +""" + +import re + +try: # Python 2 (IronPython 2.7) + _string_types = (str, unicode) # noqa: F821 +except NameError: # Python 3 + _string_types = (str,) + +from fractions import Fraction + + +# --------------------------------------------------------------- formatting + +def fmt_ft_in(feet, precision=8): + """Decimal feet -> "8'-4 1/2\"", rounded to the nearest 1/precision inch. + + `None` -> '-'. Negative values keep the sign in front of the whole + string ("-0'-6\""), never inside it. A remainder that rounds up to a + full inch carries into the inch count, and 12 inches carries into the + foot count. + """ + if feet is None: + return '-' + sign = '-' if feet < 0 else '' + value = abs(feet) + whole_ft = int(value) + # work in units of 1/precision inch so the carry logic is exact integer + # arithmetic, never float comparison against 12.0 or 1.0. + units_total = int(round((value - whole_ft) * 12.0 * precision)) + inch, rem = divmod(units_total, precision) + if inch >= 12: + whole_ft += inch // 12 + inch = inch % 12 + out = "{0}'-{1}".format(whole_ft, inch) + if rem: + out += ' {0}'.format(Fraction(rem, precision)) + return sign + out + '"' + + +def fmt_in(inches, precision=8): + """Decimal inches -> "6 3/8\"" -- for thicknesses, depths, offsets, + diameters and anything else that is already spoken of in inches, never + feet, across the codebase. + + `None` -> '-'. + """ + if inches is None: + return '-' + sign = '-' if inches < 0 else '' + value = abs(inches) + whole_in = int(value) + units = int(round((value - whole_in) * precision)) + if units >= precision: + whole_in += 1 + units = 0 + out = '{0}'.format(whole_in) + if units: + out += ' {0}'.format(Fraction(units, precision)) + return sign + out + '"' + + +# ------------------------------------------------------------------ parsing + +# A leading sign, held separately from the feet/inch markers below because a +# hyphen INSIDE the string (the "8'-4" separator, not a minus) must never be +# read as negation -- only a hyphen before the first digit is a sign. +_SIGN_RE = re.compile(r'^-\s*') + +# "8", optionally decimal, followed by a foot mark, followed by whatever is +# left (which may be empty, or an inch remainder with its own quote mark). +_FEET_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*'\s*(.*)$") + +# A hyphen used as the visual separator between a whole inch and a fraction +# ("4-1/2") rather than as a sign -- only recognised with a digit already on +# its left, so a leading "-" (from "8'-6\"", the separator right after the +# foot mark) is stripped as its own case, not caught here. +_HYPHEN_FRAC_RE = re.compile(r'(\d)\s*-\s*(\d+\s*/\s*\d+)') + +_MIXED_RE = re.compile(r'^(\d+)\s+(\d+)\s*/\s*(\d+)$') +_FRAC_RE = re.compile(r'^(\d+)\s*/\s*(\d+)$') +_DEC_RE = re.compile(r'^(\d+(?:\.\d+)?)$') + + +def _safe_div(num, den, orig): + """num/den, or ValueError(orig) for a 0 denominator -- a typed "1/0" is + bad input, not a licence for this module to raise ZeroDivisionError + instead of the ValueError every other bad string raises here. + """ + if den == 0: + raise ValueError(orig) + return num / den + + +def _normalize_quotes(s): + s = s.replace(u'’', "'").replace(u'‘', "'") + s = s.replace(u'”', '"').replace(u'“', '"') + return s + + +def _parse_inch_remainder(rest, orig, allow_empty): + """Parse what follows a foot mark (or the whole string, for a bare inch + value): an optional leading '-' separator, a whole number and/or a + fraction, an optional trailing '"'. Returns inches as a float. + """ + rest = rest.strip() + if rest.endswith('"'): + rest = rest[:-1].strip() + if rest.startswith('-'): + # the "8'-6" separator, not a sign -- the sign (if any) was already + # consumed by the caller before the foot mark was even found. + rest = rest[1:].strip() + if rest == '': + if allow_empty: + return 0.0 + raise ValueError(orig) + rest = _HYPHEN_FRAC_RE.sub(r'\1 \2', rest) + m = _MIXED_RE.match(rest) + if m: + return float(m.group(1)) + _safe_div( + float(m.group(2)), float(m.group(3)), orig) + m = _FRAC_RE.match(rest) + if m: + return _safe_div(float(m.group(1)), float(m.group(2)), orig) + m = _DEC_RE.match(rest) + if m: + return float(m.group(1)) + raise ValueError(orig) + + +def _parse_bare_number(s, orig, default_unit): + """No `'` and no trailing `"` anywhere -- a plain number, or a bare + fraction / mixed number, read in `default_unit`. + """ + m = _DEC_RE.match(s) + if m: + value = float(m.group(1)) + else: + s2 = _HYPHEN_FRAC_RE.sub(r'\1 \2', s) + m = _MIXED_RE.match(s2) + if m: + value = float(m.group(1)) + _safe_div( + float(m.group(2)), float(m.group(3)), orig) + else: + m = _FRAC_RE.match(s2) + if m: + value = _safe_div(float(m.group(1)), float(m.group(2)), orig) + else: + raise ValueError(orig) + return value * 12.0 if default_unit == 'ft' else value + + +def _parse_core_in(text, default_unit): + """The shared parser. Returns inches, given how a bare unmarked number + should be read ('ft' or 'in'). + """ + if text is None: + raise ValueError(repr(text)) + if isinstance(text, (int, float)): + return float(text) * 12.0 if default_unit == 'ft' else float(text) + if not isinstance(text, _string_types): + text = str(text) + orig = text + s = text.strip() + if s == '': + raise ValueError(orig) + s = _normalize_quotes(s) + + negative = False + m_sign = _SIGN_RE.match(s) + if m_sign: + negative = True + s = s[m_sign.end():].strip() + if s == '': + raise ValueError(orig) + + m_feet = _FEET_RE.match(s) + if m_feet: + feet = float(m_feet.group(1)) + rest = m_feet.group(2) + total_in = feet * 12.0 + _parse_inch_remainder(rest, orig, True) + elif s.endswith('"'): + total_in = _parse_inch_remainder(s, orig, False) + else: + total_in = _parse_bare_number(s, orig, default_unit) + + return -total_in if negative else total_in + + +def parse_length_in(text, default_unit='in'): + """Parse any of the accepted forms and return inches. + + A bare number with no `'` or `"` anywhere is read in `default_unit` + ('in' by default here, since most callers of the inches-flavoured parser + already speak inches -- pass default_unit='ft' to read a bare number as + feet instead). Raises ValueError(text) when nothing matches. + """ + return _parse_core_in(text, default_unit) + + +def parse_length(text, default_unit='ft'): + """Parse any of the accepted forms and return decimal feet. + + A bare number with no `'` or `"` anywhere is read in `default_unit` + ('ft' by default, per the owner's ruling). Raises ValueError(text) when + nothing matches. + """ + return _parse_core_in(text, default_unit) / 12.0 diff --git a/panellift/src/panellift/windload.py b/panellift/src/panellift/windload.py new file mode 100644 index 0000000..b61c58e --- /dev/null +++ b/panellift/src/panellift/windload.py @@ -0,0 +1,541 @@ +# -*- coding: utf-8 -*- +"""ASCE 7 Components-and-Cladding (Chapter 30) WALL wind-pressure engine. + +This computes the design wind pressure that Panel Analysis's In-Place model +currently takes as a *user-typed* input (the W1..W4 out-of-plane line loads on a +precast wall panel come from these psf pressures times the panel width). It is a +standalone CPython module -- no Revit, no licence, host-testable -- so the +engineering can be checked the same way `capacity.py` and `prestress.py` are. + +WHAT IT IS. The Components & Cladding (C&C) path of ASCE 7, Chapter 30, Part 1 +(low-rise / h <= 60 ft). Given the wind inputs the owner's engineer specifies -- +design wind speed Vult, Risk Category, Exposure, Enclosure classification, ASCE +code version, mean roof height h, and the wall's effective wind area -- it walks +the velocity-pressure chain of Chapter 26 and the wall external-pressure figure +of Chapter 30 to the Zone 4 and Zone 5 design pressures, positive and negative. +This is the governing wind for a wall panel's out-of-plane bending. + +EVERY NUMBER IS GROUNDED, NOTHING IS INVENTED. The tables and equations below +are transcribed from the mined ASCE notes under +`knowledge/library/notes/standards/` -- ASCE7-16-ch26.md / ASCE7-22-ch26.md for +the qz chain and GCpi, ASCE7-16-ch30.md / ASCE7-22-ch30.md (+ the figure-read +sibling ASCE7-22-ch30-figGCp.md) for the wall GCp. Each value carries its cite. +The wall GCp are FIGURE-READ (image content, not text) and carry +/-0.05; every +pressure that rests on one is stamped `figure_read=True`. If a needed value is +not in the notes for the selected version, the engine returns a loud NOT_JUDGED +naming the figure/page rather than fabricating. + +THE VERSION DIFFERENCE THAT MATTERS (Kd placement). The wind directionality +factor Kd moved between editions: + + 7-16 Eq. (26.10-1): qz = 0.00256 * Kz * Kzt * Kd * Ke * V^2 (Kd IN qz) + Eq. (30.3-1): p = qh * [(GCp) - (GCpi)] (no Kd here) + + 7-22 Eq. (26.10-1): qz = 0.00256 * Kz * Kzt * Ke * V^2 (no Kd in qz) + Eq. (30.3-1): p = qh * Kd * [(GCp) - (GCpi)] (Kd HERE) + +The *final* pressure is algebraically identical either way (Kd multiplies once), +but the intermediate qh differs by the factor Kd (0.85 for C&C). The engine +keys off ASCE_CODE_VERSION and never cross-applies: 7-16 puts Kd in qh; 7-22 +keeps it out of qh and applies it in the pressure equation. A caller that reads +the reported qh gets the value ASCE prints for that edition, not a blend. + +SCOPE -- WHY h <= 60 ONLY. The brief scopes this to Chapter 30 Part 1 (the +low-rise wall figure, Fig. 30.3-1, Eq. 30.3-1), which is the precast wall-panel +case and the one the Eriksson samples exercise. For h > 60 ft the C&C wall path +switches to the Directional Procedure (7-16 Eq. 30.5-1 Fig. 30.5-1; 7-22 +Eq. 30.4-1 Fig. 30.4-1), where the positive (windward) pressure uses qz at the +height z of the point on the wall -- q != qh -- and that needs a windward-z +evaluation policy this module does not own. So h > 60 returns NOT_JUDGED naming +that figure, rather than silently reusing the low-rise coefficients. +""" +import math +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + + +# ============================================================================ +# 0. Constants and small value objects +# ============================================================================ + +#: The two editions this engine knows. Anything else -> NOT_JUDGED. +CODE_VERSIONS = ("7-16", "7-22") + +#: The C&C chapter. The engine only walks Chapter 30; a caller naming any other +#: chapter is out of scope (Chapter 27/28/29 are MWFRS/other-structure paths). +CC_CHAPTER = 30 + +#: Velocity-pressure constant, US customary (V in mph, q in psf). +#: 7-16 Eq. (26.10-1) / 7-22 Eq. (26.10-1). +Q_CONST_US = 0.00256 + +#: Wind directionality factor for Components and Cladding of buildings. +#: Table 26.6-1 (7-16 p.266 / 7-22 p.274): "Components and cladding" = 0.85. +KD_CC = 0.85 + +#: Figure-read uncertainty band carried on every wall GCp (image content, not +#: text). ASCE7-16-ch30.md Fig. 30.3-1 note / ASCE7-22-ch30-figGCp.md: +/-0.05. +GCP_PM = 0.05 + +#: Chapter 30 minimum net design pressure: 16 psf in either direction. +#: 7-16 Sec. 30.2.2 (p.333) / 7-22 Sec. 30.2.2 (p.315). +MIN_NET_PRESSURE_PSF = 16.0 + + +class WindNotJudged(Exception): + """Raised for a hard input error a caller must fix (unknown version, etc.). + + Distinct from the *scoped-out* NOT_JUDGED that :func:`compute` returns on a + ``WindResult`` (e.g. h > 60): that is a legitimate answer of "outside this + module's low-rise path", carried as data so a form can show the reason. + """ + + +@dataclass +class Cited: + """A value with the ASCE clause it came from.""" + value: float + cite: str + + +@dataclass +class ZonePressure: + """One wall zone's positive and negative design pressures (psf). + + ``positive`` acts toward the wall (pressure), ``negative`` away (suction); + both are the C&C design values for out-of-plane bending. Each pressure is + reported with the GCp that produced it, and ``figure_read`` marks that the + GCp is figure-read (image content) so the pressure inherits +/-0.05 of GCp + uncertainty -- reported as ``uncertainty_psf``. + """ + zone: int + positive_psf: float + negative_psf: float + gcp_positive: float + gcp_negative: float + figure_read: bool = True + gcp_pm: float = GCP_PM + #: +/- band on each pressure from the +/-0.05 GCp read, = q_eff * gcp_pm. + uncertainty_psf: float = 0.0 + #: True where |p| < 16 psf, so Sec. 30.2.2's 16-psf floor governs design. + min_governs_positive: bool = False + min_governs_negative: bool = False + basis: str = "" + + +@dataclass +class WindResult: + """The full output: the qz chain intermediates and the zone pressures. + + ``not_judged`` is None on success; a sentence (naming the missing/mismatched + page) when the request falls outside the low-rise path. When set, ``zones`` + is empty and the chain fields may be partially filled. + """ + code_version: str + not_judged: Optional[str] = None + + # -- inputs echoed (provenance) -- + v_ult_mph: float = 0.0 + risk_category: str = "" + exposure: str = "" + enclosure: str = "" + mean_roof_height_ft: float = 0.0 + effective_wind_area_ft2: float = 0.0 + + # -- velocity-pressure chain, each cited -- + kh: Optional[Cited] = None + kzt: Optional[Cited] = None + ke: Optional[Cited] = None + kd: Optional[Cited] = None + gcpi: Optional[Cited] = None + qh_psf: Optional[Cited] = None + #: The equation string actually used for qh, keyed to the edition. + qh_equation: str = "" + #: Kd's placement for this edition: 'in_qz' (7-16) or 'in_pressure' (7-22). + kd_placement: str = "" + + # -- results -- + zones: List[ZonePressure] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + + +# ============================================================================ +# 1. Chapter 26 -- the velocity-pressure chain +# ============================================================================ + +# --- Table 26.10-1, Velocity Pressure Exposure Coefficients Kh/Kz ------------- +# Rows are (height_ft, Kz_B, Kz_C, Kz_D). The 0-15 ft band is keyed at 15. +# The Exposure B column was REVISED between editions (7-22 lowered it at and +# above 40 ft); C and D are identical. This is a real version difference beyond +# Kd placement, so both tables are carried verbatim and the engine picks by +# edition. Values transcribed from: +# 7-16: ASCE7-16-ch26.md Table 26.10-1 (p.268) +# 7-22: ASCE7-22-ch26.md Table 26.10-1 (p.277) +_KZ_TABLE_716 = [ + (15, 0.57, 0.85, 1.03), (20, 0.62, 0.90, 1.08), (25, 0.66, 0.94, 1.12), + (30, 0.70, 0.98, 1.16), (40, 0.76, 1.04, 1.22), (50, 0.81, 1.09, 1.27), + (60, 0.85, 1.13, 1.31), (70, 0.89, 1.17, 1.34), (80, 0.93, 1.21, 1.38), + (90, 0.96, 1.24, 1.40), (100, 0.99, 1.26, 1.43), (120, 1.04, 1.31, 1.48), + (140, 1.09, 1.36, 1.52), (160, 1.13, 1.39, 1.55), (180, 1.17, 1.43, 1.58), + (200, 1.20, 1.46, 1.61), (250, 1.28, 1.53, 1.68), (300, 1.35, 1.59, 1.73), + (350, 1.41, 1.64, 1.78), (400, 1.47, 1.69, 1.82), (450, 1.52, 1.73, 1.86), + (500, 1.56, 1.77, 1.89), +] +_KZ_TABLE_722 = [ + (15, 0.57, 0.85, 1.03), (20, 0.62, 0.90, 1.08), (25, 0.66, 0.94, 1.12), + (30, 0.70, 0.98, 1.16), (40, 0.74, 1.04, 1.22), (50, 0.79, 1.09, 1.27), + (60, 0.83, 1.13, 1.31), (70, 0.86, 1.17, 1.34), (80, 0.90, 1.21, 1.38), + (90, 0.92, 1.24, 1.40), (100, 0.95, 1.26, 1.43), (120, 1.00, 1.31, 1.48), + (140, 1.04, 1.34, 1.52), (160, 1.08, 1.39, 1.55), (180, 1.11, 1.41, 1.58), + (200, 1.14, 1.44, 1.61), (250, 1.21, 1.51, 1.68), (300, 1.27, 1.57, 1.73), + (350, 1.33, 1.62, 1.78), (400, 1.38, 1.66, 1.82), (450, 1.42, 1.70, 1.86), + (500, 1.46, 1.74, 1.89), +] +_KZ_TABLE = {"7-16": _KZ_TABLE_716, "7-22": _KZ_TABLE_722} +_KZ_COL = {"B": 1, "C": 2, "D": 3} +_KZ_TABLE_CITE = { + "7-16": "ASCE 7-16 Table 26.10-1 (p.268)", + "7-22": "ASCE 7-22 Table 26.10-1 (p.277)", +} + + +def velocity_pressure_coefficient(height_ft, exposure, code_version): + """Kh (= Kz at mean roof height h) from Table 26.10-1, linearly interpolated. + + Table 26.10-1 Note 3 permits linear interpolation between heights; below the + 0-15 ft band the 15 ft value is used (the table's first row). Above 500 ft + is out of table and out of scope here. + """ + col = _KZ_COL[exposure] + rows = _KZ_TABLE[code_version] + if height_ft <= rows[0][0]: + return rows[0][col] + if height_ft >= rows[-1][0]: + return rows[-1][col] + for i in range(len(rows) - 1): + h0, h1 = rows[i][0], rows[i + 1][0] + if h0 <= height_ft <= h1: + v0, v1 = rows[i][col], rows[i + 1][col] + frac = (height_ft - h0) / (h1 - h0) + return v0 + (v1 - v0) * frac + return rows[-1][col] # unreachable + + +def ground_elevation_factor(elevation_ft): + """Ke from Sec. 26.9 / Table 26.9-1 (same both editions). + + ``None`` -> 1.00, which Table 26.9-1 Note 1/3 permits ("conservative + approximation Ke = 1.00 is permitted in all cases"). A supplied elevation + uses the closed form Ke = exp(-0.0000362 * z_ft) (Table 26.9-1 Note 2). + Cite: ASCE 7-16 Table 26.9-1 (p.268) / ASCE 7-22 Table 26.9-1 (p.275). + """ + if elevation_ft is None: + return 1.0 + return math.exp(-0.0000362 * elevation_ft) + + +# --- Table 26.13-1, Internal Pressure Coefficient (GCpi) ---------------------- +# Keyed by enclosure classification; the value stored is the MAGNITUDE (used as +# +/- in the two load cases of Table 26.13-1 Note 3). +# 7-16: ASCE7-16-ch26.md Table 26.13-1 (p.271) +# 7-22: ASCE7-22-ch26.md Table 26.13-1 (p.280) +_GCPI = { + "enclosed": 0.18, + "partially_enclosed": 0.55, + "partially_open": 0.18, + "open": 0.00, +} +_GCPI_CITE = { + "7-16": "ASCE 7-16 Table 26.13-1 (p.271)", + "7-22": "ASCE 7-22 Table 26.13-1 (p.280)", +} + + +# ============================================================================ +# 2. Chapter 30 -- the wall external-pressure figure (Part 1, h <= 60) +# ============================================================================ + +# --- Fig. 30.3-1, wall (GCp), h <= 60 ft -------------------------------------- +# FIGURE-READ values (image content; each carries +/-0.05). The figure is the +# same across editions; both cites are recorded. Each curve is given by its two +# printed plateau anchors -- the small-area value (A <= 10 ft^2) and the +# large-area value (A >= 500 ft^2) -- and is a straight log-linear line between +# them. Interpolating on log10(A) between these anchors reproduces the notes' +# own derived intermediate table (checked: A=20/50/100/200 land on the note). +# 7-16: ASCE7-16-ch30.md Fig. 30.3-1 (p.335) +# 7-22: ASCE7-22-ch30-figGCp.md Sec. 1 / Fig. 30.3-1 (p.317) +# +# (small_area_anchor, large_area_anchor): +_WALL_GCP_HLE60 = { + "zone4_neg": (-1.1, -0.8), # interior wall, suction + "zone5_neg": (-1.4, -0.8), # edge strips (width a), suction + "pos": (+1.0, +0.7), # single combined Zones 4 & 5 positive curve +} +_WALL_GCP_CITE = { + "7-16": "ASCE 7-16 Fig. 30.3-1, walls h<=60 ft (p.335); figure-read +/-0.05", + "7-22": "ASCE 7-22 Fig. 30.3-1, walls h<=60 ft (p.317, ASCE7-22-ch30-figGCp.md); figure-read +/-0.05", +} + +_A_SMALL = 10.0 # small-area plateau ends here (log axis knee) +_A_LARGE = 500.0 # large-area plateau reached here, flat to 1000 + + +def wall_gcp(area_ft2, curve): + """Figure-read (GCp) for a wall curve at an effective wind area, log-linear. + + ``curve`` is one of 'zone4_neg', 'zone5_neg', 'pos'. Below A=10 ft^2 the + small-area plateau holds; above A=500 ft^2 the large-area plateau holds; + between, a straight line on log10(A) between the two anchors (Fig. 30.3-1). + """ + g_small, g_large = _WALL_GCP_HLE60[curve] + a = max(area_ft2, 1.0) + if a <= _A_SMALL: + return g_small + if a >= _A_LARGE: + return g_large + frac = (math.log10(a) - math.log10(_A_SMALL)) / ( + math.log10(_A_LARGE) - math.log10(_A_SMALL)) + return g_small + (g_large - g_small) * frac + + +# ============================================================================ +# 3. The engine +# ============================================================================ + +def _normalize_enclosure(enclosure): + """Accept a few spellings; return the canonical key or None.""" + if enclosure is None: + return None + key = enclosure.strip().lower().replace("-", "_").replace(" ", "_") + aliases = { + "enclosed": "enclosed", + "partially_enclosed": "partially_enclosed", + "partial_enclosed": "partially_enclosed", + "partially_open": "partially_open", + "partial_open": "partially_open", + "open": "open", + } + return aliases.get(key) + + +def compute( + v_ult_mph, + risk_category, + exposure, + enclosure, + code_version, + mean_roof_height_ft, + effective_wind_area_ft2, + cc_chapter=CC_CHAPTER, + kzt=1.0, + ground_elevation_ft=None, + ke=None, + roof_angle_deg=None, +): + """Compute the C&C wall design pressures (Zone 4 & 5, positive & negative). + + Parameters mirror what the owner's engineer specifies (see module docstring). + ``kzt`` defaults to 1.0 (Sec. 26.8.2: Kzt = 1.0 where the speed-up + conditions are not all met). ``ke`` may be given directly, else it is taken + from ``ground_elevation_ft`` (None -> 1.0). ``roof_angle_deg`` is optional: + when <= 10 deg, Fig. 30.3-1 Note 5 reduces the wall (GCp) by 10%; when None + the reduction is NOT applied and a note says so (roof angle unknown). + + Returns a :class:`WindResult`. On an out-of-scope request (h > 60, unknown + version/exposure/enclosure, wrong chapter, non-positive area) it returns a + result whose ``not_judged`` is a sentence naming the reason/figure, rather + than guessing. + """ + res = WindResult( + code_version=str(code_version), + v_ult_mph=v_ult_mph, + risk_category=str(risk_category), + exposure=str(exposure).strip().upper() if exposure else "", + enclosure=str(enclosure), + mean_roof_height_ft=mean_roof_height_ft, + effective_wind_area_ft2=effective_wind_area_ft2, + ) + + # ---- gate the inputs; every failure is a loud, named NOT_JUDGED ---- + if code_version not in CODE_VERSIONS: + res.not_judged = ( + "NOT JUDGED: ASCE_CODE_VERSION {0!r} not in {1}. The qz-chain " + "arrangement (Kd placement) and the GCpi/GCp source are selected by " + "edition; this engine only carries 7-16 and 7-22.".format( + code_version, CODE_VERSIONS)) + return res + + if int(cc_chapter) != CC_CHAPTER: + res.not_judged = ( + "NOT JUDGED: C&C chapter {0} is out of scope. This engine walks " + "Chapter 30 (Components & Cladding) only; Chapters 27/28/29 are " + "MWFRS / other-structure paths.".format(cc_chapter)) + return res + + exp = res.exposure + if exp not in ("B", "C", "D"): + res.not_judged = ( + "NOT JUDGED: Exposure {0!r} not in (B, C, D). Table 26.10-1 tabulates " + "Kh only for these categories.".format(exposure)) + return res + + encl = _normalize_enclosure(enclosure) + if encl is None: + res.not_judged = ( + "NOT JUDGED: enclosure {0!r} not recognized. Table 26.13-1 defines " + "enclosed / partially_enclosed / partially_open / open.".format( + enclosure)) + return res + + if effective_wind_area_ft2 is None or effective_wind_area_ft2 <= 0: + res.not_judged = ( + "NOT JUDGED: effective wind area must be > 0 ft^2 (got {0!r}).".format( + effective_wind_area_ft2)) + return res + + if mean_roof_height_ft is None or mean_roof_height_ft <= 0: + res.not_judged = ( + "NOT JUDGED: mean roof height must be > 0 ft (got {0!r}).".format( + mean_roof_height_ft)) + return res + + if mean_roof_height_ft > 60.0: + fig = ("Fig. 30.5-1 (ASCE 7-16 p.363)" if code_version == "7-16" + else "Fig. 30.4-1 (ASCE 7-22 p.334)") + res.not_judged = ( + "NOT JUDGED: mean roof height {0:.1f} ft > 60 ft is outside this " + "engine's Chapter 30 Part 1 (low-rise) scope. The C&C wall path for " + "h > 60 ft is the Directional Procedure ({1}, " + "{2}), where the positive (windward) pressure uses qz at the point's " + "height z (q != qh) and needs a windward-z evaluation policy this " + "module does not own. The wall GCp for that figure ARE in the notes; " + "the equation arrangement is not implemented here.".format( + mean_roof_height_ft, + "Eq. 30.5-1" if code_version == "7-16" else "Eq. 30.4-1", + fig)) + return res + + # ---- the velocity-pressure chain ---- + kh = velocity_pressure_coefficient(mean_roof_height_ft, exp, code_version) + ke_val = ke if ke is not None else ground_elevation_factor(ground_elevation_ft) + gcpi_mag = _GCPI[encl] + + res.kh = Cited(kh, _KZ_TABLE_CITE[code_version]) + res.kzt = Cited(kzt, "ASCE 7 Sec. 26.8.2 (default 1.0 where speed-up " + "conditions not all met)") + res.ke = Cited(ke_val, "ASCE 7-16 Table 26.9-1 (p.268)" if code_version == "7-16" + else "ASCE 7-22 Table 26.9-1 (p.275)") + res.kd = Cited(KD_CC, "ASCE 7-16 Table 26.6-1 (p.266)" if code_version == "7-16" + else "ASCE 7-22 Table 26.6-1 (p.274)") + res.gcpi = Cited(gcpi_mag, _GCPI_CITE[code_version]) + + v2 = float(v_ult_mph) ** 2 + + # THE version fork: Kd placement. + if code_version == "7-16": + # Eq. (26.10-1): Kd INSIDE qz. + qh = Q_CONST_US * kh * kzt * KD_CC * ke_val * v2 + res.qh_equation = ("qh = 0.00256 * Kh * Kzt * Kd * Ke * V^2 " + "[ASCE 7-16 Eq. 26.10-1, p.268]") + res.kd_placement = "in_qz" + # Eq. (30.3-1): p = qh * [(GCp) - (GCpi)] (Kd already in qh) + q_pressure = qh + pressure_equation = ("p = qh * [(GCp) - (GCpi)] " + "[ASCE 7-16 Eq. 30.3-1, p.334]") + else: # "7-22" + # Eq. (26.10-1): Kd NOT in qz. + qh = Q_CONST_US * kh * kzt * ke_val * v2 + res.qh_equation = ("qh = 0.00256 * Kh * Kzt * Ke * V^2 " + "[ASCE 7-22 Eq. 26.10-1, p.277]") + res.kd_placement = "in_pressure" + # Eq. (30.3-1): p = qh * Kd * [(GCp) - (GCpi)] (Kd applied here) + q_pressure = qh * KD_CC + pressure_equation = ("p = qh * Kd * [(GCp) - (GCpi)] " + "[ASCE 7-22 Eq. 30.3-1, p.316]") + + res.qh_psf = Cited(qh, res.qh_equation) + res.notes.append(pressure_equation) + # `q_pressure` is the effective multiplier on [(GCp)-(GCpi)] and is identical + # across the two editions (Kd multiplies exactly once either way). + + # ---- Fig. 30.3-1 Note 5: 10% wall-GCp reduction for theta <= 10 deg ---- + gcp_factor = 1.0 + if roof_angle_deg is not None and roof_angle_deg <= 10.0: + gcp_factor = 0.90 + res.notes.append( + "Fig. 30.3-1 Note 5 applied: wall (GCp) reduced 10% for roof angle " + "theta = {0:.1f} deg <= 10 deg.".format(roof_angle_deg)) + elif roof_angle_deg is None: + res.notes.append( + "Fig. 30.3-1 Note 5 (10% wall-GCp reduction for theta <= 10 deg) NOT " + "applied: roof angle not supplied. Supply roof_angle_deg to apply it.") + + # ---- the zone pressures ---- + a = effective_wind_area_ft2 + gcp_pos = wall_gcp(a, "pos") * gcp_factor + for zone, neg_curve in ((4, "zone4_neg"), (5, "zone5_neg")): + gcp_neg = wall_gcp(a, neg_curve) * gcp_factor + # Max positive: +GCp with negative internal (-GCpi). + p_pos = q_pressure * (gcp_pos - (-gcpi_mag)) + # Max negative (suction): -GCp with positive internal (+GCpi). + p_neg = q_pressure * (gcp_neg - (+gcpi_mag)) + unc = abs(q_pressure) * GCP_PM # +/-0.05 on GCp -> +/- this many psf + zp = ZonePressure( + zone=zone, + positive_psf=p_pos, + negative_psf=p_neg, + gcp_positive=gcp_pos, + gcp_negative=gcp_neg, + figure_read=True, + gcp_pm=GCP_PM, + uncertainty_psf=unc, + min_governs_positive=abs(p_pos) < MIN_NET_PRESSURE_PSF, + min_governs_negative=abs(p_neg) < MIN_NET_PRESSURE_PSF, + basis=_WALL_GCP_CITE[code_version], + ) + res.zones.append(zp) + + if any(z.min_governs_positive or z.min_governs_negative for z in res.zones): + res.notes.append( + "Sec. 30.2.2 minimum net C&C pressure 16 psf governs where a " + "computed |p| < 16 psf (flagged per zone).") + + return res + + +# ============================================================================ +# 4. A tiny human-readable dump (host convenience; not used by Revit) +# ============================================================================ + +def format_result(res): + """Render a WindResult as plain text for a host print / debug.""" + lines = [] + lines.append("ASCE {0} C&C wall pressures (Chapter 30, Part 1, h<=60)".format( + res.code_version)) + if res.not_judged: + lines.append(" " + res.not_judged) + return "\n".join(lines) + lines.append(" inputs: Vult={0} mph, Risk {1}, Exp {2}, {3}, h={4} ft, " + "A={5} ft^2".format(res.v_ult_mph, res.risk_category, + res.exposure, res.enclosure, + res.mean_roof_height_ft, + res.effective_wind_area_ft2)) + lines.append(" Kh = {0:.3f} [{1}]".format(res.kh.value, res.kh.cite)) + lines.append(" Kzt = {0:.3f} [{1}]".format(res.kzt.value, res.kzt.cite)) + lines.append(" Ke = {0:.3f} [{1}]".format(res.ke.value, res.ke.cite)) + lines.append(" Kd = {0:.3f} [{1}] (placement: {2})".format( + res.kd.value, res.kd.cite, res.kd_placement)) + lines.append(" GCpi = +/-{0:.2f} [{1}]".format(res.gcpi.value, res.gcpi.cite)) + lines.append(" qh = {0:.2f} psf [{1}]".format(res.qh_psf.value, + res.qh_equation)) + for z in res.zones: + lines.append( + " Zone {0}: +{1:.1f} / {2:.1f} psf (+/-{3:.1f}; GCp {4:+.2f}/{5:+.2f}; " + "figure-read)".format(z.zone, z.positive_psf, z.negative_psf, + z.uncertainty_psf, z.gcp_positive, + z.gcp_negative)) + if z.min_governs_positive or z.min_governs_negative: + lines.append(" (Sec. 30.2.2 16-psf minimum governs)") + for n in res.notes: + lines.append(" note: " + n) + return "\n".join(lines) diff --git a/panellift/tests/conftest.py b/panellift/tests/conftest.py new file mode 100644 index 0000000..020d777 --- /dev/null +++ b/panellift/tests/conftest.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""Shared fixtures, built from the ASPIRE Winter 2019 worked examples. + +Brown, K. "Calculations for Handling and Shipping Precast Concrete Deck +Panels." ASPIRE, Winter 2019, pp. 24-27. The article reproduces PCI Design +Handbook Table 8.3.1 and Figure 8.3.2 and works two examples end to end, which +makes it the one published check available without a PCI licence. + +Panel: 10 ft x 32 ft x 8.75 in., normalweight, f'ci = 4000, f'c = 5000, +four-point pick at the Figure 8.3.2 locations. +""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src')) + +from panellift.model import Blockout, Lifter, Panel, Rigging, Stage # noqa: E402 +from panellift import pci # noqa: E402 + + +WIDTH_FT = 10.0 +LENGTH_FT = 32.0 +THICKNESS_IN = 8.75 + +# The article's rounded values, quoted so a failure names the source number. +ASPIRE = { + 'w_psf': 109.4, + 'moment_lbft': 23973.0, + 's_solid_in3': 1531.0, + 's_pocketed_in3': 1148.0, + 'strip_stress_psi': 244.0, + 'strip_allowable_psi': 316.0, + 'ship_stress_psi': 282.0, + 'ship_allowable_psi': 354.0, + 'pocketed_strip_stress_psi': 326.0, + 'pocketed_ship_stress_psi': 376.0, +} + + +def four_point_lifters(length_ft=LENGTH_FT, width_ft=WIDTH_FT): + """Two lines of two, at the Figure 8.3.2 equalising positions.""" + left, right = pci.optimal_two_line_supports(length_ft) + ys = (0.25 * width_ft, 0.75 * width_ft) + return [Lifter(mark='L{0}'.format(i + 1), family='TEST_LIFTER', + x=x, y=y, role='strip', capacity_lb=8000.0) + for i, (x, y) in enumerate([(left, ys[0]), (left, ys[1]), + (right, ys[0]), (right, ys[1])])] + + +def make_panel(mark='ASPIRE-1', blockouts=None): + """A fresh panel every time. + + Built by a plain function rather than by one fixture mutating another: + pytest caches a fixture per test, so a test asking for both the solid and + the pocketed panel would otherwise be handed the same object twice and + quietly compare it with itself. + """ + return Panel( + mark=mark, + length_ft=LENGTH_FT, + width_ft=WIDTH_FT, + thickness_in=THICKNESS_IN, + unit_weight_pcf=150.0, + fci_psi=4000.0, + fc_psi=5000.0, + finish='smooth', + product_type='flat', + lifters=four_point_lifters(), + blockouts=list(blockouts or []), + ) + + +def aspire_pockets(): + """Five 6 in. x 24 in. full-depth pockets in one row across the width. + + The article puts them 'at or near the lifting points' and notes that is + not good practice -- it is the location of maximum moment. Five pockets + six inches wide take 2.5 ft out of a 10 ft width, giving S = 1148 in^3. + """ + left, _ = pci.optimal_two_line_supports(LENGTH_FT) + return [Blockout(name='P{0}'.format(i + 1), + x0=left - 1.0, y0=1.0 + i * 1.5, + length=2.0, width=0.5) + for i in range(5)] + + +@pytest.fixture +def solid_panel(): + return make_panel() + + +@pytest.fixture +def pocketed_panel(): + return make_panel(mark='ASPIRE-2', blockouts=aspire_pockets()) + + +@pytest.fixture +def stripping(): + return Stage(name='stripping', + multiplier=pci.stripping_multiplier('flat', 'smooth'), + fc_psi=4000.0) + + +@pytest.fixture +def shipping(): + return Stage(name='transportation', + multiplier=pci.TRANSPORTATION_MULTIPLIER, + fc_psi=5000.0) + + +@pytest.fixture +def rolling(): + return Rigging(mode='rolling', sling_angle_deg=90.0) + + +@pytest.fixture +def static_lift(): + return Rigging(mode='static', sling_angle_deg=90.0) diff --git a/panellift/tests/test_aspire.py b/panellift/tests/test_aspire.py new file mode 100644 index 0000000..c58d06a --- /dev/null +++ b/panellift/tests/test_aspire.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +"""The two worked examples from ASPIRE Winter 2019, end to end. + +Every number asserted here appears in print in the article. Where our model is +deliberately more refined than the article -- it reduces the self weight for +the pockets, which the article says it neglects -- the difference is asserted +in the direction it must go, rather than papered over with a loose tolerance. +""" +from panellift import pci, section, statics +from panellift.model import Stage + +from conftest import ASPIRE + + +def _stress_psi(moment_lbft, s_in3): + return moment_lbft * 12.0 / s_in3 + + +# ------------------------------------------------------- section properties + +def test_solid_section_modulus(solid_panel): + s = section.section_modulus_in3(solid_panel, 16.0) + assert abs(s - ASPIRE['s_solid_in3']) < 1.0 + + +def test_pocketed_section_modulus(pocketed_panel): + """Five 6 in. pockets in one row: 1148 in^3, printed in the article.""" + x = pocketed_panel.blockouts[0].x0 + 1.0 + s = section.section_modulus_in3(pocketed_panel, x) + assert abs(s - ASPIRE['s_pocketed_in3']) < 1.0 + + +def test_pockets_only_reduce_the_section_where_they_are(pocketed_panel): + away = section.section_modulus_in3(pocketed_panel, 16.0) + assert abs(away - ASPIRE['s_solid_in3']) < 1.0 + + +# ------------------------------------------------------------- allowables + +def test_allowable_tension_at_stripping(): + assert abs(pci.allowable_tension_psi(4000.0) - + ASPIRE['strip_allowable_psi']) < 1.0 + + +def test_allowable_tension_at_shipping(): + assert abs(pci.allowable_tension_psi(5000.0) - + ASPIRE['ship_allowable_psi']) < 1.0 + + +def test_table_831_multipliers(): + assert pci.stripping_multiplier('flat', 'smooth') == 1.3 + assert pci.stripping_multiplier('flat', 'exposed') == 1.2 + assert pci.stripping_multiplier('sculptured', 'smooth') == 1.7 + assert pci.TRANSPORTATION_MULTIPLIER == 1.5 + assert pci.YARD_AND_ERECTION_MULTIPLIER == 1.2 + + +# --------------------------------------------------- example 1, solid panel + +def test_example_1_stripping(solid_panel, stripping, rolling): + """1.3 x 23,973 lb-ft over S = 1531 in^3 gives 244 psi, under 316 psi.""" + result = statics.analyse(solid_panel, stripping, rolling, + reconcile_weight=False) + _, moment = result.diagram.max_positive + stress = _stress_psi(moment, ASPIRE['s_solid_in3']) + + assert abs(stress - ASPIRE['strip_stress_psi']) < 3.0 + assert stress < pci.allowable_tension_psi(4000.0) + + +def test_example_1_shipping(solid_panel, shipping, rolling): + """1.5 x 23,973 lb-ft over S = 1531 in^3 gives 282 psi, under 354 psi.""" + result = statics.analyse(solid_panel, shipping, rolling, + reconcile_weight=False) + _, moment = result.diagram.max_positive + stress = _stress_psi(moment, ASPIRE['s_solid_in3']) + + assert abs(stress - ASPIRE['ship_stress_psi']) < 3.0 + assert stress < pci.allowable_tension_psi(5000.0) + + +# ------------------------------------------------ example 2, with pockets + +def test_example_2_stripping_fails_as_printed(solid_panel, stripping, rolling): + """326 psi against 316 psi allowable. No good, says the article. + + Computed the article's way: the solid-panel moment against the reduced + section, because the article explicitly neglects the weight the pockets + remove. + """ + result = statics.analyse(solid_panel, stripping, rolling, + reconcile_weight=False) + _, moment = result.diagram.max_positive + stress = _stress_psi(moment, ASPIRE['s_pocketed_in3']) + + assert abs(stress - ASPIRE['pocketed_strip_stress_psi']) < 3.0 + assert stress > pci.allowable_tension_psi(4000.0) + + +def test_example_2_shipping_fails_as_printed(solid_panel, shipping, rolling): + """376 psi against 354 psi allowable.""" + result = statics.analyse(solid_panel, shipping, rolling, + reconcile_weight=False) + _, moment = result.diagram.max_positive + stress = _stress_psi(moment, ASPIRE['s_pocketed_in3']) + + assert abs(stress - ASPIRE['pocketed_ship_stress_psi']) < 3.0 + assert stress > pci.allowable_tension_psi(5000.0) + + +def test_modelling_the_pocket_weight_relieves_the_moment_slightly( + pocketed_panel, solid_panel, stripping, rolling): + """Our full model is less conservative than the article, and must say so. + + The article neglects the 1.6% of self weight the pockets remove. We do + not, so our moment comes out slightly lower -- a few percent at most. If + that gap were ever large it would mean the blockouts were misread, so the + test pins the size of the difference, not just its sign. + """ + full = statics.analyse(pocketed_panel, stripping, rolling, + reconcile_weight=False) + article = statics.analyse(solid_panel, stripping, rolling, + reconcile_weight=False) + _, m_full = full.diagram.max_positive + _, m_article = article.diagram.max_positive + + assert m_full < m_article + assert (m_article - m_full) / m_article < 0.05 + + +def test_pocketed_panel_still_fails_when_modelled_fully(pocketed_panel, + stripping, rolling): + """The refinement must not turn a printed failure into a pass.""" + result = statics.analyse(pocketed_panel, stripping, rolling, + reconcile_weight=False) + x0 = pocketed_panel.blockouts[0].x0 + x = x0 + 1.0 + moment = abs(result.diagram.moment_at(x)) + s = section.section_modulus_in3(pocketed_panel, x) + + assert _stress_psi(moment, s) > pci.allowable_tension_psi(4000.0) diff --git a/panellift/tests/test_capacity.py b/panellift/tests/test_capacity.py new file mode 100644 index 0000000..887226d --- /dev/null +++ b/panellift/tests/test_capacity.py @@ -0,0 +1,1134 @@ +# -*- coding: utf-8 -*- +"""The capacity columns: M_cr, phi, phi*M_n, phi*V_n, and the two ratios. + +Landed 2026-08-31, closing rows A24 / A26 / A27 / A28 / A34 / A36 / A38 of +`QNA/eriksson-gap-inventory.md`. + +Every number in section 1 is HAND COMPUTED, with the arithmetic written into +the comment beside it, on a rectangular prestressed section chosen so the +closed forms are exact: + + b = 48 in. t = 8 in. L = 32 ft f'c = 4000 psi w_c = 150 pcf + 4 strands, 0.153 in.^2 each, all at z = 2.0 in. above the form face + jacked at 30,000 lb each, 10% loss, f_pu = 270 ksi, l_t stated at 30 in. + +f'c = 4000 is deliberate: it is exactly the corner of ACI Table 22.2.2.4.3, so +beta1 = 0.85 with no interpolation to argue about, and w_c = 150 puts lambda +at exactly 1.0. That leaves the arithmetic reproducible on paper. + +The pin is a WRITTEN-DOWN golden, not a recomputation. A test that rebuilds +both sides of the comparison agrees with any refactor, including a wrong one +-- `PROJECT.md` 3.7. + +Section 5 is the one that matters most, and on 2026-09-01 it turned over. The +columns were informational for one day; the owner ruled that a demand/capacity +ratio over 1.0 is a FAIL and that he, not the tool, decides what to do about +it. The three assertions that used to pin "no verdict moved" are inverted in +place rather than deleted, so the day somebody makes these columns +informational again it is those that fail and name the ruling. +""" +import re + +import pytest + +from conftest import LENGTH_FT, aspire_pockets, make_panel +from panellift import capacity, checks, pci, prestress, report, statics +from panellift.model import Blockout, Panel, Prestress, Stage, Strand + + +# ------------------------------------------------------------ the fixture + +HAND_B_IN = 48.0 +HAND_T_IN = 8.0 +HAND_FC = 4000.0 +HAND_LT_IN = 30.0 +HAND_DB_IN = 0.5 + + +def hand_panel(blockouts=None, z_in=2.0): + strands = [Strand(y=0.5 + i, z_in=z_in, area_in2=0.153, + initial_pull_lb=30000.0) + for i in range(4)] + return Panel( + mark='HAND-1', length_ft=32.0, width_ft=HAND_B_IN / 12.0, + thickness_in=HAND_T_IN, unit_weight_pcf=150.0, + fci_psi=HAND_FC, fc_psi=HAND_FC, + blockouts=list(blockouts or []), + prestress=Prestress(strands=strands, loss_pct=10.0, + diameter_in=HAND_DB_IN, + transfer_length_in=HAND_LT_IN)) + + +def hand_stage(): + return Stage(name='stripping', multiplier=1.3, fc_psi=HAND_FC) + + +def at(panel, x, moment_lbft=10000.0, shear_lb=5000.0): + return capacity.capacity_at(panel, hand_stage(), x, moment_lbft, shear_lb) + + +# ------------------------------------------------- 1. the hand arithmetic + +def test_the_section_properties_are_the_ones_the_hand_check_used(): + """A = 48*8 = 384 in^2, S = 48*8^2/6 = 512 in^3, f_r = 7.5*sqrt(4000).""" + from panellift import section + net = section.NetSection(hand_panel(), 16.0) + assert abs(net.area_in2 - 384.0) < 1e-9 + assert abs(net.sz_in3 - 512.0) < 1e-9 + assert abs(pci.lambda_factor(150.0) - 1.0) < 1e-12 + assert abs(pci.beta1(HAND_FC) - 0.85) < 1e-12 + # 7.5 * sqrt(4000) = 7.5 * 63.2455532 = 474.34165 psi + assert abs(pci.modulus_of_rupture_psi(HAND_FC) - 474.34165) < 1e-4 + + +def test_mcr_sagging_matches_the_hand_calculation(): + """M_cr = S*(f_r + P/A) - P*e_z, tension on the form face. + + P = 4 * 30,000 * (1 - 0.10) = 108,000 lb + P/A = 108,000 / 384 = 281.25 psi + e_z = 2.0 - 8.0/2 = -2.00 in. + S*(f_r + P/A) = 512 * 755.59165 = 386,862.93 lb-in + - P*e_z = - 108,000 * (-2.0) = 216,000.00 lb-in + M_cr = 602,862.93 lb-in = 50,238.58 lb-ft + """ + point = at(hand_panel(), 16.0, moment_lbft=+10000.0) + assert point.sagging is True + assert abs(point.mcr_lbft - 50238.58) < 0.05 + + +def test_mcr_hogging_is_smaller_by_twice_the_prestress_moment(): + """Same section, tension on the top face: |M_cr| = S*(f_r + P/A) + P*e_z. + + 386,862.93 - 216,000 = 170,862.93 lb-in = 14,238.58 lb-ft + + A strand below mid-depth helps a sagging piece and hurts a hogging one, + and the difference here is 2*P*e_z / 12 = 36,000 lb-ft. Absoluting the + sign would have printed the sagging number at a hogging station and + overstated the cracking moment 3.5x. + """ + point = at(hand_panel(), 16.0, moment_lbft=-10000.0) + assert point.sagging is False + assert abs(point.mcr_lbft - 14238.58) < 0.05 + assert abs(at(hand_panel(), 16.0, +10000.0).mcr_lbft + - point.mcr_lbft - 36000.0) < 0.1 + + +def test_mcr_rises_with_a_concurrent_axial_pu_by_the_hand_calculation(): + """M_cr's P/A term picks up a concurrent Pu (Eriksson in-place parity #2). + + Same section/strand as the sagging M_cr hand check above, PLUS a + concurrent axial Pu = 50,000 lb (compression, e.g. gravity through the + wall's restraints) fed to `capacity_at`'s new `pu_lb` argument: + + P/A term now = (108,000 + 50,000) / 384 = 411.4583 psi + S*(f_r + P/A) = 512 * (474.34165 + 411.4583) = 453,529.59 lb-in + - P*e_z (prestress only, e_z = -2.0) = 216,000.00 lb-in + M_cr = 669,529.59 lb-in = 55,794.13 lb-ft + + `pu_lb` defaults to 0.0, so `test_mcr_sagging_matches_the_hand_ + calculation` (no `pu_lb` argument at all) is untouched -- this is the + SAME hand-calculated Mcr with one more term added, not a different path. + """ + panel = hand_panel() + stage = hand_stage() + point = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0, + pu_lb=50000.0) + assert abs(point.mcr_lbft - 55794.13) < 0.05 + # and it is EXACTLY the pu=0 case plus S*pu/(A*12) -- the pure P/A term. + baseline = at(panel, 16.0, moment_lbft=10000.0).mcr_lbft + assert abs(point.mcr_lbft - baseline - 512.0 * 50000.0 / 384.0 / 12.0) < 1e-6 + + +def test_pu_zero_capacity_at_leaves_mcr_unchanged(): + """`pu_lb`'s default (0.0) reproduces the pre-existing Mcr exactly. + + Every handling caller (Panel Lift Check) never passes `pu_lb` at all; + this pins that NOT passing it and passing 0.0 explicitly are the same + number, so the golden handling numbers this module's docstring cites + (WPA1.0/WPA5.0) cannot have moved. + """ + panel = hand_panel() + stage = hand_stage() + implicit = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0) + explicit = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0, + pu_lb=0.0) + assert implicit.mcr_lbft == explicit.mcr_lbft + + +# ------------------------------------------- strand-only P-M flexure (gap #2) + +def test_strand_pm_at_pu_zero_matches_the_power_curve_mn(): + """`strand_pm_flexure_at(pu_lb=0)` reproduces `_power_curve_flexure`'s Mn. + + Both solve the SAME neutral-axis equilibrium (C(c) = T(c), no axial) with + the SAME per-strand PCI power-curve force (`_pc_strand_force_lb`) -- one + reached through `capacity_at`'s `mn_powercurve_lbft` (about the + compression resultant), the other through the P-M surface's `mn_lbft` + (about the section centroid, Pu=0 in the equilibrium). Algebraically + identical (Mn about either point agrees once C = T), so this pins them to + stay that way rather than silently drift into two different answers for + "the same section, no axial." + """ + panel = hand_panel() + stage = hand_stage() + cap = capacity.capacity_at(panel, stage, 16.0, 10000.0, 5000.0) + pm = capacity.strand_pm_flexure_at(panel, stage, 16.0, 10000.0, pu_lb=0.0) + assert cap.mn_powercurve_lbft is not None + assert pm.mn_lbft is not None + assert abs(pm.mn_lbft - cap.mn_powercurve_lbft) < 1.0 + + +def test_strand_pm_flexure_rises_with_concurrent_axial_compression(): + """More concurrent Pu (compression) raises the strand-only phi*Mn. + + Measured on the hand section: Mn = 74,763 lb-ft at Pu=0, rising to + 79,517 / 86,257 / 96,437 lb-ft at Pu = 20k / 50k / 100k lb -- the same + P-M-interaction direction the golden's own printed phi*Mn shows moving + with its own Pu column (WPA2.0/A27, pp.30-31: phi*Mn runs 35-104 k-ft in + lockstep with Pu 0-374 k in the same row), and the mechanism this closes + (root cause #2 in `_scratch/eriksson_parity_2026-09-04.md`). + """ + panel = hand_panel() + stage = hand_stage() + mns = [] + for pu in (0.0, 20000.0, 50000.0, 100000.0): + pm = capacity.strand_pm_flexure_at(panel, stage, 16.0, 10000.0, + pu_lb=pu) + assert pm.why is None, pm.why + mns.append(pm.mn_lbft) + assert mns == sorted(mns), 'Mn should rise monotonically with Pu here' + assert mns[-1] > mns[0] * 1.2 + + +def test_strand_pm_phi_floor_is_the_strand_065_not_the_mild_075(): + """The strand-only P-M path's phi floor is 0.65, not `mild_flexure_at`'s. + + `phi_flexure_from_strain`'s own default `phi_min` is already the strand + (prestressed) floor `pci.PHI_FLEXURE_COMPRESSION_CONTROLLED` -- this pins + `strand_pm_flexure_at` never overrides it with the mild 0.75 floor + `mild_flexure_at` uses, which the golden's own printed phi column (0.68 + at its lowest, WPA2.0/A27) would fall below if this drifted onto the + mild floor at a heavily axial-loaded, compression-controlled station. + """ + panel = hand_panel() + stage = hand_stage() + # A large enough Pu pushes the section toward compression-controlled -- + # measured 0.002536 net tensile strain at 300k lb, inside the transition + # (0.002-0.005) where the mild and strand floors would actually disagree. + pm = capacity.strand_pm_flexure_at(panel, stage, 16.0, 10000.0, + pu_lb=300000.0) + assert pm.why is None, pm.why + assert pm.phi == pytest.approx( + capacity.phi_flexure_from_strain(pm.strain_t)) + assert pm.phi < pci.PHI_FLEXURE_COMPRESSION_CONTROLLED_MILD, ( + 'phi in the transition should sit BELOW the mild floor, reading on ' + 'the lower strand floor (0.65), not the mild one (0.75)') + + +def test_phi_mn_matches_the_hand_calculation(): + """ACI 318-19 Eq. 20.3.2.3.1 then the Whitney block. + + d_p = 8.0 - 2.0 = 6.0000 in. + A_ps = 4 * 0.153 = 0.6120 in^2 + rho_p = 0.612 / (48 * 6) = 0.002125 + f_ps = 270 * (1 - (0.28/0.85)*(0.002125*270000/4000)) + = 270 * (1 - 0.3294118*0.1434375) = 257.2425 ksi + T = 0.612 * 257,242.5 = 157,432.4 lb + a = T / (0.85*4000*48) = 0.964659 in. + c = a / 0.85 = 1.134893 in. + eps_t = 0.003*(6.0 - 1.134893)/1.134893 = 0.012861 -> phi = 0.90 + M_n = T*(6.0 - 0.482330) = 868,660.1 lb-in + phi*M_n= 0.9 * 72,388.3 = 65,149.51 lb-ft + """ + point = at(hand_panel(), 16.0, moment_lbft=+10000.0) + assert abs(point.aps_in2 - 0.612) < 1e-9 + assert abs(point.dp_in - 6.0) < 1e-9 + assert abs(point.fps_ksi - 257.2425) < 1e-4 + assert abs(point.a_in - 0.964659) < 1e-6 + assert abs(point.c_in - 1.134893) < 1e-6 + assert abs(point.strain_t - 0.012861) < 1e-6 + assert point.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED == 0.90 + assert abs(point.phi_mn_lbft - 65149.51) < 0.05 + # and the ratio is the demand handed in, over that + assert abs(point.m_over_phi_mn - 10000.0 / 65149.51) < 1e-9 + + +def test_phi_vn_matches_the_hand_calculation_and_uses_the_code_depth_floor(): + """V_c = 2*lambda*sqrt(f'c)*b_w*d, phi = 0.75. + + d_p = 6.00 in., but ACI 22.5.2.2 floors d at 0.80h = 6.40 in. + V_c = 2 * 1.0 * 63.2455532 * 48 * 6.4 = 38,858.07 lb + phi*V_n = 0.75 * 38,858.07 = 29,143.55 lb + + The floor is the interesting half. Without it a shallow strand layout + would report a shear capacity the code does not require anybody to take. + """ + point = at(hand_panel(), 16.0, moment_lbft=+10000.0, shear_lb=5000.0) + assert abs(point.d_shear_in - 6.4) < 1e-12 + assert point.d_floor_applied is True + assert point.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED + assert pci.PHI_SHEAR == 0.75 + assert abs(point.phi_vn_lb - 29143.55) < 0.05 + assert abs(point.v_over_phi_vn - 5000.0 / 29143.55) < 1e-7 + + +def test_a_section_that_is_not_tension_controlled_dashes_phi_rather_than_guessing(): + """Hogging on this layout puts d_p at 2.0 in. and eps_t at 0.00287. + + ACI Table 21.2.2's transition between compression- and tension-controlled + is not built, so phi and phi*M_n are None WITH a reason. Inventing a phi + off an unimplemented transition is the failure this whole package is + against, and 0.90 borrowed from the tension-controlled row would overstate + the capacity of exactly the sections where it matters. + """ + point = at(hand_panel(), 16.0, moment_lbft=-10000.0) + assert abs(point.strain_t - 0.0028690) < 1e-6 + assert point.strain_t < pci.TENSION_CONTROLLED_STRAIN + assert point.phi is None + assert point.phi_mn_lbft is None + assert point.m_over_phi_mn is None + assert 'not tension-controlled' in point.reason(capacity.PHI_MN) + # M_cr still answers -- it does not depend on phi + assert point.mcr_lbft is not None + # and so does shear + assert point.phi_vn_lb is not None + + +# ------------------------------- 2. transfer and development, one set of them + +def test_inside_the_transfer_length_fps_ramps_from_zero(): + """12 in. from the end, l_t = 30 in.: f_ps = f_se * 12/30. + + f_se = 30,000 * 0.90 / 0.153 = 176.4706 ksi + f_ps = 176.4706 * 0.4 = 70.5882 ksi + T = 0.612 * 70,588.2 = 43,200.0 lb (= 0.4 * P, exactly) + a = 43,200 / 163,200 = 0.264706 in. + phi*M_n= 0.9 * 43,200 * (6 - 0.132353) / 12 = 19,011.18 lb-ft + """ + point = at(hand_panel(), 1.0, moment_lbft=+10000.0) + assert abs(point.fse_ksi - 176.470588) < 1e-5 + assert abs(point.fps_ksi - 70.588235) < 1e-5 + assert abs(point.phi_mn_lbft - 19011.18) < 0.05 + assert point.under_developed is True + + +def test_between_transfer_and_development_fps_ramps_from_fse_to_fps(): + """40 in. from the end. l_d = l_t + (f_ps - f_se)*d_b, ACI 25.4.8.1. + + l_d = 30 + (257.2425 - 176.4706)*0.5 = 70.3860 in. + The slope of the second leg is (f_ps - f_se)/(l_d - l_t) = 1/d_b = 2, + so f_ps(40) = 176.4706 + 2*(40 - 30) = 196.4706 ksi exactly. + phi*M_n = 50,785.93 lb-ft + """ + point = at(hand_panel(), 40.0 / 12.0, moment_lbft=+10000.0) + assert abs(point.fps_ksi - 196.470588) < 1e-5 + assert abs(point.phi_mn_lbft - 50785.93) < 0.05 + assert point.under_developed is True + # the length itself, straight out of pci, built on the transfer length + ld = pci.development_length_in(HAND_LT_IN, 257.2425, 176.470588, HAND_DB_IN) + assert abs(ld - 70.38596) < 1e-4 + + +def test_the_transfer_length_is_the_one_the_stress_path_already_uses(): + """One transfer length in this package, not two. + + `pci.development_length_in` takes l_t as an ARGUMENT rather than + recomputing f_se*d_b/3, and the caller hands it + `prestress.transfer_length_in` -- the same function the P/A columns use, + which honours a job file that states one. + """ + panel = hand_panel() + assert prestress.transfer_length_in(panel.prestress) == HAND_LT_IN + # ld's first term IS lt, so a job stating a different lt moves both + assert pci.development_length_in(0.0, 200.0, 200.0, 0.5) == 0.0 + assert pci.development_length_in(31.0, 200.0, 200.0, 0.5) == 31.0 + + +def test_the_prestress_force_saturates_at_lt_while_fps_keeps_climbing(): + """Not an inconsistency -- two different quantities. + + f_se, the EFFECTIVE prestress, is fully in the concrete at the transfer + length, which is what a transfer length means; M_cr therefore stops + growing there. f_ps, the stress at flexural FAILURE, needs the longer + development length. So at 40 in. M_cr is already at its full-section value + and phi*M_n is still climbing. + """ + full = at(hand_panel(), 16.0, +10000.0) + forty = at(hand_panel(), 40.0 / 12.0, +10000.0) + twelve = at(hand_panel(), 1.0, +10000.0) + assert abs(forty.mcr_lbft - full.mcr_lbft) < 1e-6 # saturated + assert twelve.mcr_lbft < full.mcr_lbft # still ramping + assert forty.phi_mn_lbft < full.phi_mn_lbft # still ramping + assert twelve.phi_mn_lbft < forty.phi_mn_lbft + + +def test_a_blockout_restarts_the_run_and_a_strand_inside_it_carries_nothing(): + """An opening cuts every strand it passes over -- the same rule the + prestress module already applies, reached through `bonded_run` and not + re-implemented.""" + # y = 0.0 .. 2.0 ft catches the strands at y = 0.5 and 1.5; the ones at + # 2.5 and 3.5 carry straight through. + hole = Blockout(name='B', x0=14.0, y0=0.0, length=4.0, width=2.0) + panel = hand_panel(blockouts=[hole]) + inside = at(panel, 16.0, +10000.0) + assert abs(inside.aps_in2 - 2 * 0.153) < 1e-9 # two survivors only + # just past the far edge, the cut pair restarts from zero embedment + past = at(panel, 18.01, +10000.0) + assert abs(past.aps_in2 - 4 * 0.153) < 1e-9 # all four are bonded + assert past.fps_ksi < inside.fps_ksi # but two are undeveloped + assert past.under_developed is True + + +# --------------------------------- 3. could not judge, never a zero + +def test_a_station_with_no_section_dashes_every_capacity_with_a_reason(): + hole = Blockout(name='FULL', x0=10.0, y0=0.0, length=4.0, width=4.0) + point = at(hand_panel(blockouts=[hole]), 12.0) + for column in (capacity.MCR, capacity.PHI_MN, capacity.PHI_VN): + assert point.reason(column), column + assert point.mcr_lbft is None + assert point.phi_mn_lbft is None + assert point.phi_vn_lb is None + + +def test_a_piece_with_no_prestress_says_so_rather_than_computing_zero(): + panel = hand_panel() + panel.prestress = None + point = at(panel, 16.0) + assert point.phi_mn_lbft is None + assert 'no prestressing' in point.reason(capacity.PHI_MN) + # M_cr is still real: plain concrete cracks at f_r. + # 512 * 474.34165 = 242,862.9 lb-in = 20,238.58 lb-ft + assert abs(point.mcr_lbft - 20238.58) < 0.05 + # and shear still answers, on the code's 0.80h depth + assert abs(point.d_shear_in - 6.4) < 1e-12 + + +def test_the_approximate_fps_equation_is_refused_outside_its_own_range(): + """ACI 20.3.2.3.1 holds only where f_se >= 0.5 f_pu. Below that this + stands down with the reason rather than using the equation anyway.""" + panel = hand_panel() + for strand in panel.prestress.strands: + strand.initial_pull_lb = 10000.0 # f_se = 58.8 ksi, 0.218 f_pu + point = at(panel, 16.0) + assert point.phi_mn_lbft is None + assert '0.5 f_pu' in point.reason(capacity.PHI_MN) + assert point.mcr_lbft is not None # M_cr does not use the equation + + +def test_no_capacity_is_ever_the_neighbouring_stations_value(): + """Every point is solved at its own station. Two stations either side of + an opening edge differ in b, so they must differ in capacity.""" + hole = Blockout(name='B', x0=10.0, y0=0.0, length=4.0, width=2.0) + panel = hand_panel(blockouts=[hole]) + outside = at(panel, 9.999, +10000.0) + inside = at(panel, 10.001, +10000.0) + assert outside.b_in > inside.b_in + assert outside.mcr_lbft > inside.mcr_lbft + assert outside.phi_vn_lb > inside.phi_vn_lb + + +# ------------------------------------ 4. the constants are named and cited + +def test_the_phi_values_and_lambda_are_named_constants_not_inline_digits(): + """A 0.9 typed into an expression is indistinguishable from a fudge + factor six months later. This is the tombstone for that.""" + import ast + source = open(capacity.__file__, encoding='utf-8').read() + forbidden = {0.9, 0.75, 7.5, 0.85, 0.005, 0.003, 0.28, 0.8} + literals = set() + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Constant) and isinstance(node.value, float): + literals.add(node.value) + assert not literals & forbidden, sorted(literals & forbidden) + assert 'pci.PHI_FLEXURE_TENSION_CONTROLLED' in source + assert 'pci.PHI_SHEAR' in source + assert 'pci.lambda_factor' in source + assert 'pci.TENSION_CONTROLLED_STRAIN' in source + + +def test_every_landed_constant_cites_its_clause(): + source = open(pci.__file__, encoding='utf-8').read() + for name in ('PHI_FLEXURE_TENSION_CONTROLLED', 'PHI_SHEAR', + 'MODULUS_OF_RUPTURE_SQRT_FC', 'CONCRETE_SHEAR_SQRT_FC', + 'MIN_D_OVER_H_PRESTRESSED', 'GAMMA_P_LOW_RELAXATION', + 'TENSION_CONTROLLED_STRAIN', 'LAMBDA_NORMALWEIGHT_MIN_PCF'): + assert name in source, name + # the clauses themselves, so a renumbering cannot go unnoticed + for clause in ('Table 21.2.1', 'Table 21.2.2', '19.2.3.1', + 'Table 19.2.4.1(a)', 'Table 22.2.2.4.3', '20.3.2.3.1', + '25.4.8.1', '22.5.5.1', '22.5.2.2'): + assert clause in source, clause + + +def test_the_two_sevenpointfives_are_kept_apart_in_writing(): + """The rupture modulus and the handling tensile allowable are both 7.5 on + this plant and they are different quantities. The constant carries the + warning; the report prints it. This test is what stops a tidy-up merging + them.""" + source = open(pci.__file__, encoding='utf-8').read() + assert 'MODULUS_OF_RUPTURE_SQRT_FC = 7.5' in source + assert 'tension_limit_sqrt_fc' in source # named as the other one + page = report.station_table(_analysis()) + assert 'not the' in page and 'tension allowable' in page + + +def test_lambda_comes_off_the_concretes_own_density(): + assert pci.lambda_factor(150.0) == 1.0 + assert pci.lambda_factor(136.0) == 1.0 + assert pci.lambda_factor(100.0) == pci.LAMBDA_ALL_LIGHTWEIGHT == 0.75 + assert abs(pci.lambda_factor(120.0) - 0.90) < 1e-12 + # a lightweight piece is credited with less tension, everywhere + heavy = at(hand_panel(), 16.0) + light_panel = hand_panel() + light_panel.unit_weight_pcf = 110.0 + light = at(light_panel, 16.0) + assert light.lam < heavy.lam + assert light.mcr_lbft < heavy.mcr_lbft + assert light.phi_vn_lb < heavy.phi_vn_lb + + +def test_phi_mn_is_strand_only_because_nothing_can_express_a_bar_layer(): + """The justification for dropping the (omega - omega') term, asserted + rather than believed. The day a job file can carry mild steel, this test + fails and the footnote has to be rewritten with it.""" + from panellift import cli + import inspect + assert 'reinforcement' not in inspect.getsource(cli.read_panel) + assert not make_panel().reinforcement + + +# ------------------------------- 5. a ratio over 1.0 IS a verdict, and FAIL +# +# This section was the opposite of itself until 2026-09-01. It asserted, three +# ways, that nothing here could move a verdict. The owner ruled: +# +# "M/pMn 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail, +# 我做最后决策。" +# +# so the same three assertions are now inverted, in place, rather than deleted +# -- the day somebody quietly makes these columns informational again, these +# are what fail and say which ruling was undone. + +def _analysis(panel=None, stage=None): + from panellift.model import Rigging + panel = panel or make_panel(blockouts=aspire_pockets()) + stage = stage or Stage(name='stripping', multiplier=1.3, fc_psi=4000.0) + return statics.analyse(panel, stage, Rigging(mode='rolling')) + + +def _prestressed_analysis(): + """The hand panel, on a real pick, so phi-Mn exists at most stations.""" + panel = hand_panel() + panel.lifters = make_panel(blockouts=[]).lifters + return _analysis(panel=panel) + + +def test_the_checks_module_now_reads_this_one_because_the_owner_ruled_so(): + """The inverse of what this asserted for one day. `checks` HAS to import + `capacity` now: the verdict is taken off these numbers.""" + import ast + source = open(checks.__file__, encoding='utf-8').read() + imported = set() + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.ImportFrom): + imported.update(a.name for a in node.names) + elif isinstance(node, ast.Import): + imported.update(a.name for a in node.names) + assert 'capacity' in imported, sorted(imported) + assert 'check_capacity_ratios' in source + # and the ruling itself is beside the rule, in the language it was given in + assert '大于1.0你就报fail' in open(capacity.__file__, + encoding='utf-8').read() + + +def test_run_all_carries_the_capacity_verdict(): + from panellift.model import Rigging + analysis = _prestressed_analysis() + findings = checks.run_all(analysis, Rigging(mode='rolling')) + codes = [f.code for f in findings] + assert 'FLEX-CAP' in codes and 'SHEAR-CAP' in codes + + +def test_a_ratio_over_one_is_a_FAIL_and_says_whose_ruling_that_is(): + """Forced, as it always was: the moment is multiplied until M/phi*M_n + passes 1.0. What changed on 2026-09-01 is the answer -- this used to + assert 'no verdict moved' and now asserts that one did.""" + from panellift.model import Rigging + analysis = _prestressed_analysis() + analysis.diagram.moment_lbft = [m * 1000.0 + for m in analysis.diagram.moment_lbft] + findings = checks.run_all(analysis, Rigging(mode='rolling')) + flex = [f for f in findings if f.code == 'FLEX-CAP'] + assert len(flex) == 1 + assert flex[0].severity == 'FAIL' + assert checks.worst_severity(findings) == 'FAIL' + assert 'M/phi-Mn' in flex[0].message + assert 'STRAND ONLY' in flex[0].message # the basis of phi-Mn + assert 'may not bless' in flex[0].message # the ruling + assert "owner's ruling, 2026-09-01" in flex[0].message + assert flex[0].at_ft is not None # the station it happened + # the piece and the stage are named, because a sweep reads these in a list + assert analysis.panel.mark in flex[0].message + assert analysis.stage.name in flex[0].message + # and the page says the same thing rather than 'informational' + page = report.station_table(analysis) + rows = [r for r in _rows(page) if 'over 1.0' in r] + assert rows, 'no station exceeded its capacity even at 1000x the moment' + assert 'may not bless' in rows[0] + assert 'informational' not in page + + +def test_a_ratio_under_one_raises_no_FAIL(): + """The mirror of the forcing test. The same piece with the moment scaled + away has capacity to spare, and the check must say OK rather than + inventing a severity out of a comfortable number.""" + from panellift.model import Rigging + analysis = _prestressed_analysis() + analysis.diagram.moment_lbft = [m * 1e-6 + for m in analysis.diagram.moment_lbft] + analysis.diagram.shear_lb = [v * 1e-6 for v in analysis.diagram.shear_lb] + findings = checks.check_capacity_ratios(analysis) + assert findings, 'the check spoke to nothing at all' + assert all(f.severity == 'OK' for f in findings), \ + [(f.code, f.severity) for f in findings] + assert not any(f.severity == 'FAIL' + for f in checks.run_all(analysis, Rigging(mode='rolling')) + if f.code in ('FLEX-CAP', 'SHEAR-CAP')) + + +def test_exactly_one_point_zero_is_at_the_capacity_and_not_over_it(): + """`RATIO_FAIL_ABOVE` is strictly greater, the same convention every other + limit in this package uses. A ratio of exactly 1.0 is the capacity.""" + assert capacity.RATIO_FAIL_ABOVE == 1.0 + analysis = _prestressed_analysis() + points = capacity.profile_for(analysis) + index, worst, _judged, _total = capacity.worst_ratio(points, + 'm_over_phi_mn') + assert worst is not None + # pin the ratio to exactly 1.0 at that station and nowhere else + for i, p in enumerate(points): + p.m_over_phi_mn = 1.0 if i == index else None + findings = checks.check_capacity_ratios(analysis) + flex = [f for f in findings if f.code == 'FLEX-CAP'] + assert len(flex) == 1 and flex[0].severity == 'OK' + + +def test_a_station_that_cannot_be_judged_is_no_verdict_at_all(): + """A dash is not a pass and not a fail. The ASPIRE panel has no strand, so + phi-Mn dashes at every station with its reason already in the footnote -- + and the check says nothing rather than passing the piece or failing it.""" + from panellift.model import Rigging + analysis = _analysis() # no prestress on this panel + points = capacity.profile_for(analysis) + assert all(p.phi_mn_lbft is None for p in points) + assert all(p.reason(capacity.PHI_MN) for p in points) + codes = [f.code for f in checks.check_capacity_ratios(analysis)] + assert 'FLEX-CAP' not in codes + # ... and nothing else in the run turned that silence into a verdict + assert 'FLEX-CAP' not in [f.code for f in + checks.run_all(analysis, Rigging(mode='rolling'))] + + +def test_the_development_length_note_is_carried_and_is_not_an_exclusion(): + """The owner was told all four known exceedances sat inside a development + length and ruled FAIL anyway. So the sentence is on the finding -- it is + what he decides with -- and the severity is FAIL regardless.""" + # An opening across the whole strand pattern, so the worst ratio lands + # just outside its edge -- the geometry every real exceedance has. + panel = hand_panel(blockouts=[Blockout(name='DOOR', x0=10.0, y0=0.0, + length=8.0, width=3.9)]) + panel.lifters = make_panel(blockouts=[]).lifters + analysis = _analysis(panel=panel) + analysis.diagram.moment_lbft = [m * 1000.0 + for m in analysis.diagram.moment_lbft] + flex = [f for f in checks.check_capacity_ratios(analysis) + if f.code == 'FLEX-CAP'][0] + assert flex.severity == 'FAIL' + assert 'development length of a free end' in flex.message + assert 'an opening edge, which cuts every strand it crosses' in flex.message + assert 'tends to zero while M_cr does not' in flex.message + # the same sentence, from the point itself, and only where it is true + points = capacity.profile_for(analysis) + under = [p for p in points if p.under_developed] + assert under and under[0].development_note() + full = [p for p in points + if p.fps_ksi is not None and not p.under_developed] + assert full and full[0].development_note() is None + + +def test_the_shear_ratio_gets_the_same_rule_even_though_it_has_never_fired(): + """V/phi-Vn never reached 0.36 on 184 real pieces. The doctrine is about + what the tool may bless, not about which ratio has fired, so it is checked + the same way -- forced here, because reality will not do it.""" + analysis = _prestressed_analysis() + analysis.diagram.shear_lb = [v * 100000.0 for v in analysis.diagram.shear_lb] + shear = [f for f in checks.check_capacity_ratios(analysis) + if f.code == 'SHEAR-CAP'] + assert len(shear) == 1 and shear[0].severity == 'FAIL' + assert 'CONCRETE ALONE' in shear[0].message + # the development-length sentence belongs to phi-Mn and must not stray + # onto a shear finding, which is a concrete capacity + assert 'development length' not in shear[0].message + + +def test_the_verdict_scans_every_station_not_the_tables_thinned_rows(): + """A verdict that moved with a display stride would be no verdict. The + table keeps about 25 rows of 2000; the check reads all of them.""" + analysis = _prestressed_analysis() + rows, _gov = report.station_indices(analysis, + checks.stress_profile(analysis)) + points = capacity.profile_for(analysis) + assert len(points) == len(analysis.diagram.xs) + assert len(points) > 10 * len(rows) + _i, worst, judged, total = capacity.worst_ratio(points, 'm_over_phi_mn') + assert worst is not None and total == len(points) and judged <= total + + +def test_the_station_a_FAIL_came_from_is_forced_into_the_table(): + """A verdict whose evidence is not on the page is a verdict nobody can + check, so the worst station is added to the table if the thinning dropped + it.""" + analysis = _prestressed_analysis() + analysis.diagram.moment_lbft = [m * 1000.0 + for m in analysis.diagram.moment_lbft] + points = capacity.profile_for(analysis) + index, _worst, _j, _t = capacity.worst_ratio(points, 'm_over_phi_mn') + rows, _gov = report.station_indices(analysis, + checks.stress_profile(analysis)) + forced = report._with_exceeded_stations(rows, points) + assert index in dict(forced) + assert 'worst M/phi-Mn' in dict(forced)[index] + # The rendered table dresses the ASCII tag up -- notate() turns + # "phi-Mn" into real markup; the tag itself is still produced in plain + # ASCII by _with_exceeded_stations, checked above. + assert 'worst M/φMn' in report.station_table(analysis) + + +def test_the_cached_profile_never_outlives_the_diagram_it_was_measured_on(): + """`profile_for` caches on the analysis so the check and the table cannot + disagree. A cache that survived a changed diagram would be the same defect + as a capacity inherited from the neighbouring station.""" + analysis = _prestressed_analysis() + first = capacity.profile_for(analysis) + assert capacity.profile_for(analysis) is first # cached + analysis.diagram.moment_lbft = [m * 1000.0 + for m in analysis.diagram.moment_lbft] + second = capacity.profile_for(analysis) + assert second is not first # and recomputed + assert (max(p.m_over_phi_mn for p in second if p.m_over_phi_mn) > + max(p.m_over_phi_mn for p in first if p.m_over_phi_mn)) + + +def test_the_stress_check_still_has_its_own_verdict(): + """Cracking and flexural capacity are two questions. The ruling added one; + it did not replace the other, and the stress path is untouched.""" + from panellift.model import Rigging + analysis = _prestressed_analysis() + findings = checks.run_all(analysis, Rigging(mode='rolling')) + crack = [f for f in findings if f.code == 'CRACK'] + assert len(crack) == 1 + assert 'psi allowable' in crack[0].message + page = report.station_table(analysis) + assert 'class="gov"' in page # the governing row is still the stress + # one, chosen by f_t/allow + + +# ------------------------------------------- 6. what the table does with it + +def _cells(row): + return [re.sub(r'<[^>]+>', '', c) + for c in re.findall(r']*>(.*?)', row)] + + +def _rows(page): + body = page[page.index(''):page.index('')] + return re.findall(r']*>(.*?)', body) + + +def test_the_columns_follow_the_owners_2026_09_01_reorder(): + """Superseded by the owner's ruling on this table's column order: + verdict ratios right after X, then the moment values, then M_cr, then + the shear values (his list named their ratio but not them -- kept and + placed the same way, value after the ratio it feeds), then the single + phi this table computes, then section and stress, f_se (his own list, + added the same day: "加上" -- add it), the tension cluster together, note + last. No value or its formatting changed -- only where each `` sits + among the others; see the reorder note above `station_table`'s body + loop.""" + page = report.station_table(_analysis()) + head = page[:page.index('')] + order = re.findall(r']*>(.*?)', head) + plain = [re.sub(r'<[^>]+>', '', h) for h in order] + assert plain == [ + 'x, ft', 'M/φMn', 'V/φVn', 'M, kip-ft', 'φMn, kip-ft', + 'Mcr, kip-ft', 'V, kip', 'φVn, kip', 'φ, flexure', + 'S, in³', 'fc, psi', 'fse, ksi', 'ft/allow', + 'ft, psi'.replace('t', 't'), 'allow, psi', 'note', + ] + for row in _rows(page): + assert len(_cells(row)) == 16 + + +def test_a_dashed_capacity_carries_its_reason_into_the_note(): + """No strand on the piece, so phi*M_n is a dash at every station -- and a + reason that holds EVERYWHERE is stated once in the footnote instead of + being repeated down twenty-five note cells.""" + page = report.station_table(_analysis()) # ASPIRE panel: no strand + for row in _rows(page): + assert _cells(row)[4] == '—' # phi*M_n em dash, col 4 + footnote = page[page.index('
      '):] + assert 'is a dash at every station' in footnote + assert 'no prestressing on this piece' in footnote + # ... and it is NOT in every note cell + assert 'no prestressing on this piece' not in page[:page.index('')] + + +def test_fse_reports_the_capacity_points_own_value_unconverted(): + """f_se, added 2026-09-01 (owner: "加上" -- add it), sits after f_c on + his own column list -- col 11 in the reordered header. The hand panel + carries no blockouts, so every strand is bonded at every station and + f_se is the same 176.470588 ksi the hand-calculated fixture already pins + in `test_inside_the_transfer_length_fps_ramps_from_zero`: no + re-derivation here, the table cell has to read straight off + `CapacityPoint.fse_ksi`, in ksi, never converted to psi.""" + analysis = _prestressed_analysis() + page = report.station_table(analysis) + rows = _rows(page) + assert rows + for row in rows: + assert _cells(row)[11] == '176.5' # f_se, col 11, one decimal + + +def test_a_strandless_panel_dashes_fse_with_its_reason(): + """The ASPIRE panel `_analysis()` defaults to has no prestress at all, so + f_se -- like phi*M_n -- dashes at every station, and the reason ('no + prestressing on this piece') holds everywhere and is hoisted into the + footnote rather than repeated down every row.""" + page = report.station_table(_analysis()) # ASPIRE panel: no strand + for row in _rows(page): + assert _cells(row)[11] == '—' # f_se em dash, col 11 + footnote = page[page.index('
        '):] + assert 'fse is a dash at every station' in footnote + assert 'no prestressing on this piece' in footnote + + +def test_a_local_dash_stays_in_the_note_where_it_happened(): + """The strand is undeveloped at the very ends of a real prestressed piece + and developed in the middle, so the reason is local and belongs in the + row -- not hoisted into the footnote.""" + panel = hand_panel() + panel.lifters = make_panel().lifters + analysis = _analysis(panel=panel) + page = report.station_table(analysis) + body = page[:page.index('')] + assert 'strand not developed at this station' in body + + +def test_fifteen_columns_still_fit_on_paper(): + """Paper has no scroll box, so the print block had to shrink the table + when the six capacity columns landed. Losing the right-hand columns off + the edge of a sheet is the same defect as an absent column, minus the + footnote that would have named it.""" + from panellift.model import Rigging + analysis = _analysis() + page = report.build(analysis, Rigging(mode='rolling'), + checks.run_all(analysis, Rigging(mode='rolling')), + recommendation=False) + printed = page[page.index('@media print'):] + printed = printed[:printed.index('')] + assert 'table.stations{break-inside:auto;font-size:9.5px}' in printed + assert 'table.stations th,table.stations td{padding:2px 4px}' in printed + # and the on-screen scroll box -- now a fixed-height two-axis box, since + # the 2026-09-01 sticky-header/sticky-X change, rather than overflow-x + # alone -- is still what absorbs the width + assert '.tablewrap{overflow:auto;max-height:70vh}' in page + + +def test_a_capacity_too_small_to_print_never_reads_as_zero(): + assert report._cap_cell(None) == '—' + assert report._cap_cell(0.004) == '<0.1' + assert report._cap_cell(0.0) == '0.0' + assert report._cap_cell(65.1495) == '65.1' + + +def test_the_footnote_states_the_method_the_loss_and_the_stage_strength(): + panel = hand_panel() + panel.lifters = make_panel().lifters + page = report.station_table(_analysis(panel=panel)) + footnote = page[page.index('
          '):] + for stated in ('20.3.2.3.1', # the f_ps method, by clause + 'strand only', + 'stated assumption, not a calculation', # the loss + 'prestress_loss_pct', + '10%', # this piece's loss + "this stage’s".replace('’', "'"), + 'over 1.0 is a FAIL', # the ruling, not 'informational' + 'FLEX-CAP', + '22.5.5.1', # the shear clause + 'Table 21.2.2', # phi + '25.4.8.1', # development length + 'lambda'.replace('lambda', 'λ')): + assert stated in footnote, stated + + +def test_the_fpu_default_says_it_is_a_default_and_shows_the_audit(): + panel = hand_panel() + panel.lifters = make_panel().lifters + page = report.station_table(_analysis(panel=panel)) + footnote = page[page.index('
            '):] + assert panel.prestress.fpu_source == 'default' + assert 'ASTM\nA416'.replace('\n', ' ') in footnote + assert "own default — no layer stated one" in footnote + assert 'panel.prestress.fpu_ksi' in footnote # the routing key + assert 'grade_ksi' in footnote # where it comes from + # f_pi = 30,000/0.153/1000 = 196.078 ksi -> 0.726 f_pu + assert '196.1' in footnote and '0.726' in footnote + + +def test_a_job_stated_fpu_is_reported_as_stated(): + panel = hand_panel() + panel.lifters = make_panel().lifters + panel.prestress.fpu_ksi = 250.0 + panel.prestress.fpu_source = 'job' + page = report.station_table(_analysis(panel=panel)) + footnote = page[page.index('
              '):] + assert 'as the job file states it' in footnote + assert 'ASTM' not in footnote # the default branch's sentence + assert 'fpu = 250 ksi, as the job file states it' in footnote + + +def test_the_job_file_can_carry_fpu_and_a_bad_one_is_refused(): + from panellift import cli + block = {'strands': [{'y': 0.5, 'z_in': 2.0, 'initial_pull_lb': 30000.0}]} + assert cli.read_prestress(dict(block)).fpu_source == 'default' + assert cli.read_prestress(dict(block)).fpu_ksi == 270.0 + stated = cli.read_prestress(dict(block, fpu_ksi=250)) + assert stated.fpu_ksi == 250.0 and stated.fpu_source == 'job' + with pytest.raises(cli.JobError): + cli.read_prestress(dict(block, fpu_ksi=0)) + + +def test_capacity_is_solved_only_at_the_stations_that_earned_a_row(): + """A batch was measured down to 31 seconds. Solving flexural capacity at + all two thousand stations of every stage would put that back for numbers + nothing renders.""" + analysis = _analysis() + rows, _gov = report.station_indices(analysis, checks.stress_profile(analysis)) + assert len(rows) < len(analysis.diagram.xs) / 10 + points = capacity.capacity_profile(analysis, [i for i, _t in rows]) + assert len(points) == len(rows) + assert [round(p.x, 6) for p in points] == [ + round(analysis.diagram.xs[i], 6) for i, _t in rows] + + +# ---------- 7. 0/0 at a free end is a could-not-judge, not a verdict +# +# The 282-piece sweep of 2026-09-01 reported FLEX-CAP FAIL on 29 pieces, the +# largest FAIL family it had, reading up to 5,853,000%. Every one was the same +# thing: the LAST station of the stage, where the closure sends M to zero and +# strand development sends phi-Mn to zero, and the residue of one divided by +# the residue of the other. +# +# WP213 transportation: M/phi-Mn 4526367% at 21.96 ft +# -- M 0.0 lb-ft against 0.0 lb-ft capacity +# +# The ruling is about REAL exceedances. A station whose arithmetic is +# meaningless is a could-not-judge -- a dash with a reason -- which is the +# discipline the whole package is built on. The tests below pin BOTH halves: +# the 0/0 station stops being judged, and the real-demand-over-vanished- +# capacity station, which is the undeveloped-strand case the owner ruled FAIL +# on, is untouched. + + +def _free_end_analysis(): + """The 0/0 shape, reproduced rather than mocked. + + A 21.9583 ft piece on dunnage -- the geometry of the sweep's WP213. The + station grid's last x lands 3.6e-15 ft short of the length, so at that + station the strand's own bonded run leaves 4.3e-14 in. of embedment and + phi-Mn comes out at 2e-11 lb-ft, while the closure leaves M at 1.6e-7 + lb-ft. Neither number is a fixture constant: they fall out of the same + floating point the real job file's do. + """ + import dataclasses + from panellift.model import Rigging + panel = dataclasses.replace(hand_panel(), length_ft=21.9583) + stage = Stage(name='transportation', multiplier=1.4, fc_psi=HAND_FC, + supports=[4.6112, 17.3471]) + return statics.analyse(panel, stage, Rigging(mode='rolling')) + + +def test_the_free_end_station_really_is_the_0_over_0_shape_the_sweep_hit(): + """The premise of everything below, asserted before it is relied on. + + If this stops reproducing, the tests after it are testing nothing -- so it + pins the residue itself: both numbers vanishingly small, and their + quotient in the hundreds of thousands of percent. + """ + analysis = _free_end_analysis() + points = capacity.profile_for(analysis) + end = points[-1] + demand = abs(analysis.diagram.moment_lbft[-1]) + assert end.phi_mn_lbft is not None # it WAS computed; it is just tiny + assert 0.0 < end.phi_mn_lbft < 1e-6 + assert 0.0 < demand < 1e-3 + assert demand / end.phi_mn_lbft > 1000.0, \ + 'the artifact this guards against no longer reproduces' + + +def test_a_station_where_both_numbers_have_vanished_produces_no_ratio(): + """0/0 is a dash with a reason, and the reason says why -- never a silent + skip, and never a pass.""" + analysis = _free_end_analysis() + end = capacity.profile_for(analysis)[-1] + assert end.m_over_phi_mn is None + why = end.reason(capacity.M_OVER_PHI_MN) + assert why + assert 'both under 1 lb-ft' in why + assert '0/0' in why + assert 'a dash is not a pass' in why + # and the free-end context travels with it, because that is what a reader + # of the dash wants next + assert 'development length of a free end' in why + + +def test_the_0_over_0_station_no_longer_fails_the_piece(): + """The regression itself: this stage reported FLEX-CAP FAIL at several + hundred thousand percent off the last station, and must not now.""" + from panellift.model import Rigging + analysis = _free_end_analysis() + findings = checks.run_all(analysis, Rigging(mode='rolling')) + flex = [f for f in findings if f.code == 'FLEX-CAP'] + assert flex, 'the check went silent instead of judging the other stations' + assert all(f.severity != 'FAIL' for f in flex), [f.message for f in flex] + + +def test_the_finding_says_how_many_stations_went_unjudged_and_why(): + """A station excluded from the verdict without a word on the page is the + silent skip this package exists to prevent.""" + analysis = _free_end_analysis() + flex = [f for f in checks.check_capacity_ratios(analysis) + if f.code == 'FLEX-CAP'][0] + assert 'BOTH vanished' in flex.message + assert 'a dash is not a pass' in flex.message + assert 'still judged, and still fails' in flex.message + + +def test_the_station_table_prints_the_dash_reason_rather_than_a_bare_dash(): + """The ratio column dashes at that station. The note cell has to say why + -- `_capacity_cells` reads the ratio columns' reasons now, not only the + three capacity ones.""" + analysis = _free_end_analysis() + page = report.station_table(analysis) + # notate() dresses "M/phi-Mn" up as real markup in the rendered table. + rows = [r for r in _rows(page) if 'M/φMn: M' in r] + assert rows, 'the 0/0 station dashed with no reason beside it' + assert 'both under 1 lb-ft' in rows[0] + assert 'says nothing about the section' in rows[0] + # and the footnote states the rule once, for the reader who wants it + assert 'Where both numbers have vanished there is no ratio at all' in page + + +def test_real_demand_against_a_nearly_vanished_capacity_is_still_a_FAIL(): + """The over-correction this must not become. + + A station inside a development length with a REAL moment on it is the + undeveloped-strand case the owner ruled FAIL on. It fails the DEMAND half + of the negligible test, so it is judged exactly as before. The panel is + the one the development-length test uses: an opening across the whole + strand pattern, so the worst ratio lands just outside its edge. + """ + panel = hand_panel(blockouts=[Blockout(name='DOOR', x0=10.0, y0=0.0, + length=8.0, width=3.9)]) + panel.lifters = make_panel(blockouts=[]).lifters + analysis = _analysis(panel=panel) + analysis.diagram.moment_lbft = [m * 1000.0 + for m in analysis.diagram.moment_lbft] + points = capacity.profile_for(analysis) + index, worst, _judged, _total = capacity.worst_ratio(points, + 'm_over_phi_mn') + assert worst is not None + demand = abs(analysis.diagram.moment_lbft[index]) + assert demand > capacity.NEGLIGIBLE_MOMENT_LBFT # a REAL moment + assert worst.m_over_phi_mn > capacity.RATIO_FAIL_ABOVE + assert worst.under_developed + flex = [f for f in checks.check_capacity_ratios(analysis) + if f.code == 'FLEX-CAP'][0] + assert flex.severity == 'FAIL' + assert 'development length of a free end' in flex.message + + +def test_a_vanished_capacity_alone_is_not_enough_to_withhold_the_ratio(): + """The guard is an AND, pinned at the function that decides it. + + A capacity below the floor with a real demand on it still yields a ratio + -- that is the FAIL above. Only both together are 0/0. + """ + tiny, real = 1e-9, 5000.0 + floor = capacity.NEGLIGIBLE_MOMENT_LBFT + assert capacity._negligible_ratio_reason(tiny, tiny, floor, 'lb-ft', 'M', + 'phi-Mn') + assert capacity._negligible_ratio_reason(real, tiny, floor, 'lb-ft', 'M', + 'phi-Mn') is None + assert capacity._negligible_ratio_reason(tiny, real, floor, 'lb-ft', 'M', + 'phi-Mn') is None + assert capacity._negligible_ratio_reason(real, real, floor, 'lb-ft', 'M', + 'phi-Mn') is None + # a negative moment is a moment: the sign must not decide this + assert capacity._negligible_ratio_reason(-real, tiny, floor, 'lb-ft', 'M', + 'phi-Mn') is None + # and the floor is exclusive at the boundary, the same convention + # RATIO_FAIL_ABOVE uses: exactly at it is not under it + assert capacity._negligible_ratio_reason(floor, tiny, floor, 'lb-ft', 'M', + 'phi-Mn') is None + + +def test_a_normal_exceedance_is_untouched_by_the_guard(): + """The ordinary over-1.0 FAIL, both numbers far above the floor, is + exactly what it was before the guard existed.""" + from panellift.model import Rigging + analysis = _prestressed_analysis() + analysis.diagram.moment_lbft = [m * 1000.0 + for m in analysis.diagram.moment_lbft] + points = capacity.profile_for(analysis) + _i, worst, _j, _t = capacity.worst_ratio(points, 'm_over_phi_mn') + assert worst.phi_mn_lbft > capacity.NEGLIGIBLE_MOMENT_LBFT + flex = [f for f in checks.run_all(analysis, Rigging(mode='rolling')) + if f.code == 'FLEX-CAP'] + assert len(flex) == 1 and flex[0].severity == 'FAIL' + assert 'may not bless' in flex[0].message + + +def test_the_guard_is_not_a_development_length_exclusion(): + """The distinction the whole fix rests on, stated as a test. + + Most of the stations the guard silences ARE inside a development length -- + and so are the ones it must leave alone. What separates them is the + demand, not the location, and nothing in the deciding function may start + branching on where the station is. + """ + import inspect + source = inspect.getsource(capacity._negligible_ratio_reason) + for word in ('embed', 'free_end', 'under_developed', 'ld_in'): + assert word not in source, ( + '{0!r} in the negligible test: it has become a location rule' + .format(word)) + + +def test_the_shear_ratio_carries_the_same_guard(): + """The defect is in the shape of a demand/capacity ratio, not in flexure. + + It cannot fire on the real shear path -- phi-Vn is concrete alone with d + floored at 0.80h, and the smallest one on the 282-piece sweep is 17,607 lb + over 1,534,019 stations, five orders of magnitude above the floor. That is + measured rather than assumed, so the guard and its reach are pinned + separately. + """ + analysis = _prestressed_analysis() + points = capacity.profile_for(analysis) + assert all(p.reason(capacity.V_OVER_PHI_VN) is None for p in points) + assert all(p.phi_vn_lb is None + or p.phi_vn_lb > capacity.NEGLIGIBLE_SHEAR_LB for p in points) + assert capacity._negligible_ratio_reason( + 1e-9, 1e-9, capacity.NEGLIGIBLE_SHEAR_LB, 'lb', 'V', 'phi-Vn') diff --git a/panellift/tests/test_checks.py b/panellift/tests/test_checks.py new file mode 100644 index 0000000..e141c37 --- /dev/null +++ b/panellift/tests/test_checks.py @@ -0,0 +1,482 @@ +# -*- coding: utf-8 -*- +"""The checks, and that the report they feed actually builds. + +The ASPIRE examples are re-used here as verdict tests rather than number +tests: example 1 must come back OK and the pocketed example 2 must come back +FAIL. A refactor that quietly turns a printed failure into a pass is the +regression worth catching. +""" +import re + +import pytest + +from panellift import checks, pci, report, statics +from panellift.model import Blockout, Lifter, Panel, Prestress, Rigging, Stage, Strand + +from conftest import make_panel, aspire_pockets + + +def _analyse(panel, stage, rigging): + return statics.analyse(panel, stage, rigging, reconcile_weight=False) + + +# ------------------------------------------------------------ verdicts + +def test_solid_panel_passes_stripping(stripping, rolling): + a = _analyse(make_panel(), stripping, rolling) + found = checks.check_cracking(a, stripping) + assert [f.severity for f in found] == ['OK'] + + +def test_pocketed_panel_fails_stripping(stripping, rolling): + """Same panel, five pockets at the pick, and the answer must flip.""" + a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling) + found = checks.check_cracking(a, stripping) + assert found[0].severity == 'FAIL' + assert 'psi' in found[0].message + + +def test_the_governing_station_is_at_a_pocket(stripping, rolling): + """The worst stress must be found at the blockout, not on the grid.""" + panel = make_panel(blockouts=aspire_pockets()) + a = _analyse(panel, stripping, rolling) + found = checks.check_cracking(a, stripping) + edge = panel.blockouts[0] + assert edge.x0 - 0.1 <= found[0].at_ft <= edge.x1 + 0.1 + + +# --------------------------------------------------- prestress loss note + +PULL_LB = 0.153 * 270_000 * 0.75 # 75% pull, 1/2 in. 270k strand + + +def _near_miss_analysis(loss_pct): + """A hand-built Analysis, bypassing the beam solve entirely. + + Station and moment are chosen so the worst corner lands at 95% of the + tensile limit -- squarely inside the WARN band rather than balanced on + its edge -- for a 4 x 20 ft, 6 in. solid panel. loss_pct=None gives a + panel with no prestress at all, at a moment tuned to land in the same + band on bending alone. + """ + panel = Panel(mark='NM', length_ft=20.0, width_ft=4.0, thickness_in=6.0, + fc_psi=5000.0, fci_psi=3500.0) + if loss_pct is not None: + # y and z at the section's own centroid, so neither eccentricity + # term fires and the axial term alone does the work of getting this + # close to the limit. + strand = Strand(y=2.0, z_in=3.0, area_in2=0.153, + initial_pull_lb=PULL_LB) + panel.prestress = Prestress(strands=[strand], loss_pct=loss_pct) + moment_lbft = 10_320.0 + else: + moment_lbft = 8_061.0 + diagram = statics.Diagram(xs=[10.0], shear_lb=[0.0], + moment_lbft=[moment_lbft]) + stage = Stage(name='test', multiplier=1.0, fc_psi=5000.0) + analysis = statics.Analysis(panel=panel, stage=stage, rigging=Rigging(), + rig=None, profile=None, diagram=diagram) + return analysis, stage + + +def test_near_miss_with_prestress_names_the_loss_assumption(): + analysis, stage = _near_miss_analysis(loss_pct=12.5) + found = checks.check_cracking(analysis, stage) + crack = found[0] + assert crack.severity == 'WARN' # verdict is unchanged + assert 'within 10% of the limit' in crack.message + assert '12.5%' in crack.message # read from the config, + assert '8.0%' not in crack.message # not the 8% default + assert 'stated assumption, not a calculated one' in crack.message + assert 'computed over time' in crack.message + + +def test_near_miss_without_prestress_has_no_loss_note(): + """No prestress credited, so there is no loss assumption to question.""" + analysis, stage = _near_miss_analysis(loss_pct=None) + found = checks.check_cracking(analysis, stage) + crack = found[0] + assert crack.severity == 'WARN' + assert 'within 10% of the limit' in crack.message + assert 'loss' not in crack.message + + +# ------------------------------------------------------------- inserts + +def test_insert_check_defactors_the_reaction(solid_panel, stripping, rolling): + """PCI Table 8.3.1 note (a): the handling multiplier is not for inserts. + + Two stages differing only in multiplier must give the same line force. + """ + def line_force(mult): + stage = Stage('s', mult, 4000.0) + a = _analyse(make_panel(), stage, rolling) + found = [f for f in checks.check_insert_capacity(a, rolling) + if f.code == 'INSERT'] + return found[0].message + + assert line_force(1.0) == line_force(1.7) + + +def test_shallow_sling_raises_the_line_force(solid_panel, stripping): + vertical = Rigging(mode='rolling', sling_angle_deg=90.0) + shallow = Rigging(mode='rolling', sling_angle_deg=30.0) + a = _analyse(make_panel(), stripping, vertical) + + v = checks.check_insert_capacity(a, vertical) + s = checks.check_insert_capacity(a, shallow) + assert not any(f.code == 'SLING-ANGLE' for f in v) + assert any(f.code == 'SLING-ANGLE' for f in s) + + +def test_unrated_insert_warns_rather_than_passing(stripping, rolling): + panel = make_panel() + for lifter in panel.lifters: + lifter.capacity_lb = None + a = _analyse(panel, stripping, rolling) + found = checks.check_insert_capacity(a, rolling) + assert found[0].code == 'INSERT-UNRATED' + assert found[0].severity == 'WARN' + + +def test_an_overloaded_insert_fails(stripping, rolling): + panel = make_panel() + for lifter in panel.lifters: + lifter.capacity_lb = 100.0 + a = _analyse(panel, stripping, rolling) + found = checks.check_insert_capacity(a, rolling) + assert any(f.severity == 'FAIL' for f in found) + + +# --------------------------------- owner's ruling, 2026-09-02: decomposition +# +# "panel lift 里面,现在如果有sling angle的话,是直接给tension放大。不要直接给 +# tension放大,把力分解成tension跟shear,并且依次检查lifter的tension和shear +# capacity。举例来说P136 at 18.83 ft: line force 7,609 lb (service 6,589 lb / +# sin 60°) vs 8,000 lb tension (95%),就是tension部分6589/8000,shear部分拆 +# 出来单独算,并且跟shear capacity进行对比。二者都小于1即可,不考虑interaction." +# +# The wrong arithmetic compared the WHOLE amplified line force (7,609 lb) +# to the tension rating and printed 95% -- a near-WARN that isn't real. The +# decomposition the owner asked for compares only the vertical share (the +# service reaction itself, 6,589 lb) to tension, and separately the +# horizontal share (6,589 * cot(60deg) = 3,804 lb) to shear. + +def _p136_analysis(sling_angle_deg=60.0, capacity_lb=8000.0, + shear_capacity_lb=8000.0, service=6589.0): + lifter = Lifter(mark='P136', family='P136', x=18.83, y=0.0, + capacity_lb=capacity_lb, + shear_capacity_lb=shear_capacity_lb) + line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=service) + rig = statics.Rig(lines=[line], points=1, mode='rolling') + stage = Stage('erect', 1.0, 5000.0) + rigging = Rigging(mode='rolling', sling_angle_deg=sling_angle_deg) + analysis = statics.Analysis(panel=make_panel(), stage=stage, + rigging=rigging, rig=rig, profile=None, + diagram=None) + return analysis, rigging + + +def test_owner_worked_example_p136_decomposes_tension_and_shear(): + """The pinned numbers from the ruling: 82% tension (not the old 95% on + the amplified line force), and a shear share of ~3,804 lb checked on + its own against the shear column -- both under 1, no interaction.""" + analysis, rigging = _p136_analysis() + found = [f for f in checks.check_insert_capacity(analysis, rigging) + if f.code == 'INSERT'] + assert found + msg = found[0].message + assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg + assert 'shear 3,804 lb vs 8,000 lb (48%)' in msg + # The old (wrong) arithmetic's 95% verdict does not survive. + assert '95%' not in msg + # 82% and 48% are each under 1 -- the finding must read as OK, not the + # WARN the old single-ratio comparison against the amplified 7,609 lb + # line force gave. + assert found[0].severity == 'OK' + + +def test_vertical_rigging_degrades_the_shear_share_to_zero(): + """theta = 90 (a vertical line, sling_angle_deg's own convention for + 'straight up') must fall back to the old plumb-vertical arithmetic + exactly: cot(90) = 0, so the shear share is zero and only tension is a + real question.""" + analysis, rigging = _p136_analysis(sling_angle_deg=90.0) + found = [f for f in checks.check_insert_capacity(analysis, rigging) + if f.code == 'INSERT'] + assert found + msg = found[0].message + assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg + assert 'shear 0 lb vs 8,000 lb (0%)' in msg + + +def test_a_missing_shear_capacity_still_judges_the_tension_half(): + """Some lifters carry no shear rating. That is a could-not-judge on the + shear half, named inline -- never a silent pass -- and it must not stop + the tension half, which has everything it needs, from judging.""" + analysis, rigging = _p136_analysis(shear_capacity_lb=None) + found = [f for f in checks.check_insert_capacity(analysis, rigging) + if f.code == 'INSERT'] + assert found + msg = found[0].message + assert 'tension 6,589 lb vs 8,000 lb (82%)' in msg + assert 'no shear rating' in msg and 'NOT JUDGED' in msg + codes = [f.code for f in checks.check_insert_capacity(analysis, rigging)] + assert 'INSERT-UNRATED' in codes + + +# --------------------- MC dict-shaped capacities: cli.py resolves, checks.py +# judges what comes out (a number, or None + a reason) +# +# `cli._resolve_capacity` is what turns MC's richer `capacity_lb` / +# `shear_capacity_lb` dicts into the (float-or-None, reason) pair +# `check_insert_capacity` has always consumed -- these exercise the CHECK +# side of that split: a None tension carrying a reason must surface as +# NOT JUDGED naming it, and a real published zero on the other axis (MC's +# LPA4T512 pin: `shear_lb: 0`) must still be judged, not treated as absent. + +def _mc_lifter(tension_reason=None, capacity_lb=8000.0, + shear_capacity_lb=0.0): + """`tension_reason` given makes tension itself unrated (None) with that + reason -- the angle-unresolved shape. Left None, tension carries a real + `capacity_lb` instead, so a shear-only test is not also incidentally an + unrated-tension test.""" + return Lifter(mark='PIN', family='LPA4T512', x=18.83, y=0.0, + capacity_lb=(None if tension_reason else capacity_lb), + capacity_why=tension_reason, + shear_capacity_lb=shear_capacity_lb) + + +def test_an_unresolved_tension_dict_is_not_judged_carrying_its_reason(): + """The angle-unresolved shape -- tension resolves to None upstream in + cli.py, with `capacity_why` carrying the specific "sling angle not + confirmed" sentence rather than the generic "no rating" one. The check + must print that reason on the tension clause, not swallow it.""" + reason = ('M8 publishes LPA4T512 tension by sling angle and the plant ' + 'has not confirmed which column applies') + lifter = _mc_lifter(tension_reason=reason) + line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0) + rig = statics.Rig(lines=[line], points=1, mode='rolling') + stage = Stage('erect', 1.0, 5000.0) + rigging = Rigging(mode='rolling', sling_angle_deg=60.0) + analysis = statics.Analysis(panel=make_panel(), stage=stage, + rigging=rigging, rig=rig, profile=None, + diagram=None) + found = [f for f in checks.check_insert_capacity(analysis, rigging) + if f.code == 'INSERT'] + assert found + msg = found[0].message + assert 'no tension rating' in msg and 'NOT JUDGED' in msg + assert reason in msg + + +def test_a_published_zero_shear_capacity_is_judged_not_skipped(): + """MC's LPA4T512 publishes `shear_lb: 0` -- a real rating, not a + could-not-judge -- so a real shear demand against it must come back a + genuine (very bad) ratio, never an INSERT-UNRATED could-not-judge and + never a ZeroDivisionError crash.""" + lifter = _mc_lifter(shear_capacity_lb=0.0) + line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0) + rig = statics.Rig(lines=[line], points=1, mode='rolling') + stage = Stage('erect', 1.0, 5000.0) + rigging = Rigging(mode='rolling', sling_angle_deg=60.0) # nonzero shear + analysis = statics.Analysis(panel=make_panel(), stage=stage, + rigging=rigging, rig=rig, profile=None, + diagram=None) + found = [f for f in checks.check_insert_capacity(analysis, rigging) + if f.code == 'INSERT'] + assert found + msg = found[0].message + assert 'no shear rating' not in msg # judged, not could-not-judge + assert 'vs 0 lb' in msg + assert found[0].severity == 'FAIL' # any real demand over 0 fails + codes = [f.code for f in checks.check_insert_capacity(analysis, rigging)] + assert 'INSERT-UNRATED' not in codes + + +def test_a_published_zero_shear_capacity_with_no_shear_demand_is_ok(): + """0/0 -- a vertical sling puts no shear share on this axis at all, and + an axis with no capacity that also carries no demand is OK, not a + fabricated FAIL.""" + lifter = _mc_lifter(shear_capacity_lb=0.0) + line = statics.SupportLine(x=18.83, lifters=[lifter], reaction_lb=860.0) + rig = statics.Rig(lines=[line], points=1, mode='rolling') + stage = Stage('erect', 1.0, 5000.0) + rigging = Rigging(mode='rolling', sling_angle_deg=90.0) # cot(90) = 0 + analysis = statics.Analysis(panel=make_panel(), stage=stage, + rigging=rigging, rig=rig, profile=None, + diagram=None) + found = [f for f in checks.check_insert_capacity(analysis, rigging) + if f.code == 'INSERT'] + assert found + assert 'shear 0 lb vs 0 lb (0%)' in found[0].message + + +# -------------------------------------------------------- aspect ratio + +@pytest.mark.parametrize('width,expected', [(4.0, 'OK'), (20.0, 'WARN'), + (28.0, 'FAIL')]) +def test_near_square_panels_are_refused(width, expected): + panel = make_panel() + panel.width_ft = width + assert checks.check_aspect_ratio(panel)[0].severity == expected + + +@pytest.mark.parametrize('width', [4.0, 20.0, 28.0]) +def test_turning_a_piece_does_not_change_whether_it_is_square(width): + """transpose() stands a horizontal panel up by swapping length and width. + + A 26.8 x 11.5 panel is 0.43 either way round. When this check read + width/length it came back 2.33 turned, failed, and reported the piece as + "close to square" -- which is how all 184 horizontal panels in a real + model failed erection and no genuine overstress could be seen behind it. + """ + flat = make_panel() + flat.width_ft = width + stood_up = make_panel() + stood_up.length_ft, stood_up.width_ft = width, flat.length_ft + assert (checks.check_aspect_ratio(flat)[0].severity == + checks.check_aspect_ratio(stood_up)[0].severity) + + +# ------------------------------------------------------------- dunnage + +def _dunnage_stage(supports): + return Stage(name='transportation', multiplier=1.4, fc_psi=5000.0, + pci_case='transportation', supports=supports) + + +def test_support_under_a_big_opening_is_named(rolling): + """One real piece: the plant's fraction-of-span rule put a support at + 5.23 ft on a panel whose opening ran 3.23 to 12.52 ft -- a support line + under a hole. Here an 8 ft wide, full-depth opening removes 80% of a + 10 ft panel's width at x=5, which is well past 'most of the width'.""" + panel = make_panel() + panel.blockouts = [Blockout(name='DOOR', x0=3.0, y0=0.0, + length=4.0, width=8.0)] + stage = _dunnage_stage([5.0, 25.0]) + a = statics.analyse(panel, stage, rolling, reconcile_weight=False) + found = checks.check_dunnage_position(a) + + assert len(found) == 1 # only the 5.0 ft support + f = found[0] + assert f.severity == 'WARN' + assert f.code == 'DUNNAGE-OPENING' + assert f.at_ft == 5.0 + assert '5.00 ft' in f.message + assert 'DOOR' in f.message + assert '3.00 to 7.00 ft' in f.message + assert '80%' in f.message + + +def test_support_clear_of_any_opening_is_not_flagged(rolling): + panel = make_panel() # no blockouts at all + stage = _dunnage_stage([5.0, 25.0]) + a = statics.analyse(panel, stage, rolling, reconcile_weight=False) + assert checks.check_dunnage_position(a) == [] + + +def test_a_small_opening_under_a_support_does_not_trigger(rolling): + """A opening that leaves most of the width intact is a different case + from a support bearing mostly on a hole -- below the fraction this draws + the line at, it is left alone.""" + panel = make_panel() + panel.blockouts = [Blockout(name='SMALL', x0=3.0, y0=0.0, + length=4.0, width=2.5)] # 25% of 10 ft + stage = _dunnage_stage([5.0, 25.0]) + a = statics.analyse(panel, stage, rolling, reconcile_weight=False) + assert checks.check_dunnage_position(a) == [] + + +def test_dunnage_check_does_not_run_on_a_lifted_stage(stripping, rolling): + """supports is None on a normal pick -- nothing to ask voids_at about.""" + a = statics.analyse(make_panel(), stripping, rolling, + reconcile_weight=False) + assert checks.check_dunnage_position(a) == [] + + +def test_run_all_includes_the_dunnage_check_on_a_supported_stage(rolling): + panel = make_panel() + panel.blockouts = [Blockout(name='DOOR', x0=3.0, y0=0.0, + length=4.0, width=8.0)] + stage = _dunnage_stage([5.0, 25.0]) + a = statics.analyse(panel, stage, rolling, reconcile_weight=False) + found = checks.run_all(a, rolling) + assert any(f.code == 'DUNNAGE-OPENING' for f in found) + + +# -------------------------------------------------------------- report + +def test_report_builds_and_is_self_contained(stripping, rolling): + a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling) + found = checks.run_all(a, rolling) + page = report.build(a, rolling, found) + + assert page.count('') == 5 + assert '' in page + # A calc report has to open from disk on a machine with no network. + assert 'http://' not in page and 'https://' not in page + assert 'ASPIRE-1' in page or 'ASPIRE' in page + + +def test_report_states_the_verdict(stripping, rolling): + a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling) + found = checks.run_all(a, rolling) + assert checks.worst_severity(found) == 'FAIL' + assert 'not satisfied' in report.build(a, rolling, found) + + +def test_transportation_report_says_it_was_checked_as_a_lift(stripping, + rolling): + """Transportation sits on dunnage, not the inserts. Until the support + model exists, the report must say which model produced the numbers.""" + strip = _analyse(make_panel(), stripping, rolling) + strip_page = report.build(strip, rolling, + checks.run_all(strip, rolling), + recommendation=False) + assert 'dunnage' not in strip_page + + haul = Stage(name='haul', multiplier=1.4, fc_psi=5000.0, + pci_case='transportation') + haul_a = _analyse(make_panel(), haul, rolling) + haul_page = report.build(haul_a, rolling, + checks.run_all(haul_a, rolling), + recommendation=False) + assert 'dunnage' in haul_page + assert 'checked as a lift' in haul_page + + +def test_transportation_on_dunnage_reports_supports_not_a_pick(rolling): + panel = make_panel() + haul = Stage(name='transportation', multiplier=1.4, fc_psi=5000.0, + pci_case='transportation', + supports=[0.2 * panel.length_ft, 0.8 * panel.length_ft]) + a = _analyse(panel, haul, rolling) + page = report.build(a, rolling, checks.run_all(a, rolling), + recommendation=False) + assert 'dunnage line' in page + assert 'checked as a lift' not in page + assert 'no insert check applies' in page + + +# ------------------------------------------------------ demo-safe: no CJK +# +# Owner, 2026-09-01: no Chinese anywhere in a report or a UI surface, even +# when quoting him -- he is demoing this to colleagues before the weekend. +# The owner's own rulings behind these findings (`capacity.py`'s M/phi-Mn +# ruling, `sandwich.py`'s "not conservative" ruling, both quoted again in +# `checks.py` and `cli.py`) now live in `#` comments only, never in a +# `Finding.message`. This renders the real HTML page -- `report.build`, +# the surface a colleague actually sees -- off a pocketed panel forced +# into a FAIL, so the capacity and cracking findings are both live. +def test_the_rendered_findings_page_carries_no_chinese_text_or_html( + stripping, rolling): + panel = make_panel(blockouts=aspire_pockets()) + a = _analyse(panel, stripping, rolling) + findings = checks.run_all(a, rolling) + assert any(f.severity == 'FAIL' for f in findings) # the FAIL path ran + cjk = re.compile(u'[一-鿿]') + for f in findings: + assert not cjk.search(f.message), f.message + page = report.build(a, rolling, findings, recommendation=False) + assert not cjk.search(page), page diff --git a/panellift/tests/test_cli.py b/panellift/tests/test_cli.py new file mode 100644 index 0000000..98115cd --- /dev/null +++ b/panellift/tests/test_cli.py @@ -0,0 +1,607 @@ +# -*- coding: utf-8 -*- +"""The job file is a contract, so it is tested like one. + +Revit cannot import this package and this package cannot import Revit; the +JSON between them is the only place the two agree. Every way of getting it +wrong that would still produce a plausible-looking report is worth a test, +because a handling report that looks ordinary and is wrong is the failure this +whole package exists to prevent. +""" +import io +import json + +import pytest + +from panellift import checks, cli +from conftest import ASPIRE, LENGTH_FT, THICKNESS_IN, WIDTH_FT +from panellift import pci + + +def aspire_job(**overrides): + """The ASPIRE panel, written the way Revit would write it.""" + left, right = pci.optimal_two_line_supports(LENGTH_FT) + job = { + 'schema': 'panellift/1', + 'panel': { + 'mark': 'ASPIRE-1', + 'length_ft': LENGTH_FT, + 'width_ft': WIDTH_FT, + 'thickness_in': THICKNESS_IN, + 'unit_weight_pcf': 150.0, + 'fci_psi': 4000.0, + 'fc_psi': 5000.0, + 'lifters': [ + {'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left, + 'y': 7.5, 'capacity_lb': 12000.0}, + {'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right, + 'y': 7.5, 'capacity_lb': 12000.0}, + ], + }, + 'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0}, + 'stages': [ + {'name': 'stripping', + 'multiplier': pci.stripping_multiplier('flat', 'smooth'), + 'fc_psi': 4000.0}, + ], + } + job.update(overrides) + return job + + +# --------------------------------------------------------------- it computes + +def test_job_reproduces_the_published_stripping_stress(): + """A job file must reach the same number the fixtures do, or it is a + second implementation rather than a second front door.""" + _panel, _rig, cases = cli.analyse_job(aspire_job()) + summary = cli.summarise(cases) + peak = summary['stages'][0]['peak_tension_psi'] + assert peak == pytest.approx(ASPIRE['strip_stress_psi'], abs=1.5) + + +def test_stage_without_lifters_key_uses_every_insert(): + _panel, _rig, cases = cli.analyse_job(aspire_job()) + assert cases[0][0].rig.points == 4 + + +def test_stage_may_carry_the_piece_on_a_subset(): + """The whole reason stages name their inserts: a crane that cannot reach + the top row makes erection a different pick from stripping.""" + job = aspire_job() + job['stages'][0]['lifters'] = ['L1', 'L2', 'L3'] + _panel, _rig, cases = cli.analyse_job(job) + assert cases[0][0].rig.points == 3 + + +# ------------------------------------------------------------ it refuses well + +def test_unknown_lifter_mark_is_an_error_not_a_smaller_pick(): + """Silently analysing three picks where four were meant is + unconservative and looks entirely normal in the report.""" + job = aspire_job() + job['stages'][0]['lifters'] = ['L1', 'L2', 'L9'] + with pytest.raises(cli.JobError) as err: + cli.analyse_job(job) + assert 'L9' in str(err.value) + + +def test_duplicate_lifter_marks_are_refused(): + job = aspire_job() + job['panel']['lifters'][1]['mark'] = 'L1' + with pytest.raises(cli.JobError): + cli.analyse_job(job) + + +# --------------------------------------------- capacity_lb: the dict shape +# +# `knowledge.lifter_capacity()` (private repo `pyrevit-knowledge`, week of +# 2026-09-01) grew a richer shape for MC hardware: `capacity_lb` and +# `shear_capacity_lb` can now arrive as a dict instead of a bare number, and +# `liftexport.export()` started writing that dict straight through. A real +# MC dry-run (WPA594) hit `read_panel` doing `float({...})` and died with +# `"capacity_lb" is not a number` -- no report, exit 2, before a single +# check ran. These pin the fix: `cli._resolve_capacity` reading both the +# split-number shape and the angle-unresolved stand-down shape, with the +# existing plain-number shape passing through byte-for-byte unchanged. + +def test_a_plain_number_capacity_is_unchanged_the_regression_case(): + """The critical backward-compat: a plain-number deck carries a bare + number and must read identically to before the dict shape ever + existed.""" + job = aspire_job() + job['panel']['lifters'][0]['capacity_lb'] = 12000.0 + job['panel']['lifters'][0]['shear_capacity_lb'] = 9000.0 + panel = cli.read_panel(job['panel']) + l1 = [l for l in panel.lifters if l.mark == 'L1'][0] + assert l1.capacity_lb == 12000.0 + assert l1.shear_capacity_lb == 9000.0 + assert l1.capacity_why is None + assert l1.shear_capacity_why is None + + +def test_a_split_tension_shear_dict_reads_both_capacities(): + """{'tension_lb': N, 'shear_lb': M} -- MC's QL589-shaped rating.""" + job = aspire_job() + job['panel']['lifters'][0]['capacity_lb'] = { + 'tension_lb': 13800, 'shear_lb': 3860} + job['panel']['lifters'][0]['shear_capacity_lb'] = { + 'tension_lb': 13800, 'shear_lb': 3860} + panel = cli.read_panel(job['panel']) + l1 = [l for l in panel.lifters if l.mark == 'L1'][0] + assert l1.capacity_lb == 13800.0 + assert l1.shear_capacity_lb == 3860.0 + # A dict that resolved to a real number carries no could-not-judge + # reason of its own. + assert l1.capacity_why is None + assert l1.shear_capacity_why is None + + +def test_a_zero_capacity_inside_a_dict_is_a_real_rating_not_a_missing_one(): + """MC's LPA4T512 publishes `shear_lb: 0` -- the pin genuinely takes no + shear. That is a judgeable 0.0, not the same fact as "no rating" + (which reads as None): a dict carrying the key at all, even as 0, must + resolve to the number, never fall through to the reason branch.""" + job = aspire_job() + job['panel']['lifters'][0]['shear_capacity_lb'] = { + 'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680, + 'tension_lb_unresolved_because': 'sling angle not confirmed'} + panel = cli.read_panel(job['panel']) + l1 = [l for l in panel.lifters if l.mark == 'L1'][0] + assert l1.shear_capacity_lb == 0.0 + assert l1.shear_capacity_why is None + + +def test_an_angle_unresolved_dict_leaves_tension_none_with_its_reason(): + """The MC LPA4T512 shape: no settled `tension_lb`, only 60/75-degree + columns and a stated reason nobody has confirmed which applies. This + must NOT pick 60 or 75 -- tension resolves to None, carrying the exact + reason string, never a silently chosen number.""" + job = aspire_job() + reason = ('M8 (manufacturer Lifting Anchor Chart published 12.02.22, ' + 'footnote b) publishes LPA4T512 tension by sling angle -- ' + '5,990 lb @ 60 deg, 6,680 lb @ 75 deg -- and asks the plant ' + 'to confirm which column applies. lifting_anchors.' + 'sling_angle_choice is still null: nobody has confirmed it.') + job['panel']['lifters'][0]['capacity_lb'] = { + 'shear_lb': 0, 'tension_60_lb': 5990, 'tension_75_lb': 6680, + 'tension_lb_unresolved_because': reason} + panel = cli.read_panel(job['panel']) + l1 = [l for l in panel.lifters if l.mark == 'L1'][0] + assert l1.capacity_lb is None + assert l1.capacity_why == reason + # Neither 60 nor 75 was silently picked. + assert '5990' not in (l1.capacity_why or '') + assert '6680' not in (l1.capacity_why or '') + + +def test_a_dict_reason_beats_the_generic_top_level_why(): + """The exporter also writes a generic top-level `capacity_why` + ("taken as stated ()") beside the dict. The SPECIFIC + per-axis reason inside the dict is what a reader needs, and must win + over that generic wrapper rather than being shadowed by it.""" + job = aspire_job() + job['panel']['lifters'][0]['capacity_lb'] = { + 'shear_lb': 0, 'tension_60_lb': 5990, + 'tension_lb_unresolved_because': 'sling angle not confirmed'} + job['panel']['lifters'][0]['capacity_why'] = ( + 'TEST_LIFTER is a job- or plant-pinned rating, taken as stated') + panel = cli.read_panel(job['panel']) + l1 = [l for l in panel.lifters if l.mark == 'L1'][0] + assert l1.capacity_why == 'sling angle not confirmed' + + +def test_a_dict_with_no_number_and_no_reason_is_not_judged_not_crashed(): + """A malformed dict -- neither a settled number nor a stated reason for + the axis being read -- must still refuse to guess, but as a named + could-not-judge rather than `float(dict)` raising TypeError.""" + job = aspire_job() + job['panel']['lifters'][0]['capacity_lb'] = {'something_else': 1} + panel = cli.read_panel(job['panel']) + l1 = [l for l in panel.lifters if l.mark == 'L1'][0] + assert l1.capacity_lb is None + assert l1.capacity_why is not None + assert 'NOT JUDGED' in l1.capacity_why + + +def test_a_wpa594_shaped_deck_with_empty_stages_refuses_legibly(): + """The real dry-run failure this whole fix is about: a deck with MC's + dict-shaped capacities and zero stages (no `handling.lifter_groups` + mapping yet). Before the fix this died in `read_panel` on + `float({...})`, exit 2, before the empty-stage guard ever ran. After + it, `read_panel` succeeds and the ordinary empty-stage refusal fires + instead -- a clean JobError, not a crash, and not a report.""" + job = aspire_job() + job['panel']['lifters'][0]['capacity_lb'] = { + 'shear_lb': 0, 'tension_60_lb': 5990, + 'tension_lb_unresolved_because': 'sling angle not confirmed'} + job['stages'] = [] + with pytest.raises(cli.JobError) as err: + cli.analyse_job(job) + assert 'not a number' not in str(err.value) + + +def test_a_wpa594_shaped_deck_with_empty_stages_exits_clean_via_main( + tmp_path, capsys): + job = aspire_job() + job['panel']['lifters'][0]['capacity_lb'] = { + 'shear_lb': 0, 'tension_60_lb': 5990, + 'tension_lb_unresolved_because': 'sling angle not confirmed'} + job['stages'] = [] + job_file = tmp_path / 'job.json' + out = tmp_path / 'report.html' + job_file.write_text(json.dumps(job), encoding='utf-8') + + assert cli.main([str(job_file), '-o', str(out)]) == 2 + assert not out.exists() + captured = capsys.readouterr() + assert 'not a number' not in captured.err + assert 'Traceback' not in captured.err + + +def test_a_job_with_no_stages_checks_nothing_and_says_so(): + job = aspire_job() + job['stages'] = [] + with pytest.raises(cli.JobError): + cli.analyse_job(job) + + +# ------------------------------------------- zero lifters: NOT JUDGED, loudly +# +# `families.lifter_include` matching zero families on the Revit side is +# CLAUDE.md's own named cardinal sin, and it has bitten twice already: the +# shipped defaults matching no real family, and P78 on insulated panels -- +# both times a silent degrade into a report that completed and looked like a +# clean pass. `liftexport.export` now refuses before writing a deck shaped +# like this at all, but a HAND-WRITTEN job file skips the exporter entirely, +# so this package has to refuse on its own too -- defense in depth. See +# `cli._refuse_zero_lifters`. + +def test_zero_lifters_is_refused_not_judged(): + job = aspire_job() + job['panel']['lifters'] = [] + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(job) + assert 'NOT JUDGED' in str(caught.value) + assert 'panel.lifters' in str(caught.value) + + +def test_zero_lifters_names_the_panel_wide_fact_not_one_stages_pick_list(): + """Without the panel-wide guard, the per-stage 'carries the piece on no + inserts' message would fire first -- once statics reached that stage -- + and read as one stage's narrower pick problem rather than the fact that + the piece has no lifters modelled at all.""" + job = aspire_job() + job['panel']['lifters'] = [] + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(job) + assert 'carries the piece on no inserts' not in str(caught.value) + + +def test_zero_lifters_with_an_explicit_empty_stage_list_is_also_refused(): + """The exporter writes an explicit `"lifters": []` on a stage, not an + absent key (see default_stages() on the Revit side) -- the guard must + catch that shape too, not just a stage with no 'lifters' key at all.""" + job = aspire_job() + job['panel']['lifters'] = [] + job['stages'][0]['lifters'] = [] + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(job) + assert 'NOT JUDGED' in str(caught.value) + + +def test_zero_lifters_on_dunnage_only_stages_still_computes(): + """A piece genuinely carried on dunnage for every stage that runs needs + no insert at all -- the guard must not refuse a legitimate dunnage-only + deck just because panel.lifters happens to be empty.""" + job = _supported_job() + job['panel']['lifters'] = [] + _panel, _rig, cases = cli.analyse_job(job) + assert cases[0][0].rig.points == 0 + + +def test_zero_lifters_with_every_lift_stage_disabled_computes_the_rest(): + """A lift stage that is switched off is not going to run, so it does not + need lifters either -- only an ENABLED, non-dunnage stage does.""" + dunnage_stage = _supported_job()['stages'][0] + job = aspire_job() + job['panel']['lifters'] = [] + job['stages'] = [ + dict(job['stages'][0], enabled=False, + disabled_reason='no crane on this site yet'), + dunnage_stage, + ] + _panel, _rig, cases = cli.analyse_job(job) + assert len(cases) == 1 + assert cases[0][0].stage.supports == dunnage_stage['supports'] + + +def test_a_normal_deck_with_lifters_is_unaffected_by_the_guard(): + """The other side of the same rule: a piece that DOES have lifters must + not trip on a guard meant for the piece that has none.""" + _panel, _rig, cases = cli.analyse_job(aspire_job()) + assert len(cases) == 1 + + +def test_main_reports_not_judged_without_a_traceback(tmp_path, capsys): + job = aspire_job() + job['panel']['lifters'] = [] + job_file = tmp_path / 'job.json' + out = tmp_path / 'report.html' + job_file.write_text(json.dumps(job), encoding='utf-8') + + assert cli.main([str(job_file), '-o', str(out)]) == 2 + assert not out.exists() + captured = capsys.readouterr() + assert 'NOT JUDGED' in captured.err + assert 'Traceback' not in captured.err + + +def test_missing_thickness_names_the_field(): + job = aspire_job() + del job['panel']['thickness_in'] + with pytest.raises(cli.JobError) as err: + cli.analyse_job(job) + assert 'thickness_in' in str(err.value) + + +def test_a_future_schema_is_refused_rather_than_guessed_at(): + with pytest.raises(cli.JobError): + cli.analyse_job(aspire_job(schema='panellift/99')) + + +def test_unknown_rigging_mode_is_refused(): + with pytest.raises(cli.JobError): + cli.analyse_job(aspire_job(rigging={'mode': 'magic'})) + + +def test_a_stage_with_neither_multiplier_nor_pci_case_is_refused(): + """Defaulting a missing load factor to 1.0 would produce a report that + looks complete and is 30% light.""" + job = aspire_job() + del job['stages'][0]['multiplier'] + with pytest.raises(cli.JobError) as err: + cli.analyse_job(job) + assert 'pci_case' in str(err.value) + + +# --------------------------------------------------------- Table 8.3.1 lookup + +def test_pci_case_reads_the_table_for_this_panel(): + job = aspire_job() + job['stages'][0]['multiplier'] = None + job['stages'][0]['pci_case'] = 'stripping' + _p, _r, cases = cli.analyse_job(job) + assert cases[0][0].stage.multiplier == pytest.approx(1.3) # flat, smooth + + +def test_pci_case_follows_the_finish(): + job = aspire_job() + job['panel']['product_type'] = 'fluted' + job['stages'][0]['multiplier'] = None + job['stages'][0]['pci_case'] = 'stripping' + _p, _r, cases = cli.analyse_job(job) + assert cases[0][0].stage.multiplier == pytest.approx(1.6) # fluted, smooth + + +def test_a_stated_multiplier_beats_the_table(): + """A plant working to its own number is not overruled by the handbook.""" + job = aspire_job() + job['stages'][0]['multiplier'] = 1.4 + job['stages'][0]['pci_case'] = 'stripping' + _p, _r, cases = cli.analyse_job(job) + assert cases[0][0].stage.multiplier == pytest.approx(1.4) + + +def test_a_product_type_not_in_the_table_is_refused(): + job = aspire_job() + job['panel']['product_type'] = 'corrugated' + job['stages'][0]['multiplier'] = None + job['stages'][0]['pci_case'] = 'stripping' + with pytest.raises(cli.JobError): + cli.analyse_job(job) + + +# ------------------------------------------------------------------ the main + +def test_main_writes_one_file_covering_every_stage(tmp_path): + job = aspire_job() + job['stages'].append({'name': 'transportation', + 'multiplier': pci.TRANSPORTATION_MULTIPLIER, + 'fc_psi': 5000.0}) + job_file = tmp_path / 'job.json' + out = tmp_path / 'report.html' + job_file.write_text(json.dumps(job), encoding='utf-8') + + assert cli.main([str(job_file), '-o', str(out)]) == 0 + html = io.open(str(out), encoding='utf-8').read() + assert html.count('') <= 1 + assert 'stripping' in html and 'transportation' in html + + +def test_main_exits_nonzero_when_a_stage_fails(tmp_path): + """The exit code is what a caller checks when it is not going to read the + HTML -- it has to mean something.""" + job = aspire_job() + job['stages'][0]['fc_psi'] = 500.0 # nothing passes at 500 psi + job_file = tmp_path / 'job.json' + job_file.write_text(json.dumps(job), encoding='utf-8') + assert cli.main([str(job_file), '-o', str(tmp_path / 'r.html')]) == 1 + + +def test_main_rejects_a_bad_job_file_without_writing_a_report(tmp_path): + job_file = tmp_path / 'job.json' + out = tmp_path / 'report.html' + job_file.write_text('{"schema": "panellift/1"}', encoding='utf-8') + assert cli.main([str(job_file), '-o', str(out)]) == 2 + assert not out.exists() + + +# ------------------------------------------------- a stage may rig its own way + +def _peak(analysis): + points = checks.stress_profile(analysis) + return max(abs(p.stress_psi) for p in points) + + +def _three_line_job(**stage_extra): + """Three support lines, where rolling and static actually disagree. + + On two lines the two modes are the same problem: statics alone fixes both + reactions. It takes a third line before stiffness has anything to say. + """ + job = aspire_job() + mid = LENGTH_FT / 2.0 + job['panel']['lifters'].extend([ + {'mark': 'L5', 'family': 'TEST_LIFTER', 'x': mid, 'y': 2.5, + 'capacity_lb': 12000.0}, + {'mark': 'L6', 'family': 'TEST_LIFTER', 'x': mid, 'y': 7.5, + 'capacity_lb': 12000.0}, + ]) + job['stages'][0].update(stage_extra) + return job + + +def test_a_stage_without_a_rig_mode_inherits_the_job(): + _p, _r, cases = cli.analyse_job(_three_line_job()) + _analysis, rigging, _f = cases[0] + assert rigging.mode == 'rolling' + + +def test_a_stage_can_rig_differently_from_the_job(): + """The setting has to reach the mechanics, not just be stored. + + This is the whole point of it: a commercial check of one real piece + stripped on fixed slings and erected on an equalising block, and the + stripping moment came out 14.1 k-ft against 23.8 for one mode everywhere. + Whichever is right for a given plant, the two must not produce the same + answer -- if they do, the setting is decorative. + """ + rolling = cli.analyse_job(_three_line_job(rig_mode='rolling'))[2][0] + static = cli.analyse_job(_three_line_job(rig_mode='static'))[2][0] + assert rolling[1].mode == 'rolling' + assert static[1].mode == 'static' + assert _peak(rolling[0]) != pytest.approx(_peak(static[0]), rel=1e-6) + + +def test_the_job_rigging_is_left_alone_by_a_stage_override(): + """One stage overriding must not re-rig the stages after it.""" + job = _three_line_job() + job['stages'] = [dict(job['stages'][0], name='stripping', + rig_mode='static'), + dict(job['stages'][0], name='erection')] + _p, rigging, cases = cli.analyse_job(job) + assert [c[1].mode for c in cases] == ['static', 'rolling'] + assert rigging.mode == 'rolling' + + +def test_a_misspelt_rig_mode_is_refused_rather_than_inherited(): + """'Static' silently inheriting 'rolling' would change every moment in the + stage and look exactly like a stage that meant to inherit.""" + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(_three_line_job(rig_mode='fixed')) + assert 'rig_mode' in str(caught.value) + + +def test_the_summary_says_which_mode_each_stage_used(): + """A per-stage setting the output does not show is one nobody can check.""" + # The job rigs one way and the stage the other, so reporting the stage's + # mode is the only way to pass: printing job['rigging']['mode'] would say + # 'static' here and nobody reading the report would know the difference. + job = aspire_job(rigging={'mode': 'static', 'sling_angle_deg': 90.0}) + job['stages'][0]['rig_mode'] = 'rolling' + _p, _r, cases = cli.analyse_job(job) + assert cli.summarise(cases)['stages'][0]['rig_mode'] == 'rolling' + + +# ------------------------------------------------------------- supports + +def _supported_job(): + job = aspire_job() + job['stages'][0] = { + 'name': 'transportation', + 'pci_case': 'transportation', + 'multiplier': 1.4, + 'fc_psi': 5000.0, + 'supports': [0.2 * LENGTH_FT, 0.8 * LENGTH_FT], + } + return job + + +def test_a_stage_with_supports_is_solved_without_a_pick(): + """Dunnage is not rigging: no lifters, no sling, no insert findings.""" + _p, _r, cases = cli.analyse_job(_supported_job()) + analysis, rigging, findings = cases[0] + assert analysis.rig.points == 0 + assert analysis.stage.supports == [0.2 * LENGTH_FT, 0.8 * LENGTH_FT] + assert analysis.rig.lines[0].lifters == [] + codes = set(f.code for f in findings) + assert 'INSERT' not in codes and 'INSERT-UNRATED' not in codes + assert 'PLUMB' not in codes + assert 'PICK' not in codes + + +def test_two_symmetrical_supports_share_the_piece_evenly(): + _p, _r, cases = cli.analyse_job(_supported_job()) + total = cases[0][0].profile.total_lb + reactions = [line.reaction_lb for line in cases[0][0].rig.lines] + assert reactions[0] == pytest.approx(total / 2.0, rel=1e-6) + assert reactions[1] == pytest.approx(total / 2.0, rel=1e-6) + + +def test_the_summary_carries_the_support_positions(): + _p, _r, cases = cli.analyse_job(_supported_job()) + summary = cli.summarise(cases) + assert summary['stages'][0]['supports'] == \ + [0.2 * LENGTH_FT, 0.8 * LENGTH_FT] + + +def test_a_stage_cannot_name_both_supports_and_lifters(): + job = _supported_job() + job['stages'][0]['lifters'] = ['L1', 'L2'] + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(job) + assert 'both supports and lifters' in str(caught.value) + + +def test_one_support_line_is_refused(): + job = _supported_job() + job['stages'][0]['supports'] = [0.2 * LENGTH_FT] + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(job) + assert 'at least two supports' in str(caught.value) + + +def test_a_support_outside_the_piece_is_refused(): + job = _supported_job() + job['stages'][0]['supports'] = [0.2 * LENGTH_FT, LENGTH_FT + 1.0] + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(job) + assert 'outside the piece' in str(caught.value) + + +def test_an_empty_lifter_list_does_not_contradict_a_support_line(): + """The Revit exporter writes `lifters: []` on a stage it turned into + supports, and an empty list names no insert. Refusing it made every job + file a plant with dunnage configured could write unreadable -- which no + test caught, because the shape came from the other side of the language + boundary and the tests here built their own.""" + job = aspire_job() + haul = dict(job['stages'][0], name='transportation', lifters=[], + supports=[8.0, 24.0]) + job['stages'] = [job['stages'][0], haul] + _p, _r, cases = cli.analyse_job(job) + assert cases[1][0].stage.supports == [8.0, 24.0] + + +def test_a_support_line_and_a_named_insert_are_still_refused(): + job = aspire_job() + haul = dict(job['stages'][0], name='transportation', lifters=['L1'], + supports=[8.0, 24.0]) + job['stages'] = [haul] + with pytest.raises(cli.JobError) as caught: + cli.analyse_job(job) + assert 'one or the other' in str(caught.value) diff --git a/panellift/tests/test_compression_limit.py b/panellift/tests/test_compression_limit.py new file mode 100644 index 0000000..f2751ac --- /dev/null +++ b/panellift/tests/test_compression_limit.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +"""The compression limit is a job decision too, following the tensile one. + +`compression_limit_ratio` multiplies f'c (f'ci at stripping, since the ratio +reads whatever `Stage.fc_psi` the stage was built with) to give the +compression allowable in psi. Unlike the tensile limit, the owner did not +leave two schools of thought open here -- he closed the question, +2026-09-01: + + "0.6保持可调" ("0.6 stays adjustable") + +0.6 remains the shipped default (ACI 318-19 Table 24.5.3.1, "all other +locations", transfer; Sec. 24.5.4.1, service) and becomes a per-job, +per-stage field exactly like `tension_limit_sqrt_fc`: a job file may state +it, a `stages[]` entry may override it for that stage, and +`compression_limit_source` says which. ACI's higher 0.70*f'ci at member ends +is deliberately NOT implemented -- "先不用实现0.7...别搞这么复杂" -- so +there is one ratio for the whole piece, and nothing here tests a location +split. + +Mirrors `test_tension_limit.py` structurally; see that file's docstring for +the reasoning behind the pattern. +""" +import pytest + +from panellift import checks, cli, layout, report +from panellift.checks import Finding, StressPoint +from panellift.model import COMPRESSION_LIMIT_SOURCES, Panel, Stage +from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT +from panellift import pci + + +# --------------------------------------------------- the model-level ratio + +def test_the_default_ratio_and_source(): + """Byte-identity when nobody sets it -- the same contract + `tension_limit_sqrt_fc` gives at 5.0.""" + stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0) + assert stage.compression_limit_ratio == 0.6 + assert stage.compression_limit_source == 'default' + + +def test_the_ratio_multiplies_the_stages_own_strength(): + """The requirement this whole feature turns on: the ratio multiplies + THIS STAGE'S f'c/f'ci, not the job's f'c or a strength borrowed from + another stage. Two stages with the same ratio and different strengths + (f'ci at stripping vs f'c at yard, say) must scale independently.""" + stripping = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0, + compression_limit_ratio=0.6) + yard = Stage(name='yard', multiplier=1.0, fc_psi=5000.0, + compression_limit_ratio=0.6) + assert stripping.allowable_compression_psi == pytest.approx(1800.0) + assert yard.allowable_compression_psi == pytest.approx(3000.0) + # Same ratio, different psi allowable, because the strength that changed + # was the STAGE's, not the ratio's. + assert stripping.allowable_compression_psi != yard.allowable_compression_psi + + +def test_a_different_ratio_moves_the_same_stages_allowable(): + """The other half of the same requirement, isolating the ratio instead + of the strength: hold f'ci fixed and change only the ratio.""" + base = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0, + compression_limit_ratio=0.6) + relaxed = Stage(name='stripping', multiplier=1.4, fc_psi=3000.0, + compression_limit_ratio=0.7) + assert base.allowable_compression_psi == pytest.approx(1800.0) + assert relaxed.allowable_compression_psi == pytest.approx(2100.0) + + +def test_compression_basis_names_ratio_and_source(): + stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0, + compression_limit_ratio=0.7, compression_limit_source='job') + assert stage.compression_basis == "0.7*f'c, job" + + +def test_the_basis_string_is_ascii(): + """Findings are printed to whatever console launched the run -- no + character here may be outside cp1252.""" + stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0) + stage.compression_basis.encode('ascii') + + +def test_an_unrecognised_source_word_prints_stated_not_a_guess(): + stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0, + compression_limit_source='typo') + assert stage.compression_limit_source_word == 'stated' + + +# -------------------------------------------------- the check actually uses it + +def _points(compression_psi, prestress_lb=1000.0): + return [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0, + stress_psi=0.0, compression_psi=compression_psi, + prestress_lb=prestress_lb)] + + +def test_no_prestress_means_no_compression_finding(): + """Compression only becomes a question once there is prestress -- see + `check_compression`'s own docstring. Unaffected by this field.""" + stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0) + points = [StressPoint(x=0.0, moment_lbft=0.0, s_in3=1.0, stress_psi=0.0, + compression_psi=500.0, prestress_lb=0.0)] + assert checks.check_compression(points, stage) == [] + + +def test_the_ratio_decides_the_verdict(): + """0.6*4000 = 2400 psi allowable. 2000 psi passes comfortably; 2500 + fails; the stage's OWN ratio is what makes the difference, not a + hard-coded 0.6 inside the check.""" + stage_default = Stage(name='s', multiplier=1.0, fc_psi=4000.0) + assert checks.check_compression(_points(2000.0), stage_default)[0] \ + .severity == 'OK' + assert checks.check_compression(_points(2500.0), stage_default)[0] \ + .severity == 'FAIL' + # The same 2500 psi passes once the job relaxes the ratio: proof the + # number that moved the verdict was the ratio, not the stress. + stage_relaxed = Stage(name='s', multiplier=1.0, fc_psi=4000.0, + compression_limit_ratio=0.7, + compression_limit_source='job') + assert checks.check_compression(_points(2500.0), stage_relaxed)[0] \ + .severity == 'OK' + + +def test_every_compression_finding_states_the_basis(): + """CRUSH is the check the ratio decides, and its line has to carry the + ratio and who chose it beside the psi -- exactly as CRACK does for + tension.""" + stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0, + compression_limit_ratio=0.7, compression_limit_source='job') + found = checks.check_compression(_points(2500.0), stage) + assert found + assert "0.7*f'c, job" in found[0].message + + +# ------------------------------------------------------- the deck round trip + +def _panel(): + return Panel(mark='p', length_ft=1.0, width_ft=1.0, thickness_in=1.0) + + +def test_absent_from_the_deck_is_the_default(): + """A job file written before this field existed leaves both keys out, + and that has to read exactly as it always has: 0.6, default.""" + stage, _marks = cli.read_stage( + {'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0}, 0, _panel()) + assert stage.compression_limit_ratio == 0.6 + assert stage.compression_limit_source == 'default' + + +def test_a_stated_ratio_is_read_as_job(): + stage, _marks = cli.read_stage( + {'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0, + 'compression_limit_ratio': 0.7}, 0, _panel()) + assert stage.compression_limit_ratio == 0.7 + assert stage.compression_limit_source == 'job' + + +def test_the_exporter_can_say_a_stage_overrode_the_job(): + stage, _marks = cli.read_stage( + {'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0, + 'compression_limit_ratio': 0.7, + 'compression_limit_source': 'stage'}, 0, _panel()) + assert stage.compression_limit_source == 'stage' + + +def test_an_unknown_provenance_word_is_refused_not_displayed(): + with pytest.raises(cli.JobError) as exc: + cli.read_stage( + {'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0, + 'compression_limit_ratio': 0.7, + 'compression_limit_source': 'jobb'}, 0, _panel()) + assert 'compression_limit_source' in str(exc.value) + + +def test_a_non_numeric_ratio_is_refused(): + """Mirrors `tension_limit_sqrt_fc`'s own refusal shape: garbage is + refused loudly, never silently coerced to the 0.6 default.""" + with pytest.raises(cli.JobError) as exc: + cli.read_stage( + {'name': 's', 'multiplier': 1.0, 'fc_psi': 4000.0, + 'compression_limit_ratio': 'static_lift'}, 0, _panel()) + assert 'compression_limit_ratio' in str(exc.value) + + +def test_the_provenance_words_are_the_ones_the_model_knows(): + assert set(COMPRESSION_LIMIT_SOURCES) == set(['default', 'job', 'stage']) + + +# ------------------------------------------------ end to end, with a real pick + +def _job(compression_limit_ratio=None, compression_limit_source=None): + """The ASPIRE panel, prestressed at mid-depth (e=0) so the only thing + the strands add is uniform P/A compression -- enough to exercise + `check_compression` without pulling in the eccentricity mechanics + `test_prestress.py` already covers on its own piece.""" + left, right = pci.optimal_two_line_supports(LENGTH_FT) + pull = 0.153 * 270_000.0 * 0.75 + mid_z = THICKNESS_IN / 2.0 + stage = {'name': 'stripping', + 'multiplier': pci.stripping_multiplier('flat', 'smooth'), + 'fc_psi': 4000.0} + if compression_limit_ratio is not None: + stage['compression_limit_ratio'] = compression_limit_ratio + if compression_limit_source is not None: + stage['compression_limit_source'] = compression_limit_source + return { + 'schema': 'panellift/1', + 'panel': { + 'mark': 'ASPIRE-1', + 'length_ft': LENGTH_FT, + 'width_ft': WIDTH_FT, + 'thickness_in': THICKNESS_IN, + 'unit_weight_pcf': 150.0, + 'fci_psi': 4000.0, + 'fc_psi': 5000.0, + 'lifters': [ + {'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left, + 'y': 7.5, 'capacity_lb': 12000.0}, + {'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right, + 'y': 7.5, 'capacity_lb': 12000.0}, + ], + 'prestress': { + 'strands': [ + {'y': 2.5, 'z_in': mid_z, 'initial_pull_lb': pull}, + {'y': 7.5, 'z_in': mid_z, 'initial_pull_lb': pull}, + ], + 'loss_pct': 8.0, + }, + }, + 'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0}, + 'stages': [stage], + } + + +def _analysed(**kw): + _panel, _rigging, cases = cli.analyse_job(_job(**kw)) + return cases[0] + + +def test_the_stage_carries_the_ratio_through_analyse_job(): + stage = _analysed(compression_limit_ratio=0.7, + compression_limit_source='job')[0].stage + assert stage.compression_limit_ratio == 0.7 + assert stage.compression_limit_source == 'job' + assert stage.allowable_compression_psi == pytest.approx(0.7 * 4000.0) + + +def test_a_real_pick_produces_a_named_crush_finding(): + """End to end: a prestressed piece really does report compression, and + the line names the ratio and who chose it -- the report-side + requirement, exercised through the actual arithmetic rather than a + synthetic StressPoint.""" + findings = _analysed(compression_limit_ratio=0.6, + compression_limit_source='default')[2] + crush = [f for f in findings if f.code == 'CRUSH'] + assert crush + assert "0.6*f'c, default" in crush[0].message + + +def test_the_default_note_cites_aci_and_the_conservative_choice(): + """The report's note beside the compression allowable has to name the + source -- and when that source is the untouched default, cite ACI and + say plainly that the flat ratio (vs. ACI's 0.70 at member ends) is a + deliberate, conservative simplification.""" + page = report.build_many( + [_analysed(compression_limit_ratio=0.6, + compression_limit_source='default')], + recommendation=layout.not_searched()) + assert '24.5.3.1' in page or '24.5.4.1' in page + assert '0.70' in page + assert 'ACI' in page + + +def test_a_job_set_ratio_does_not_get_the_citation(): + """The citation belongs to the number nobody set. A job that chose its + own ratio does not need ACI quoted back at it.""" + page = report.build_many( + [_analysed(compression_limit_ratio=0.7, + compression_limit_source='job')], + recommendation=layout.not_searched()) + assert 'Compression allowable:' not in page diff --git a/panellift/tests/test_derating.py b/panellift/tests/test_derating.py new file mode 100644 index 0000000..31054b3 --- /dev/null +++ b/panellift/tests/test_derating.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""An insert is worth less at release than at 28 days. + +A catalogue rating is derived at one concrete strength and the anchor pulls a +cone of concrete out, so below that strength the capacity follows sqrt(f'c). +One handling report checks the same insert at stripping AND at erection, which +is why the rating cannot be derated once on the way in: it has to be done per +stage, at the strength that stage actually has. +""" +import dataclasses + +import pytest + +from conftest import make_panel +from panellift import checks, pci, statics +from panellift.model import Stage + + +def rated_panel(basis=3500.0, capacity=8000.0): + panel = make_panel() + return dataclasses.replace(panel, lifters=[ + dataclasses.replace(l, capacity_lb=capacity, basis_fc_psi=basis) + for l in panel.lifters]) + + +# ------------------------------------------------------------- the factor + +def test_below_the_basis_it_follows_the_square_root(): + assert pci.insert_derate(3500.0, 2500.0) == pytest.approx(0.8452, abs=1e-4) + + +def test_at_or_above_the_basis_nothing_is_gained(): + """Past the basis the anchor's own steel governs; stronger concrete around + it does not make the anchor stronger.""" + assert pci.insert_derate(3500.0, 3500.0) == 1.0 + assert pci.insert_derate(3500.0, 6000.0) == 1.0 + + +def test_an_unstated_basis_does_not_silently_reduce_a_rating(): + assert pci.insert_derate(None, 2500.0) == 1.0 + assert pci.insert_derate(3500.0, None) == 1.0 + + +# --------------------------------------------------------- through a check + +def test_the_same_insert_is_rated_differently_at_two_stages(): + panel = rated_panel() + lifter = panel.lifters[0] + at_release = Stage('stripping', 1.3, 2500.0) + at_28 = Stage('erection', 1.2, 6000.0) + assert checks.rated(lifter, at_release) == pytest.approx(6761, abs=1) + assert checks.rated(lifter, at_28) == pytest.approx(8000, abs=1) + + +def test_the_capacity_check_uses_the_derated_number(rolling): + panel = rated_panel() + stage = Stage('stripping', 1.3, 2500.0) + a = statics.analyse(panel, stage, rolling, role='strip') + inserts = [f for f in checks.check_insert_capacity(a, rolling) + if f.code == 'INSERT'] + assert inserts + assert 'derated from 8,000 lb' in inserts[0].message + + +def test_no_basis_means_the_rating_is_taken_as_stated(rolling): + """A plant that types a number into capacity_lb meant that number.""" + panel = rated_panel(basis=None) + stage = Stage('stripping', 1.3, 2500.0) + a = statics.analyse(panel, stage, rolling, role='strip') + inserts = [f for f in checks.check_insert_capacity(a, rolling) + if f.code == 'INSERT'] + assert inserts and 'derated' not in inserts[0].message diff --git a/panellift/tests/test_detailed_shear.py b/panellift/tests/test_detailed_shear.py new file mode 100644 index 0000000..0344f45 --- /dev/null +++ b/panellift/tests/test_detailed_shear.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +"""The detailed Vci/Vcw shear method: `capacity.detailed_shear_at`. + +Parity gap #6 -- our simplified V_c = 2*sqrt(f'c)*bw*d does not reproduce the +reference tool's (Eriksson Wall) PCI detailed min(Vci, Vcw), which left the +handling shear utilisation low by -35% (MWV1.0) to -54% (WPB3.0) on the parity +sweep. The formulas are `docs/eriksson-wall-algorithms.md` sec.3, transcribed +from that tool's QC document (QC p.37 prestressed, QC p.76 non-prestressed). + +Every number below is HAND COMPUTED, the arithmetic written into the comment +beside it, on a rectangular prestressed section chosen so the closed forms are +exact -- the same discipline `test_capacity.py` uses: + + b = 48 in. t = 8 in. L = 32 ft f'c = 4000 psi w_c = 150 pcf + 4 strands, 0.153 in.^2 each, all at z = 2.0 in. above the form face + jacked at 30,000 lb each, 10% loss, l_t stated at 30 in. + +f'c = 4000 puts sqrt(f'c) = 63.24555..., w_c = 150 puts lambda = 1.0, and a +mid-span station (x = 16 ft, no blockout) develops every strand fully, so: + + Pi = 4 * 30000 * (1 - 0.10) = 108,000 lb + A = 48 * 8 = 384 in^2 + fpc = Pi/A = 281.25 psi (centroid, Vcw) + dv = 0.8 * 8 = 6.40 in (ACI 22.5.8.3.1) + b*d = 48 * 6.40 = 307.2 in^2 + I = 48 * 8^3 / 12 = 2048 in^4 + St = I / (t/2) = 2048 / 4 = 512 in^3 + e = z_strand - t/2 = 2.0 - 4.0 = -2.0 in (low strand) + fpe = Pi/A + Pi*|e|*(t/2)/I (compression at + = 281.25 + 108000*2.0*4/2048 = 703.125 psi the sagging tension + fibre, for Mcre) + Mcre = St*(6*sqrt(f'c) + fpe) (PCI 5-26) + = 512*(6*63.24555 + 703.125) = 554,290.4 lb-in + = 46,190.86 lb-ft + Vcw = (3.5*sqrt(f'c) + 0.3*fpc)*b*d (PCI 5-28) + = (3.5*63.24555 + 0.3*281.25)*307.2 + = (221.3594 + 84.375)*307.2 = 93,921.62 lb + +The pins are WRITTEN-DOWN goldens, not recomputations. A test that rebuilds +both sides agrees with any refactor, including a wrong one -- PROJECT.md 3.7. +""" +import math + +from panellift import capacity, pci +from panellift.model import Blockout, Panel, Prestress, Stage, Strand + + +SQRT_4000 = math.sqrt(4000.0) # 63.245553... +MID_X = 16.0 # mid-span: every strand fully developed + + +def _pre_panel(blockouts=None): + strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153, + initial_pull_lb=30000.0) + for i in range(4)] + return Panel( + mark='HS-PRE', length_ft=32.0, width_ft=48.0 / 12.0, thickness_in=8.0, + unit_weight_pcf=150.0, fci_psi=4000.0, fc_psi=4000.0, + blockouts=list(blockouts or []), + prestress=Prestress(strands=strands, loss_pct=10.0, diameter_in=0.5, + transfer_length_in=30.0)) + + +def _stage(): + return Stage(name='strip', multiplier=1.4, fc_psi=4000.0) + + +# --------------------------------------------------------- the shared terms + +def test_section_terms_match_the_hand_calc(): + d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0, + 8000.0) + assert d.prestressed is True + assert abs(d.dv_in - 6.40) < 1e-9 # 0.8 * 8 + assert abs(d.bd_in2 - 307.2) < 1e-9 # 48 * 6.40 + assert abs(d.fpc_psi - 281.25) < 1e-6 # Pi/A = 108000/384 + assert abs(d.fpe_psi - 703.125) < 1e-6 # 281.25 + 108000*2*4/2048 + assert abs(d.mcre_lbft - 46190.8616) < 1e-2 # St*(6 sqrt f'c + fpe)/12 + assert abs(d.vcw_lb - 93921.6188) < 1e-2 # (3.5 sqrt f'c + 0.3 fpc)bd + + +# --------------------------------------------------------- Vci.1 governs + +def test_vci_flexure_shear_term_governs_above_the_floor(): + """M = 10,000 lb-ft, V = 8,000 lb -- Vci.1 clears the 1.7 floor. + + Vci.1 = 0.6*sqrt(f'c)*b*d + Vu*Mcre/Mmax (PCI 5-25) + = 0.6*63.24555*307.2 + 8000*46190.86/10000 + = 11,657.36 + 36,952.69 = 48,610.11 lb + Vci.2 = 1.7*sqrt(f'c)*b*d = 1.7*63.24555*307.2 = 33,029.36 lb [floor] + Vci = max(48,610.11, 33,029.36) = 48,610.11 lb + Vcw = 93,921.62 lb ; Vc = min = 48,610.11 lb + phiVn = 0.75 * 48,610.11 = 36,457.58 lb + util = 8000 / 36,457.58 = 0.219433 + """ + d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0, + 8000.0) + assert d.governed_by == 'Vci' + assert abs(d.vci_lb - 48610.1097) < 1e-2 + assert abs(d.vc_lb - 48610.1097) < 1e-2 + assert abs(d.phi_vn_lb - 36457.5822) < 1e-2 + assert abs(d.v_over_phi_vn - 0.2194331) < 1e-6 + + +# --------------------------------------------------------- the 1.7 floor + +def test_vci_floor_governs_when_the_flexure_shear_term_is_small(): + """M = 20,000 lb-ft, V = 8,000 lb -- Vci.1 falls under the floor. + + Vci.1 = 11,657.36 + 8000*46190.86/20000 + = 11,657.36 + 18,476.34 = 30,133.70 lb + Vci.2 = 33,029.36 lb [floor wins] + Vci = max(30,133.70, 33,029.36) = 33,029.36 lb + Vc = min(33,029.36, 93,921.62) = 33,029.36 lb + phiVn = 0.75 * 33,029.36 = 24,772.02 lb + util = 8000 / 24,772.02 = 0.322945 + + This is the 1.7*sqrt(f'c)*bw*d FLOOR (PCI 5-27), a max and not a cap -- the + correction #4 in docs/eriksson-wall-algorithms.md. It is exactly this floor + that lands MWV1.0 on Eriksson's 0.19 in the parity harness (the reduced + end section where Vci.1 sits below it); dropping the floor overshoots. + """ + d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 20000.0, + 8000.0) + assert d.governed_by == 'Vci' + assert abs(d.vci_lb - 33029.3577) < 1e-2 # the floor, 1.7 sqrt f'c bd + assert abs(d.vc_lb - 33029.3577) < 1e-2 + assert abs(d.v_over_phi_vn - 0.3229450) < 1e-6 + + +# ---------------------------------------------- zero moment -> Vcw governs + +def test_zero_moment_station_sends_vci_to_infinity_and_vcw_governs(): + """M -> 0 : Vci.1 -> +inf, so Vci = +inf and Vc = Vcw (docs sec.3.1). + + This is the reference tool's printed "inf" in the Vci column at a + zero-moment station (QC p.15/p.18). Vcw is independent of the moment: + + Vc = Vcw = 93,921.62 lb + phiVn = 0.75 * 93,921.62 = 70,441.21 lb + util = 8000 / 70,441.21 = 0.113570 + """ + d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 0.0, 8000.0) + assert d.vci_lb == float('inf') + assert d.governed_by == 'Vcw' + assert abs(d.vc_lb - 93921.6188) < 1e-2 + assert abs(d.phi_vn_lb - 70441.2141) < 1e-2 + assert abs(d.v_over_phi_vn - 0.1135699) < 1e-6 + + +def test_a_moment_just_under_one_lbft_is_treated_as_zero(): + """The Mmax->0 guard fires at the same 1 lb-ft floor the ratios use.""" + d = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 0.5, 8000.0) + assert d.vci_lb == float('inf') + assert d.governed_by == 'Vcw' + + +# --------------------------------------------------- non-prestressed path + +def test_non_prestressed_uses_the_aci_max_not_a_cap(): + """ACI 318-14 22.5.6.1, phi*Vn = 0.75*max(Vc, Vc.min) -- QC p.76. + + Same rectangular section, no strand, no axial (Nu = 0): + + Vc = 2*lambda*(1 + 0)*sqrt(f'c)*bw*d + = 2*63.24555*307.2 = 38,858.07 lb + Vc.min = (4/3)*lambda*sqrt(f'c)*Ag + = 1.33333*63.24555*384 = 32,381.72 lb + Vc = max(38,858.07, 32,381.72) = 38,858.07 lb (Vc governs) + phiVn = 0.75 * 38,858.07 = 29,143.55 lb + + It is a MAX, not a cap (docs correction #5, a genuine surprise). With + dv = 0.8h the flexure term always clears the (4/3)Ag floor for a solid + rectangle at Nu = 0, so Vc governs here; the branch is still a `max`, which + is asserted directly below so a future edit to a `min`/cap is caught. + """ + panel = Panel(mark='HS-NP', length_ft=32.0, width_ft=48.0 / 12.0, + thickness_in=8.0, unit_weight_pcf=150.0, + fci_psi=4000.0, fc_psi=4000.0) + d = capacity.detailed_shear_at(panel, _stage(), MID_X, 5000.0, 8000.0) + assert d.prestressed is False + assert d.governed_by == 'Vc' + vc_flex = 2.0 * SQRT_4000 * 0.8 * 384.0 # 38,858.07 + vc_min = (4.0 / 3.0) * SQRT_4000 * 384.0 # 32,381.72 + assert abs(d.vc_lb - vc_flex) < 1e-2 + assert abs(d.vc_lb - max(vc_flex, vc_min)) < 1e-9 # a MAX, never a cap + assert d.vc_lb > vc_min # the floor did not win + assert abs(d.phi_vn_lb - 29143.5509) < 1e-2 + + +# -------------------------------------------------- additive: nothing moved + +def test_simplified_shear_capacity_is_untouched(): + """`capacity_at` and its simplified V_c are what Panel Lift Check reads; + the detailed path must not change them. + + V_c(simplified) = 2*lambda*sqrt(f'c)*bw*d, d = max(dp, 0.8h) + Here dp(sagging) = t - z = 6.0 in, floored at 0.8h = 6.4 in, so d = 6.4: + V_c = 2*63.24555*48*6.4 = 38,858.07 lb + phiVn = 0.75 * 38,858.07 = 29,143.55 lb + """ + cap = capacity.capacity_at(_pre_panel(), _stage(), MID_X, 10000.0, 8000.0) + assert abs(cap.phi_vn_lb - 29143.5509) < 1e-2 + assert abs(cap.d_shear_in - 6.4) < 1e-9 # 0.8h floor, dp = 6.0 < 6.4 + + +def test_detailed_and_simplified_disagree_by_design(): + """The whole point of the gap: the two methods give different V_c, and the + detailed one is what matches Eriksson. At this station Vci (48,610) beats + the simplified V_c (38,858), so the detailed util is LOWER here -- but at a + reduced section where the 1.7 floor bites (a blockout), the detailed util + runs HIGHER, which is what closes MWV1.0's -35%.""" + stage = _stage() + det = capacity.detailed_shear_at(_pre_panel(), stage, MID_X, 10000.0, 8000.0) + old = capacity.capacity_at(_pre_panel(), stage, MID_X, 10000.0, 8000.0) + assert det.phi_vn_lb != old.phi_vn_lb + + +# ------------------------------------------ prestress reduced at a blockout + +def test_undeveloped_strand_lowers_fpc_and_fpe(): + """A blockout that cuts strands drops Pi at the station, so fpc and fpe + fall with it -- the detailed shear reads the SAME developed prestress the + stress and flexure paths do (prestress.state_at), never the full jacking + force. A full-depth blockout over the whole width from x=10 to x=14 cuts + every strand; just past it at x=15 the strands are still redeveloping.""" + blocked = _pre_panel(blockouts=[Blockout( + name='O', x0=10.0, y0=0.0, length=4.0, width=48.0 / 12.0)]) + near = capacity.detailed_shear_at(blocked, _stage(), 15.0, 10000.0, 8000.0) + far = capacity.detailed_shear_at(_pre_panel(), _stage(), MID_X, 10000.0, + 8000.0) + assert near.fpc_psi < far.fpc_psi # less strand developed here + assert near.vcw_lb < far.vcw_lb # so Vcw is smaller too diff --git a/panellift/tests/test_developed_at_opening.py b/panellift/tests/test_developed_at_opening.py new file mode 100644 index 0000000..3f25f78 --- /dev/null +++ b/panellift/tests/test_developed_at_opening.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""The reference tool's (Eriksson Wall) §1.3 developed-strand-at-opening rule: +a strand a blockout SAW-CUT re-develops over the DEVELOPMENT length from that +edge, while a strand's transfer from the PIECE END is unchanged. + +docs/eriksson-wall-algorithms.md sec.1.3 / sec.9. ADDITIVE and OPT-IN: it fires +only when a caller passes `ld_ft` (the handling STRESS path, +`capacity.handling_stress_at`). `prestress.state_at` with no `ld_ft` -- which is +what Panel Lift Check's `checks.stress_profile` uses -- keeps the physically +correct transfer-length force model, so a clean solid panel is byte-for-byte +what it was (validated against Eriksson to ~1%). +""" +import pytest + +from panellift import capacity, prestress +from panellift.model import Blockout, Panel, Prestress, Stage, Strand + + +def _panel_with_one_opening(): + """20 ft panel, a full-depth opening x = 8..12 across the strand's y band. + + The single strand at y = 2 is cut by it, so it has two bonded runs, + [0, 8] and [12, 20]. l_t = 2 ft (stated), and the tests pass l_d = 5 ft. + """ + strand = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0) + pre = Prestress(strands=[strand], loss_pct=0.0, diameter_in=0.5, + fpu_ksi=270.0, transfer_length_in=24.0) # l_t = 2 ft + panel = Panel(mark='O', length_ft=20.0, width_ft=4.0, thickness_in=8.0, + unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0, + prestress=pre) + panel.blockouts = [Blockout(name='O1', x0=8.0, y0=0.0, length=4.0, + width=4.0)] + return panel, strand + + +def test_developed_uses_development_length_from_an_opening_edge(): + """At x = 6, 2 ft short of the opening edge at x = 8, over l_d = 5: + fraction = (8 - 6)/5 = 0.4 -- against 1.0 with transfer only. + The piece-end side (x = 6, l_t = 2) is already full, so the opening edge + governs. + """ + panel, strand = _panel_with_one_opening() + # transfer only: (8-6)/2 = 1.0 on the opening side -> full + assert prestress.developed(strand, panel, 6.0, lt_ft=2.0) == pytest.approx(1.0) + # development length from the opening edge: (8-6)/5 = 0.4 + assert prestress.developed(strand, panel, 6.0, lt_ft=2.0, + ld_ft=5.0) == pytest.approx(0.4) + + +def test_developed_keeps_transfer_length_from_the_piece_end(): + """At x = 1, near the piece end (x = 0), the transfer length still governs + even when l_d is supplied: (1 - 0)/2 = 0.5, the same both ways. Releasing + at the bed transfers f_se over l_t; only a saw-cut rebuilds over l_d.""" + panel, strand = _panel_with_one_opening() + assert prestress.developed(strand, panel, 1.0, lt_ft=2.0) == pytest.approx(0.5) + assert prestress.developed(strand, panel, 1.0, lt_ft=2.0, + ld_ft=5.0) == pytest.approx(0.5) + + +def test_developed_on_the_far_side_of_the_opening(): + """In the second run [12, 20], the near edge x = 12 is an OPENING edge, so + at x = 14: (14 - 12)/5 = 0.4 with l_d, 1.0 with transfer only.""" + panel, strand = _panel_with_one_opening() + assert prestress.developed(strand, panel, 14.0, lt_ft=2.0) == pytest.approx(1.0) + assert prestress.developed(strand, panel, 14.0, lt_ft=2.0, + ld_ft=5.0) == pytest.approx(0.4) + + +def test_state_at_ld_reduces_pi_near_an_opening(): + """`state_at(..., ld_ft=5)` credits less prestress force approaching an + opening than the transfer-only default. Pi = 30000 * developed.""" + panel, _ = _panel_with_one_opening() + s_transfer = prestress.state_at(panel, 6.0) + s_dev = prestress.state_at(panel, 6.0, ld_ft=5.0) + assert s_transfer.force_lb == pytest.approx(30000.0) # full + assert s_dev.force_lb == pytest.approx(30000.0 * 0.4) # 12000 + + +def test_clean_panel_is_untouched_by_the_ld_rule(): + """A panel with NO openings has only piece-end boundaries, so passing l_d + changes nothing -- the WPA2.0 clean-panel match is preserved. Checked at + several stations, near end and midspan alike.""" + strand = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0) + pre = Prestress(strands=[strand], loss_pct=0.0, diameter_in=0.5, + fpu_ksi=270.0, transfer_length_in=24.0) + panel = Panel(mark='C', length_ft=20.0, width_ft=4.0, thickness_in=8.0, + unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0, + prestress=pre) # no blockouts + for x in (0.5, 1.5, 5.0, 10.0, 18.5, 19.5): + a = prestress.state_at(panel, x) + b = prestress.state_at(panel, x, ld_ft=5.0) + assert a.force_lb == pytest.approx(b.force_lb) + assert a.cy_in == pytest.approx(b.cy_in) + + +def test_stress_development_length_is_built_on_the_transfer_length(): + """The representative l_d handling_stress_at uses is l_t + (f_ps - f_se) d_b + -- the ACI 25.4.8.1 form on the SAME transfer length the force model uses, + so it is always at least the transfer length.""" + panel, _ = _panel_with_one_opening() + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0) + ld = capacity.stress_development_length_ft(panel, stage) + lt = prestress.transfer_length_in(panel.prestress) / 12.0 + assert ld >= lt + assert ld == pytest.approx(2.0, abs=1e-9) or ld > 2.0 # l_t was 2.0 ft + + +def test_handling_stress_solid_panel_unchanged_by_the_rule(): + """On a solid panel the §1.3 rule is a no-op, so the extreme-fibre stress + is exactly the transfer-model value -- the guard that the clean-panel + parity match cannot regress.""" + strand_b = Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0) + strand_t = Strand(y=2.0, z_in=6.5, area_in2=0.153, initial_pull_lb=30000.0) + pre = Prestress(strands=[strand_b, strand_t], loss_pct=0.0, diameter_in=0.5, + fpu_ksi=270.0) + panel = Panel(mark='C', length_ft=20.0, width_ft=4.0, thickness_in=8.0, + unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0, + prestress=pre) + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0, + tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6) + sp = capacity.handling_stress_at(panel, stage, 8.0, 12000.0) + # rebuild with the plain transfer-model state, no ld gating + from panellift import section + net = section.NetSection(panel, 8.0) + st = prestress.state_at(panel, 8.0) # no ld_ft + f_form, f_far = capacity.extreme_fibre_stresses( + st.force_lb, net.area_in2, 0.0, panel.thickness_in / 2.0, + net.iz_in4, 12000.0) + assert sp.f_form_psi == pytest.approx(f_form) + assert sp.f_far_psi == pytest.approx(f_far) diff --git a/panellift/tests/test_edge_anchor.py b/panellift/tests/test_edge_anchor.py new file mode 100644 index 0000000..dbfa06d --- /dev/null +++ b/panellift/tests/test_edge_anchor.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +"""Erection is two conditions, and they use two different ratings. + + the pick panel flat, face lifters and edge anchors together. Face + lifters are pulled out of the face (tension); the anchors are + edge-on to the line, which pulls across them (shear). + + standing panel plumb. The face lifters have let go and carry nothing. + The whole piece hangs from the anchors alone, in tension. + +Checking the anchors against tension in the first condition, or running the +second one through the beam model, both produce a report that looks complete +and answers a question the panel never asks. +""" +import dataclasses + +import pytest + +from conftest import LENGTH_FT, WIDTH_FT, make_panel +from panellift import checks, statics +from panellift.model import Lifter + + +def erection_panel(shear_lb=4010.0, tension_lb=7690.0, n=4, weight=35000.0): + """The ASPIRE panel picked on its lower two lines plus a top edge row.""" + panel = make_panel() + face = [dataclasses.replace(l, capacity_lb=12000.0) + for l in panel.lifters if l.x < 0.5 * LENGTH_FT] + edge = [Lifter(mark='E{0}'.format(i + 1), family='EDGE_ANCHOR', + x=LENGTH_FT, y=WIDTH_FT * (i + 1) / (n + 1.0), + role='strip', capacity_lb=tension_lb, + shear_capacity_lb=shear_lb, on_edge=True) + for i in range(n)] + return dataclasses.replace(panel, lifters=face + edge, weight_lb=weight) + + +def analyse(panel, stage, rolling): + return statics.analyse(panel, stage, rolling, role='strip') + + +def codes(findings): + return [f.code for f in findings] + + +def insert_lines(findings, family): + return [f for f in findings if f.code == 'INSERT' and family in f.message] + + +# --------------------------------------------- condition 1: the flat pick + +def test_an_edge_anchor_is_checked_in_both_tension_and_shear(stripping, rolling): + """2026-09-02 owner's ruling: the sling's line force is decomposed into + a vertical (tension) and a horizontal (shear) share and BOTH are + checked against the lifter's own capacity in that column -- `on_edge` + no longer selects a single axis to judge here (it still matters to + check_plumb_hang). An edge anchor carries a tension rating same as a + face lifter, and it is checked too; at this fixture's vertical + (90deg) rigging the shear share is zero, so the shear ratio comes back + a trivial 0% rather than being skipped.""" + a = analyse(erection_panel(), stripping, rolling) + found = insert_lines(checks.check_insert_capacity(a, rolling), + 'EDGE_ANCHOR') + assert found + assert 'shear' in found[0].message and 'tension' in found[0].message + assert '4,010' in found[0].message # the shear rating + assert '7,690' in found[0].message # the tension rating + + +def test_a_face_lifter_is_checked_in_tension(stripping, rolling): + a = analyse(erection_panel(), stripping, rolling) + found = insert_lines(checks.check_insert_capacity(a, rolling), + 'TEST_LIFTER') + assert found + assert 'tension' in found[0].message + + +def test_an_edge_anchor_with_no_shear_rating_still_judges_tension(stripping, + rolling): + """No shear rating means the shear half of the decomposition can't be + judged -- but the tension half is a separate question with its own + capacity, and it still judges rather than the whole lifter vanishing + from the report. The missing half is named, not silently dropped.""" + a = analyse(erection_panel(shear_lb=None), stripping, rolling) + found = checks.check_insert_capacity(a, rolling) + assert 'INSERT-UNRATED' in codes(found) + lines = insert_lines(found, 'EDGE_ANCHOR') + assert lines + assert 'tension' in lines[0].message + assert 'NOT JUDGED' in lines[0].message + assert '7,690' in lines[0].message # the tension rating still judged + + +# ------------------------------------------ condition 2: standing plumb + +def test_standing_plumb_hangs_the_whole_piece_on_the_anchors(stripping, + rolling): + a = analyse(erection_panel(n=4, weight=35000.0), stripping, rolling) + found = [f for f in checks.check_plumb_hang(a) if f.code == 'PLUMB'] + assert len(found) == 1 + assert '8,750' in found[0].message # 35,000 / 4 + assert '7,690' in found[0].message # the tension rating + + +def test_standing_plumb_ignores_the_handling_multiplier_and_the_sling(rolling): + """It is a piece hanging straight down off four anchors. There is no + distribution question in it, so nothing about the pick can change it.""" + from panellift.model import Stage + panel = erection_panel() + gentle = checks.check_plumb_hang(analyse(panel, Stage('a', 1.0, 4000.0), + rolling)) + harsh = checks.check_plumb_hang(analyse(panel, Stage('b', 1.7, 4000.0), + rolling)) + assert gentle[0].message == harsh[0].message + + +def test_a_row_too_light_for_the_piece_fails(stripping, rolling): + a = analyse(erection_panel(tension_lb=2000.0), stripping, rolling) + found = [f for f in checks.check_plumb_hang(a) if f.code == 'PLUMB'] + assert found and found[0].severity == 'FAIL' + + +def test_an_anchor_with_no_tension_rating_says_so(stripping, rolling): + a = analyse(erection_panel(tension_lb=None), stripping, rolling) + assert 'PLUMB-UNRATED' in codes(checks.check_plumb_hang(a)) + + +def test_a_stage_with_no_edge_anchors_is_not_stood_up(stripping, rolling): + """Stripping and transportation never put the piece on end, so there is + no plumb condition to report and no warning to give about it.""" + a = analyse(make_panel(), stripping, rolling) + assert checks.check_plumb_hang(a) == [] + + +def test_face_lifters_are_not_used_to_stand_the_piece_up(stripping, rolling): + """They have a tension rating too. Using it here would report a condition + the panel never sees.""" + panel = erection_panel() + panel = dataclasses.replace(panel, lifters=[ + dataclasses.replace(l, on_edge=False) for l in panel.lifters]) + a = analyse(panel, stripping, rolling) + assert checks.check_plumb_hang(a) == [] + + +def test_run_all_reports_both_conditions(stripping, rolling): + a = analyse(erection_panel(), stripping, rolling) + found = codes(checks.run_all(a, rolling)) + assert 'INSERT' in found and 'PLUMB' in found + + + +# ----------------------------------- transverse edge-anchor path + +def _transverse_panel(opening=False): + """A horizontal panel erected on its long edge, top-row anchors along the + edge, in TRANSPOSED shape: length = erected span (11.5 ft), width = long + dimension (26.83 ft).""" + from panellift.model import Blockout, Panel + panel = Panel(mark='H', length_ft=11.5, width_ft=26.83, thickness_in=8.0, + weight_lb=30800.0, fci_psi=3500.0, fc_psi=6000.0) + for i, y in enumerate([3.5, 10.5, 16.33, 24.08]): + panel.lifters.append(Lifter( + mark='E{0}'.format(i + 1), family='EDGE_ANCHOR', x=11.5, y=y, + role='strip', capacity_lb=16000.0, shear_capacity_lb=4010.0, + on_edge=True)) + if opening: + # Reaches the anchored edge (x0 + length = 11.5) inside the gap + # between the anchors at y = 10.5 and 16.33. + panel.blockouts.append(Blockout(name='BIG', x0=7.0, y0=14.0, + length=4.5, width=3.0)) + return panel + + +def _edge_path(panel): + from panellift.model import Stage + return checks.check_edge_anchor_path( + panel, Stage('erection', 1.4, 6000.0, span='width')) + + +def _f_t_psi(finding): + m = __import__('re').search(r'f_t ([0-9.]+) psi', finding.message) + return float(m.group(1)) + + +def test_transverse_edge_path_is_tiny_on_a_solid_horizontal_panel(): + found = _edge_path(_transverse_panel()) + ok = [f for f in found if f.code == 'EDGE-PATH'] + assert ok and ok[0].severity == 'OK' + assert 'psi' in ok[0].message + assert _f_t_psi(ok[0]) < 20.0 # a few pounds per square inch + + +def test_transverse_edge_path_respects_an_opening_across_the_anchor_line(): + solid = _edge_path(_transverse_panel())[0] + with_opening = _edge_path(_transverse_panel(opening=True))[0] + assert solid.code == 'EDGE-PATH' + assert with_opening.code == 'EDGE-PATH' + assert 'opening' in with_opening.message.lower() + assert _f_t_psi(with_opening) > _f_t_psi(solid) + + +def test_transverse_edge_path_ignores_length_stages_and_few_anchors(): + import dataclasses as _dc + from panellift.model import Stage + panel = _transverse_panel() + assert checks.check_edge_anchor_path( + panel, Stage('s', 1.4, 6000.0, span='length')) == [] + few = _dc.replace(panel, lifters=panel.lifters[:1]) + assert checks.check_edge_anchor_path( + few, Stage('s', 1.4, 6000.0, span='width')) == [] + + +# ----------------------- the anchored edge is read, not assumed + +def _mirrored_panel(): + """The same piece with the anchor row on the OTHER long edge -- x == 0 + after transposing -- and the opening cutting in from that edge. + + Which of a horizontal panel's two long edges ends up on top is a fact + about the model. Assuming it is always x == length does not give a wrong + number here, it gives a clean OK: no opening is ever seen to reach the + anchor line, so the strip reports its full depth and the one thing this + check exists to catch goes unreported. + """ + import dataclasses as _dc + from panellift.model import Blockout + panel = _transverse_panel() + panel = _dc.replace(panel, lifters=[_dc.replace(l, x=0.0) + for l in panel.lifters]) + panel.blockouts.append(Blockout(name='BIG', x0=0.0, y0=14.0, + length=4.5, width=3.0)) + return panel + + +def test_the_anchored_edge_is_taken_from_the_anchors(): + found = _edge_path(_mirrored_panel())[0] + assert 'opening BIG' in found.message + # 11.5 ft of span less the 4.5 ft the opening eats = 7.0 ft of web. + assert '84.0 in' in found.message + + +def test_mirrored_and_normal_read_the_same_strip(): + """Same geometry, mirrored. The stress must come out the same.""" + normal = _f_t_psi(_edge_path(_transverse_panel(opening=True))[0]) + mirrored = _f_t_psi(_edge_path(_mirrored_panel())[0]) + assert abs(normal - mirrored) < 0.05 + + +# ------------------------------ the load and the ends are not understated + +def test_the_handling_multiplier_is_on_the_load(): + """This compares a concrete stress to a concrete allowable, so it is the + factored load -- the insert checks divide the multiplier back out because + an insert is rated against service load, and that is a different question. + """ + import dataclasses as _dc + from panellift.model import Stage + panel = _transverse_panel() + gentle = checks.check_edge_anchor_path( + panel, Stage('a', 1.0, 6000.0, span='width'))[0] + harsh = checks.check_edge_anchor_path( + panel, Stage('b', 2.0, 6000.0, span='width'))[0] + # the message rounds to a tenth of a psi, so compare at that grain + assert abs(_f_t_psi(harsh) - 2.0 * _f_t_psi(gentle)) <= 0.15 + + +def test_the_strip_past_the_last_anchor_is_a_cantilever(): + """It hangs off the end anchor; there is nothing beyond it to lean on. + Calling it a simple span understates that moment four times over.""" + import dataclasses as _dc + panel = _transverse_panel() + # One long overhang, and the two anchors close together so no interior + # gap can win: 26.83 ft of piece with anchors at 3.5 and 5.0. + panel = _dc.replace(panel, lifters=[ + _dc.replace(panel.lifters[0], y=3.5), + _dc.replace(panel.lifters[1], y=5.0)]) + found = _edge_path(panel)[0] + assert 'overhang' in found.message + w = 30800.0 * 1.4 / 26.83 + gap = 26.83 - 5.0 + s_in3 = 8.0 * (11.5 * 12.0) ** 2 / 6.0 + assert abs(_f_t_psi(found) - w * gap ** 2 / 2.0 * 12.0 / s_in3) < 0.05 + + +# --------------------------------- nothing checked never reads as a pass + +def test_no_concrete_strength_is_not_a_pass(): + from panellift.model import Stage + found = checks.check_edge_anchor_path( + _transverse_panel(), Stage('e', 1.4, 0.0, span='width')) + assert codes(found) == ['EDGE-PATH-UNRATED'] + assert found[0].severity == 'WARN' + + +def test_an_opening_that_eats_the_whole_strip_fails(): + import dataclasses as _dc + from panellift.model import Blockout + panel = _transverse_panel() + panel.blockouts.append(Blockout(name='SLOT', x0=0.0, y0=12.0, + length=11.5, width=6.0)) + found = _edge_path(panel)[0] + assert found.severity == 'FAIL' + assert 'no section at all' in found.message diff --git a/panellift/tests/test_edge_sliver.py b/panellift/tests/test_edge_sliver.py new file mode 100644 index 0000000..e5b5ea8 --- /dev/null +++ b/panellift/tests/test_edge_sliver.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""An opening that all but reaches an edge must not leave a fibre behind. + +A real piece had an opening drawn to 11.1041 ft on a panel 11.1042 ft wide. +The 0.001 in of concrete left over was, arithmetically, the outermost material +in the section -- so every in-plane stress was reported at a fibre 110 in from +the centroid instead of 23 in, on a sliver nothing could be cast into. The +piece came back at 432 psi where a commercial check of the same piece said +162, and it was one of only two failures in a 194-piece sweep. + +The stress at a fibre that is not really there is a stress nobody has. +""" +import pytest + +from panellift import section +from panellift.model import Blockout + +from conftest import make_panel + + +def _panel_with_opening(gap_ft): + """A panel whose only opening stops `gap_ft` short of the far edge.""" + panel = make_panel() + panel.blockouts = [Blockout( + name='O1', x0=10.0, y0=3.0, length=4.0, + width=panel.width_ft - 3.0 - gap_ft)] + return panel + + +@pytest.mark.parametrize('gap_ft', [0.0, 1e-4, 1e-3, 0.004]) +def test_a_drafting_sliver_is_not_the_extreme_fibre(gap_ft): + """Whether the opening lands exactly on the edge or a thousandth short, + the section has to be the same piece of concrete.""" + exact = section.NetSection(_panel_with_opening(0.0), 12.0) + nearly = section.NetSection(_panel_with_opening(gap_ft), 12.0) + + assert nearly.width_in == pytest.approx(exact.width_in, abs=0.1) + assert nearly.y_extremes_in[1] == pytest.approx(exact.y_extremes_in[1], + abs=0.1) + assert nearly.iy_in4 == pytest.approx(exact.iy_in4, rel=0.01) + + +def test_the_extreme_fibre_is_inside_the_concrete(): + """The outermost fibre cannot be further from the centroid than the + material is. This is what the sliver broke: 110 in of lever arm on a + section only 46 in wide.""" + net = section.NetSection(_panel_with_opening(1e-3), 12.0) + reach = max(abs(v) for v in net.y_extremes_in) + assert reach <= net.width_in, ( + 'extreme fibre %.1f in from the centroid, but the section is only ' + '%.1f in wide' % (reach, net.width_in)) + + +def test_an_opening_well_short_of_the_edge_keeps_its_strip(): + """The snap is a noise floor, not a licence to delete real concrete. A + strip an inch and a half wide is still a strip.""" + panel = _panel_with_opening(0.125) # 1.5 in of concrete left + net = section.NetSection(panel, 12.0) + strips = section.strips_at(panel, 12.0) + assert len(strips) == 2 + assert net.y_extremes_in[1] > 0.5 * panel.width_ft * 12.0 diff --git a/panellift/tests/test_extra_cast.py b/panellift/tests/test_extra_cast.py new file mode 100644 index 0000000..0d2e53e --- /dev/null +++ b/panellift/tests/test_extra_cast.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""Concrete of the cast piece that is not the panel rectangle. + +A slab-seat ledge is modelled as its own family inside the assembly. It is +real concrete -- on WP371 it carried 8.99 ft3 = 1,349 lb, 7.5% of the piece +-- and leaving it out made the weight reconciliation accuse a correct model. +The lump rides at no station, so it is added to the geometric weight whole; +the statics rescale to the stated weight regardless, and this is what makes +that reconciliation honest. +""" +import pytest + +from panellift import cli, section + +from conftest import make_panel + + +LEDGE_FT3 = 8.992 # what WP371's LEDGE family actually measured +LEDGE_LB = LEDGE_FT3 * 150.0 + + +def test_extra_cast_is_added_to_the_geometric_weight(): + """The ledge is weight whether or not it sits at a station.""" + bare = make_panel() + ledged = make_panel() + ledged.extra_cast_ft3 = LEDGE_FT3 + + expected = section.geometric_weight_lb(bare) + LEDGE_LB + assert section.geometric_weight_lb(ledged) == pytest.approx(expected) + + +def test_reconciliation_clears_when_the_ledge_is_counted(): + """A panel whose stated weight includes the ledge must not be accused.""" + panel = make_panel() + panel.extra_cast_ft3 = LEDGE_FT3 + panel.weight_lb = section.geometric_weight_lb(panel) + + _geom, _stated, error = section.weight_reconciliation(panel) + assert abs(error) < 0.001 + + +def test_reconciliation_still_catches_a_missing_ledge(): + """Without the field, the old wrong answer is still visible. + + The ledge here is sized as a share of THIS panel -- on the smaller + WP371 it was 7.5% of the piece, and check_weight warns past 5%. + """ + panel = make_panel() + ledge_ft3 = 0.075 * (panel.length_ft * panel.width_ft * + panel.thickness_ft) + panel.weight_lb = (section.geometric_weight_lb(panel) + + ledge_ft3 * panel.unit_weight_pcf) + + _geom, _stated, error = section.weight_reconciliation(panel) + assert error < -0.05 # we are light by more than 5% + + +def test_cli_reads_extra_cast_ft3(): + """The job file carries the ledge, and the reader keeps it.""" + import json + + panel = make_panel() + panel.extra_cast_ft3 = LEDGE_FT3 + data = json.loads(json.dumps({ + 'mark': panel.mark, + 'length_ft': panel.length_ft, + 'width_ft': panel.width_ft, + 'thickness_in': panel.thickness_in, + 'unit_weight_pcf': panel.unit_weight_pcf, + 'extra_cast_ft3': LEDGE_FT3, + })) + + assert cli.read_panel(data).extra_cast_ft3 == pytest.approx(LEDGE_FT3) + + +def test_cli_treats_a_missing_field_as_no_ledge(): + """Old job files without the key keep working, as zero.""" + import json + + data = json.loads(json.dumps({ + 'mark': 'OLD-1', + 'length_ft': 32.0, + 'width_ft': 10.0, + 'thickness_in': 8.75, + })) + + assert cli.read_panel(data).extra_cast_ft3 == 0.0 diff --git a/panellift/tests/test_fps_powercurve.py b/panellift/tests/test_fps_powercurve.py new file mode 100644 index 0000000..1cdd1f3 --- /dev/null +++ b/panellift/tests/test_fps_powercurve.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""The PCI Design Aid 15.2.3 strand power curve and the strain-compatibility +flexure it feeds -- docs/eriksson-wall-algorithms.md sec.4.2. + +ADDITIVE. `pci.fps_approximate_ksi` (the ACI approximate f_ps that the shared +`capacity_at`/`phi_mn_lbft` and Panel Lift Check keep) is untouched; these pin +the more exact law the reference tool (Eriksson Wall) uses and that the additive +`handling_flexure_at` (Panel Analysis) now prefers. +""" +import pytest + +from panellift import capacity, pci +from panellift.model import Panel, Prestress, Stage, Strand + + +# ------------------------------------------------------- the pure power curve + +def test_fps_power_curve_elastic_branch(): + """eps_ps <= 0.0085 is elastic: f_ps = Ep*eps_ps. + + 0.005 * 28500 = 142.5 ksi. At the limit itself, 0.0085 * 28500 = 242.25. + """ + assert pci.fps_power_curve_ksi(0.005, 270.0) == pytest.approx(142.5) + assert pci.fps_power_curve_ksi(0.0085, 270.0) == pytest.approx(242.25) + + +def test_fps_power_curve_power_branch(): + """eps_ps > 0.0085: f_ps = f_pu - 0.04/(eps_ps - 0.007). + + At 0.010: 270 - 0.04/(0.010 - 0.007) = 270 - 13.3333 = 256.6667 ksi. + At 0.020: 270 - 0.04/0.013 = 270 - 3.0769 = 266.9231 ksi. + """ + assert pci.fps_power_curve_ksi(0.010, 270.0) == pytest.approx(256.6667, + abs=1e-3) + assert pci.fps_power_curve_ksi(0.020, 270.0) == pytest.approx(266.9231, + abs=1e-3) + + +def test_fps_power_curve_is_nearly_continuous_at_the_knee(): + """The two branches meet within ~1 ksi at eps_ps = 0.0085 -- one smooth + law, not a step. Elastic 242.25; power 270 - 0.04/0.0015 = 243.33.""" + elastic = pci.fps_power_curve_ksi(0.0085, 270.0) + power = 270.0 - 0.04 / (0.0085 - 0.007) + assert abs(power - elastic) < 1.1 + + +def test_fps_power_curve_caps_at_fpu_and_floors_at_zero(): + # The power branch approaches f_pu from below but never reaches it: at + # eps = 1.0, 270 - 0.04/0.993 = 269.96 ksi, under f_pu. + assert pci.fps_power_curve_ksi(1.0, 270.0) == pytest.approx(269.96, abs=0.01) + assert pci.fps_power_curve_ksi(1.0, 270.0) < 270.0 + # The cap DOES bind if the elastic branch is pushed past f_pu (an out-of- + # range Ep): 0.005 * 100000 = 500 ksi -> capped to f_pu = 270. + assert pci.fps_power_curve_ksi(0.005, 270.0, ep_ksi=100000.0) \ + == pytest.approx(270.0) + # Non-positive strain is no stress, not a negative one. + assert pci.fps_power_curve_ksi(0.0, 270.0) == 0.0 + assert pci.fps_power_curve_ksi(-0.001, 270.0) == 0.0 + + +# --------------------------------------- strain-compat Mn on a solid panel + +def _solid_prestressed_panel(): + """A plain solid panel, one bottom strand row, no openings -- so the + power-curve solve has a single fully-developed tension layer and can be + reproduced by an independent bisection in the test.""" + strands = [Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=28900.0), + Strand(y=6.0, z_in=1.5, area_in2=0.153, initial_pull_lb=28900.0)] + pre = Prestress(strands=strands, loss_pct=0.0, diameter_in=0.5, + fpu_ksi=270.0) + return Panel(mark='S', length_ft=24.0, width_ft=8.0, thickness_in=8.0, + unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0, + prestress=pre) + + +def test_power_curve_mn_matches_an_independent_strain_compat_solve(): + """`capacity_at` stores a power-curve M_n; reproduce it from the same law. + + Midspan, fully developed: every strand sees the full power-curve f_ps at + its own strain, and equilibrium 0.85 f'ci beta1 c b = sum(A f_ps) fixes c. + The test re-solves that balance independently and checks the stored M_n. + """ + panel = _solid_prestressed_panel() + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0) + x = 12.0 + cap = capacity.capacity_at(panel, stage, x, moment_lbft=1.0, shear_lb=0.0) + assert cap.mn_powercurve_lbft is not None + + # Independent solve, same inputs: two identical strands at d = 8 - 1.5 = + # 6.5 in, f_se = 28900/0.153/1000 = 188.9 ksi, eps_se = f_se/28500. + import math + fc = 3500.0 + b = panel.thickness_in and (panel.width_ft * 12.0) # solid, full width + beta1 = pci.beta1(fc) + ep = pci.STRAND_ELASTIC_MODULUS_KSI + d = 8.0 - 1.5 + area = 0.153 * 2 + fse = 28900.0 / 0.153 / 1000.0 + eps_se = fse / ep + + def tension(c): + eps = eps_se + 0.003 * (d - c) / c + return area * pci.fps_power_curve_ksi(eps, 270.0, ep) * 1000.0 + + lo, hi = 1e-4, d + for _ in range(80): + mid = 0.5 * (lo + hi) + if 0.85 * fc * beta1 * mid * b - tension(mid) > 0.0: + hi = mid + else: + lo = mid + c = 0.5 * (lo + hi) + a = beta1 * c + mn = tension(c) * (d - a / 2.0) / 12.0 + assert cap.mn_powercurve_lbft == pytest.approx(mn, rel=1e-3) + + +def test_handling_flexure_prefers_the_power_curve_mn(): + """handling_flexure_at reads the power-curve M_n, not the ACI-approximate + one, when the point carries it -- and applies the strain-based phi to it.""" + panel = _solid_prestressed_panel() + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0) + x = 12.0 + cap = capacity.capacity_at(panel, stage, x, moment_lbft=4000.0, shear_lb=0.0) + phi, phi_mn, ratio = capacity.handling_flexure_at(cap, 4000.0) + # Fully developed, low rho -> tension-controlled -> phi 0.90, and phi*M_n + # is 0.90 times the stored power-curve M_n, NOT the approximate phi_mn_lbft. + assert phi == pytest.approx(0.90) + assert phi_mn == pytest.approx(0.90 * cap.mn_powercurve_lbft) + assert phi_mn != pytest.approx(cap.phi_mn_lbft) # differs from approximate + assert ratio == pytest.approx(4000.0 / phi_mn) + + +def test_power_curve_mn_is_none_without_developed_strand(): + """A free end has no developed area, so no power-curve M_n is stored.""" + panel = _solid_prestressed_panel() + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0) + cap = capacity.capacity_at(panel, stage, x=0.0, moment_lbft=1.0, + shear_lb=0.0) + assert cap.mn_powercurve_lbft is None + + +def test_power_curve_free_end_flexure_is_not_a_full_phi_mn(): + """The development cap keeps a station near a free end from claiming the + full midspan phi*M_n: at x just inside the transfer length the strand has + only begun to develop, so phi*M_n there is well below the midspan value.""" + panel = _solid_prestressed_panel() + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0) + mid = capacity.capacity_at(panel, stage, 12.0, 1.0, 0.0) + near = capacity.capacity_at(panel, stage, 0.5, 1.0, 0.0) + assert mid.mn_powercurve_lbft is not None + if near.mn_powercurve_lbft is not None: + assert near.mn_powercurve_lbft < mid.mn_powercurve_lbft diff --git a/panellift/tests/test_handling_stress.py b/panellift/tests/test_handling_stress.py new file mode 100644 index 0000000..ac60a4e --- /dev/null +++ b/panellift/tests/test_handling_stress.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +"""The Tier-1 handling additions to `capacity`: the extreme-fibre STRESS check +and the phi development RAMP. + +Both are ADDITIVE -- `capacity_at` and every type it returns are untouched, and +their tests (test_capacity.py, test_station_table.py, ...) stand as they were. +These pin the new functions on numbers a reviewer can reproduce on paper: + + * `extreme_fibre_stresses` is the task's Pi/A -/+ (Mg + Pi*e)*c/I, hand + checked to the psi. + * `handling_stress_at` builds those from a real panel/stage and divides by + the STAGE's own allowables (tension_limit_sqrt_fc*sqrt(f'c), + compression_limit_ratio*f'c) -- the settings that were read by nothing + until this landed. A stage set to 5 vs 7.5 sqrt(f'c) moves the tension + utilisation by exactly 1.5. + * `phi_flexure_ramped` / `ramped_phi_mn` are the 0.75->0.90 ramp across the + transfer/development length. +""" +import math + +import pytest + +from panellift import capacity, prestress +from panellift.model import Panel, Prestress, Stage, Strand + + +# --------------------------------------------------- the pure stress formula + +def test_extreme_fibre_stresses_hand_check(): + """Pi=100000, A=1000, e=0, c=4, I=2000, Mg=10000 -> form +140, far -340. + + S = I/c = 500 in3; Pi/A = 100 psi (compression, negative); Mg/S = 240 psi. + Form face (z=0): -100 + 240 = +140 (tension). Far face: -100 - 240 = -340. + """ + f_form, f_far = capacity.extreme_fibre_stresses( + pi_lb=100000.0, area_in2=1000.0, e_in=0.0, c_in=4.0, + i_in4=2000.0, moment_lbft=10000.0) + assert f_form == pytest.approx(140.0) + assert f_far == pytest.approx(-340.0) + + +def test_extreme_fibre_stresses_eccentric_prestress(): + """A low strand (e<0) hogs the piece: it ADDS compression on the form face. + + e = -2 in, Pi=120000, so Pi*e = -240000 lb-in. With A=1000, c=4, I=2000 + (S=500) and Mg=0: form = -120 + (-240000/500) = -120 - 480 = -600; + far = -120 + 480 = +360 (tension on the far face, which is the hogging a + low strand causes). + """ + f_form, f_far = capacity.extreme_fibre_stresses( + pi_lb=120000.0, area_in2=1000.0, e_in=-2.0, c_in=4.0, + i_in4=2000.0, moment_lbft=0.0) + assert f_form == pytest.approx(-600.0) + assert f_far == pytest.approx(360.0) + + +def test_extreme_fibre_stresses_degenerate_returns_none(): + assert capacity.extreme_fibre_stresses(1.0, 0.0, 0.0, 4.0, 2000.0, 1.0) \ + == (None, None) + assert capacity.extreme_fibre_stresses(1.0, 1000.0, 0.0, 4.0, 0.0, 1.0) \ + == (None, None) + + +# ------------------------------------------------ handling_stress_at on panel + +def _solid_panel(): + """A plain solid panel with two symmetric strands -- e_z = 0, no openings. + + t=8 in, so mid-depth is 4 in; strands at z=1.5 and z=6.5 (= 8-1.5) with + equal force put the group centroid at mid-depth (e_z=0) and, being full + width, leave the in-plane term e_y=0 too. So the fibre stress is exactly + the 1-D Pi/A -/+ Mg*c/I with no prestress bending -- hand-reproducible. + """ + strands = [Strand(y=2.0, z_in=1.5, area_in2=0.153, initial_pull_lb=30000.0), + Strand(y=2.0, z_in=6.5, area_in2=0.153, initial_pull_lb=30000.0)] + pre = Prestress(strands=strands, loss_pct=0.0, diameter_in=0.5, + fpu_ksi=270.0) + return Panel(mark='S', length_ft=20.0, width_ft=4.0, thickness_in=8.0, + unit_weight_pcf=150.0, fci_psi=3500.0, fc_psi=6000.0, + prestress=pre) + + +def test_handling_stress_at_matches_the_pure_formula(): + panel = _solid_panel() + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0, + tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6) + x = 8.0 + m = 12000.0 + sp = capacity.handling_stress_at(panel, stage, x, m) + + # Rebuild the ingredients the way the function does, independently. + from panellift import section + net = section.NetSection(panel, x) + state = prestress.state_at(panel, x) + pi = state.force_lb + assert pi > 0 + assert sp.e_in == pytest.approx(0.0, abs=1e-9) # symmetric -> e_z = 0 + f_form, f_far = capacity.extreme_fibre_stresses( + pi, net.area_in2, 0.0, panel.thickness_in / 2.0, net.iz_in4, m) + assert sp.f_form_psi == pytest.approx(f_form) + assert sp.f_far_psi == pytest.approx(f_far) + assert sp.in_plane_psi == pytest.approx(0.0, abs=1e-6) + + +def test_handling_stress_utils_use_the_stages_allowables(): + """The utilisation divides by the STAGE's own allowables -- the settings + that nothing read before. tension over tension_limit_sqrt_fc*sqrt(f'c), + compression over compression_limit_ratio*f'c.""" + panel = _solid_panel() + stage = Stage(name='strip', multiplier=1.4, fc_psi=3500.0, + tension_limit_sqrt_fc=7.5, compression_limit_ratio=0.6) + sp = capacity.handling_stress_at(panel, stage, 10.0, 15000.0) + allow_t = 7.5 * math.sqrt(3500.0) + allow_c = 0.6 * 3500.0 + assert sp.tension_util == pytest.approx(sp.tension_psi / allow_t) + assert sp.compression_util == pytest.approx(sp.compression_psi / allow_c) + + +def test_handling_stress_limit_coefficient_moves_the_tension_util_by_1p5(): + """5 vs 7.5 sqrt(f'c) is the whole 1.5x that the two schools argue about, + and it lands on the tension utilisation and nowhere else.""" + panel = _solid_panel() + common = dict(multiplier=1.4, fc_psi=3500.0, compression_limit_ratio=0.6) + five = Stage(name='a', tension_limit_sqrt_fc=5.0, **common) + seven = Stage(name='b', tension_limit_sqrt_fc=7.5, **common) + a = capacity.handling_stress_at(panel, five, 10.0, 15000.0) + b = capacity.handling_stress_at(panel, seven, 10.0, 15000.0) + assert a.tension_psi == pytest.approx(b.tension_psi) # same stress + # 5 is the stricter school -> a higher utilisation, by exactly 1.5. + assert a.tension_util == pytest.approx(b.tension_util * 1.5) + + +def test_handling_stress_no_allowable_is_a_dash_not_a_zero(): + """A stage stating no strength has no allowable to divide by -- util is + None with a reason, never a fabricated 0, the same could-not-judge + discipline as every ratio in this module.""" + panel = _solid_panel() + stage = Stage(name='s', multiplier=1.0, fc_psi=0.0) + sp = capacity.handling_stress_at(panel, stage, 10.0, 15000.0) + assert sp.tension_util is None and sp.compression_util is None + assert sp.why + + +# --------------------------------- phi from net tensile strain (Table 21.2.2) + +def test_phi_flexure_from_strain_boundaries_and_slope(): + """ACI 318-14 Table 21.2.2, prestressed floor 0.65, eps_ty 0.002.""" + assert capacity.phi_flexure_from_strain(0.001) == pytest.approx(0.65) + assert capacity.phi_flexure_from_strain(0.002) == pytest.approx(0.65) + # midway: 0.65 + 0.25*(0.0035-0.002)/(0.005-0.002) = 0.65 + 0.125 = 0.775 + assert capacity.phi_flexure_from_strain(0.0035) == pytest.approx(0.775) + assert capacity.phi_flexure_from_strain(0.005) == pytest.approx(0.90) + assert capacity.phi_flexure_from_strain(0.010) == pytest.approx(0.90) + + +def test_phi_flexure_from_strain_none_is_the_flat_value(): + """No strain to judge on is not a reason to drop to the floor.""" + assert capacity.phi_flexure_from_strain(None) == pytest.approx(0.90) + + +def test_handling_flexure_at_applies_the_strain_phi_to_the_same_mn(): + """M_n does not change; only phi moves, from the section's strain.""" + pt = capacity.CapacityPoint(x=5.0) + pt.phi = 0.90 + pt.phi_mn_lbft = 9000.0 # M_n = 10000 lb-ft + pt.strain_t = 0.0035 # transition -> phi 0.775 + phi, phi_mn, ratio = capacity.handling_flexure_at(pt, moment_lbft=3100.0) + assert phi == pytest.approx(0.775) + assert phi_mn == pytest.approx(10000.0 * 0.775) # = 7750 + assert ratio == pytest.approx(3100.0 / 7750.0) + + +def test_handling_flexure_at_tension_controlled_is_flat_0p90(): + pt = capacity.CapacityPoint(x=5.0) + pt.phi = 0.90 + pt.phi_mn_lbft = 9000.0 + pt.strain_t = 0.020 # well tension-controlled + phi, phi_mn, _ = capacity.handling_flexure_at(pt, moment_lbft=3000.0) + assert phi == pytest.approx(0.90) + assert phi_mn == pytest.approx(9000.0) # unchanged from flat + + +def test_handling_flexure_at_rebuilds_mn_in_the_transition_region(): + """Where capacity_at bailed (no phi*M_n) but left the equilibrium geometry, + M_n is rebuilt from C = 0.85 f'c a b at lever arm (d_p - a/2).""" + pt = capacity.CapacityPoint(x=5.0) # phi_mn_lbft stays None + pt.a_in = 1.0 + pt.b_in = 100.0 + pt.dp_in = 6.0 + pt.fc_psi = 3500.0 + pt.strain_t = 0.003 # transition + phi, phi_mn, ratio = capacity.handling_flexure_at(pt, moment_lbft=5000.0) + tension = 0.85 * 3500.0 * 1.0 * 100.0 + mn = tension * (6.0 - 0.5) / 12.0 + assert phi == pytest.approx(0.65 + 0.25 * (0.003 - 0.002) / 0.003) + assert phi_mn == pytest.approx(phi * mn) + assert ratio == pytest.approx(5000.0 / (phi * mn)) + + +def test_handling_flexure_at_no_strength_is_none_not_zero(): + pt = capacity.CapacityPoint(x=5.0) # no phi_mn, no geometry, no eps + assert capacity.handling_flexure_at(pt, moment_lbft=1000.0) \ + == (None, None, None) diff --git a/panellift/tests/test_ignore_lateral_prestress.py b/panellift/tests/test_ignore_lateral_prestress.py new file mode 100644 index 0000000..a07d663 --- /dev/null +++ b/panellift/tests/test_ignore_lateral_prestress.py @@ -0,0 +1,15 @@ +"""`Panel.ignore_lateral_prestress` drops the in-plane prestress term from +the corner stresses -- the plant's reference-tool convention -- and is off +by default.""" +from panellift.model import Panel + + +def test_default_is_off(): + assert Panel(mark='X', length_ft=20.0, width_ft=10.0, + thickness_in=8.0).ignore_lateral_prestress is False + + +def test_flag_is_a_plain_field(): + p = Panel(mark='X', length_ft=20.0, width_ft=10.0, thickness_in=8.0, + ignore_lateral_prestress=True) + assert p.ignore_lateral_prestress is True diff --git a/panellift/tests/test_inplace.py b/panellift/tests/test_inplace.py new file mode 100644 index 0000000..715317d --- /dev/null +++ b/panellift/tests/test_inplace.py @@ -0,0 +1,552 @@ +# -*- coding: utf-8 -*- +"""The In-Place strength model: load cases, combinations, and the checks. + +Neutral fixtures only -- no client geometry, no client numbers. The parity +against the reference tool's real pieces lives in the scratchpad harness; these +are the identities any correct in-place answer must satisfy: + + * a cantilever under a uniform wind gives w*L^2/2 at the support (statics); + * a load combination assembles exactly sum(factor * case) and nothing else; + * the flexure util is |Mu| / (phi*Mn from capacity_at), reused not rebuilt; + * axial phi*Pn(C) is 0.85 f'c Ag * 0.80 * 0.65 for a plain/mild section, and + carries the strand reduction where strand is developed; + * fewer than two restraints refuses loudly, the engine's own words. +""" +import math + +import pytest + +from panellift import capacity, inplace, section +from panellift.model import Panel, Prestress, Strand + + +# ------------------------------------------------------------------ fixtures + +def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0, + fc_psi=5000.0): + """A solid, non-prestressed wall -- the mild-wall shape (no strand).""" + return Panel(mark='TEST-WALL', length_ft=length_ft, width_ft=width_ft, + thickness_in=thickness_in, unit_weight_pcf=150.0, + fc_psi=fc_psi, fci_psi=3500.0, prestress=None) + + +def strand_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0, + fc_psi=5000.0): + """A solid wall carrying two straight strands near the tension face.""" + strands = [Strand(y=2.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0, + label='S1'), + Strand(y=8.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0, + label='S2')] + pre = Prestress(strands=strands, loss_pct=8.0, diameter_in=0.5, + fpu_ksi=270.0) + return Panel(mark='TEST-STRAND', length_ft=length_ft, width_ft=width_ft, + thickness_in=thickness_in, unit_weight_pcf=150.0, + fc_psi=fc_psi, fci_psi=3500.0, prestress=pre) + + +def cantilever_inputs(pressure_psf=100.0): + """Restraints at 0 and 20 ft, a 10 ft cantilever above with wind on it.""" + return inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0], + wind_zones=[inplace.LateralPressureZone(20.0, 30.0, pressure_psf, + 'W cantilever')]) + + +# ------------------------------------------------------- cantilever statics + +def test_cantilever_moment_is_w_l_squared_over_two(): + """A uniform wind on a cantilever gives w*L^2/2 at the support. + + This is the whole reason the beam reuse is trusted: the reference tool's + own governing WPA1.0 flexure moment IS a cantilever moment, and the closed + form is unarguable. + """ + panel = plain_wall() + inputs = cantilever_inputs(pressure_psf=100.0) + dia, rig = inplace.solve_lateral(panel, inputs.restraint_xs, + inputs.wind_zones, panel.width_ft, + panel.fc_psi) + w_plf = 100.0 * panel.width_ft # 1000 plf + cantilever_ft = 10.0 + expected = w_plf * cantilever_ft ** 2 / 2.0 # 50,000 lb-ft + at_support = dia.moment_at(20.0) + assert abs(abs(at_support) - expected) / expected < 0.01 + # and the free top closes to zero + assert abs(dia.moment_at(30.0)) < 0.02 * expected + + +# --------------------------------------------------- load-case superposition + +def test_load_case_fields_split_dead_and_wind(): + """Dead is axial only; wind is moment/shear only. No crosstalk.""" + panel = plain_wall() + inputs = cantilever_inputs() + xs = inplace.key_stations(panel, inputs) + cases = inplace.build_load_cases(panel, inputs, xs) + + d = cases['D'] + w = cases['W'] + # Dead carries axial, no moment/shear. + assert max(abs(v) for v in d.axial_lb) > 0.0 + assert all(v == 0.0 for v in d.moment_lbft) + assert all(v == 0.0 for v in d.shear_lb) + # Wind carries moment/shear, no axial. + assert max(abs(v) for v in w.moment_lbft) > 0.0 + assert all(v == 0.0 for v in w.axial_lb) + # Dead axial at the base is the whole self weight; at the top it is zero. + assert d.axial_at(0.0) > d.axial_at(20.0) > 0.0 + assert abs(d.axial_at(panel.length_ft)) < 1.0 + + +def test_combination_demand_is_pure_superposition(): + """Mu, Vu, Pu are exactly sum(factor * case) -- LC7 = 0.9D + 1.0W.""" + panel = plain_wall() + inputs = cantilever_inputs() + xs = inplace.key_stations(panel, inputs) + cases = inplace.build_load_cases(panel, inputs, xs) + + lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7') + x = 20.0 + pu, mu, vu = inplace.combination_demand(cases, lc7, x) + # 0.9 * dead axial, 1.0 * wind moment, and T/H are zero cases. + assert pu == pytest.approx(0.9 * cases['D'].axial_at(x), rel=1e-9) + assert mu == pytest.approx(1.0 * cases['W'].moment_at(x), rel=1e-9) + assert vu == pytest.approx(1.0 * cases['W'].shear_at(x), rel=1e-9) + + +def test_known_aci_combo_assembles_the_right_factored_demand(): + """LC5 = 1.2D + 1.0L + 0.5R + 1.0W applies each factor to its own case. + + Built from a hand-set case table so the arithmetic is unarguable: dead 100k + axial, live 20k axial, wind 10 k-ft moment -> LC5 Pu = 1.2*100 + 1.0*20 = + 140 k and Mu = 1.0*10 = 10 k-ft. + """ + xs = [0.0, 1.0] + zeros = [0.0, 0.0] + cases = { + 'D': inplace.LoadField('D', 'D', xs, [100000.0, 100000.0], list(zeros), + list(zeros)), + 'L': inplace.LoadField('L', 'L', xs, [20000.0, 20000.0], list(zeros), + list(zeros)), + 'R': inplace.LoadField('R', 'R', xs, [5000.0, 5000.0], list(zeros), + list(zeros)), + 'W': inplace.LoadField('W', 'W', xs, list(zeros), + [10000.0, 10000.0], [3000.0, 3000.0]), + } + lc5 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC5') + pu, mu, vu = inplace.combination_demand(cases, lc5, 0.0) + assert pu == pytest.approx(1.2 * 100000 + 1.0 * 20000 + 0.5 * 5000) + assert mu == pytest.approx(1.0 * 10000) + assert vu == pytest.approx(1.0 * 3000) + + +# ----------------------------------------------------- axial capacity (new) + +def test_axial_phi_pn_c_plain_section(): + """phi*Pn(C) = 0.65 * 0.80 * 0.85 f'c Ag for a plain/mild section.""" + panel = plain_wall(width_ft=10.0, thickness_in=12.0, fc_psi=5000.0) + stage = inplace.in_place_stage(panel, cantilever_inputs()) + ag = section.NetSection(panel, 5.0).area_in2 # 120 in * 12 in + ax = inplace.axial_capacity(panel, stage, 5.0, pu_lb=100000.0, + restraint_xs=[0.0, 20.0]) + expected = (inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR + * 0.85 * 5000.0 * ag) + assert ax.phi_pn_c_lb == pytest.approx(expected, rel=1e-9) + assert ax.util == pytest.approx(100000.0 / expected, rel=1e-9) + + +def test_axial_strand_term_reduces_po(): + """A developed strand REDUCES Po by (f_se - 0.003 Ep) Aps (docs sec.5.1).""" + panel = strand_wall() + stage = inplace.in_place_stage(panel, cantilever_inputs()) + # A mid-span station where both strands are fully developed. + x = 15.0 + ax = inplace.axial_capacity(panel, stage, x, pu_lb=50000.0, + restraint_xs=[0.0, 20.0]) + plain = inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR * \ + 0.85 * panel.fc_psi * section.NetSection(panel, x).area_in2 + # With strand present phi*Pn(C) is LOWER than the plain 0.85 f'c Ag figure. + assert ax.aps_in2 > 0.0 + assert ax.phi_pn_c_lb < plain + + +def test_axial_reports_pcrit(): + """phi*Pcrit is computed (Euler on EI_eff) and flagged approximate.""" + panel = plain_wall() + stage = inplace.in_place_stage(panel, cantilever_inputs()) + ax = inplace.axial_capacity(panel, stage, 10.0, pu_lb=100000.0, + restraint_xs=[0.0, 20.0]) + assert ax.phi_pcrit_lb is not None and ax.phi_pcrit_lb > 0.0 + assert ax.pcrit_note and 'approximate' in ax.pcrit_note + + +# ------------------------------------------------- strength util from capacity + +def test_flexure_util_comes_from_capacity_at(): + """The in-place flexure util is a P-M-aware capacity fed the combo's Mu/Pu. + + A pure-strand piece (no mild reinforcement) is checked through the + additive `capacity.strand_pm_flexure_at` (Eriksson parity gap #2), NOT + `capacity_at`'s flat pure-bending phi*Mn -- `_check_station` overwrites + `sd.cap` with the P-M numbers when they can be judged. This pins that the + util the run reports is that same P-M number, at the SAME station's own + Pu, not the flat one. + """ + panel = strand_wall() + inputs = cantilever_inputs(pressure_psf=300.0) # enough to bend it + res = inplace.run(panel, inputs) + assert res.refusal is None + # find a combo/station where flexure was judged + found = False + for cr in res.combos: + for sd in cr.stations: + if sd.flex_util is not None: + direct = capacity.strand_pm_flexure_at( + panel, res.stage, sd.x, sd.mu_lbft, pu_lb=sd.pu_lb) + assert sd.flex_util == pytest.approx(direct.m_over_phi_mn) + found = True + assert found, 'no station produced a flexural util to check' + + +def test_mild_wall_flexure_dashes_not_passes(): + """A no-strand wall dashes phi*Mn with a reason -- never a fabricated pass. + + capacity_at is strand-only; the mild flexural capacity is the concurrent + capacity layer's job. The in-place check must surface that as a + could-not-judge, exactly like every other dash in the engine. + """ + panel = plain_wall() + inputs = cantilever_inputs(pressure_psf=300.0) + res = inplace.run(panel, inputs) + combo = res.combos[0] + sd = combo.stations[len(combo.stations) // 2] + assert sd.flex_util is None + assert sd.cap.reason(capacity.PHI_MN) # a reason is present + + +# ---------------------------------------------------------------- refusal + +def test_fewer_than_two_restraints_refuses_loudly(): + """One restraint is not a span -- the engine refuses, in its own words.""" + panel = plain_wall() + inputs = inplace.InPlaceInputs(restraint_xs=[0.0], wind_zones=[]) + res = inplace.run(panel, inputs) + assert res.refusal is not None + assert 'two supports' in res.refusal + assert not res.combos + + +def test_run_finds_an_overall_governing_check(): + """A wired run names an overall governing combo/check/util/x.""" + panel = strand_wall() + res = inplace.run(panel, cantilever_inputs(pressure_psf=300.0)) + assert res.gov_combo and res.gov_check and res.gov_util is not None + assert res.gov_util > 0.0 + + +# ---------------------------------------------------------- combination set + +def test_asce_strength_set_matches_the_printed_combinations(): + """The strength set is the ASCE 7-16 2.3 combos with Eriksson's T/H.""" + names = [c.name for c in inplace.ASCE_7_16_STRENGTH] + assert names == ['LC1', 'LC2', 'LC3', 'LC4', 'LC5', 'LC6', 'LC7', 'LC8'] + lc1 = inplace.ASCE_7_16_STRENGTH[0] + assert lc1.factors['D'] == 1.4 + lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7') + assert lc7.factors['D'] == 0.9 and lc7.factors['W'] == 1.0 + # every strength combo folds in 1.0T and 1.6H, per docs sec.7 + for c in inplace.ASCE_7_16_STRENGTH: + assert c.factors.get('T') == 1.0 + assert c.factors.get('H') == 1.6 + + +# ------------------------------------------- eccentric bearing loads (gap #1) + +def test_eccentric_dead_load_puts_a_real_moment_on_the_d_case(): + """`AppliedAxial.ecc_in` turns a dead bearing load into a D-case moment. + + Before this field existed, `dead_axial_field`/`live_axial_field` only + ever populated `axial_lb` -- `D`'s `moment_lbft` was all zeros + (Eriksson in-place parity gap #1, `_scratch/eriksson_parity_2026-09-04.md` + root cause #1). A load with `ecc_in == 0.0` (the default) must still + leave D's moment at all zeros -- a deck with no eccentric loads is + byte-for-byte what this engine reported before `ecc_in` existed. + """ + panel = plain_wall(length_ft=40.0) + concentric = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0, 40.0], + applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0, + label='P1')]) + xs = inplace.key_stations(panel, concentric) + cases = inplace.build_load_cases(panel, concentric, xs) + assert all(v == 0.0 for v in cases['D'].moment_lbft) + + eccentric = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0, 40.0], + applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0, + ecc_in=6.0, label='P1')]) + cases_ecc = inplace.build_load_cases(panel, eccentric, xs) + assert max(abs(v) for v in cases_ecc['D'].moment_lbft) > 0.0 + # axial is unchanged by the eccentricity -- same P(x), a real moment on top. + assert cases_ecc['D'].axial_lb == cases['D'].axial_lb + + +def test_eccentric_moment_matches_the_point_moment_hand_solution(): + """The D-case moment field IS the `PointMoment` beam solve, hand-checked. + + Two restraints only (determinate), so `solve_point_moments`'s answer must + match `PointMoment`'s own closed form exactly, on `eccentric_moments`' OWN + `moment_lbft = -(P*ecc_in/12)` (the minus is the golden-pinned sign, see + `eccentric_moments`' docstring): with `m0 = -(P*ecc_in/12)` at x=10 ft on + a 2-support 20 ft span, R0=-m0/L, RL=+m0/L, and M(x) = -m0*x/L for x<10, + m0*(1-x/20) for x>10. + """ + panel = plain_wall(length_ft=20.0) + p_lb, ecc_in = 40000.0, 6.0 + m0 = -(p_lb * ecc_in / 12.0) + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0], + applied_axial=[inplace.AppliedAxial(x_ft=10.0, dead_lb=p_lb, + ecc_in=ecc_in, label='P1')]) + xs = sorted(set([2.0, 5.0, 8.0, 12.0, 15.0, 18.0])) + cases = inplace.build_load_cases(panel, inputs, xs) + d = cases['D'] + for x in xs: + expected = (-m0 * x / 20.0 if x < 10.0 + else m0 * (1.0 - x / 20.0)) + assert d.moment_at(x) == pytest.approx(expected, abs=1.0), x + + +def test_eccentric_moment_sign_matches_the_golden_p1_p6_jumps(): + """The golden's own Mu jump at P1 and P6 (WPA2.0/A27 pp.30-31), reproduced. + + P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k, L=12.25k) jumps + +38.9 k-ft crossing its own station in the golden's table (14.87 ft: + -16.6 -> 16.35 ft: +22.3) -- close to the LC2-factored P*e/12 = + (1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft, POSITIVE for a NEGATIVE offset. + P1 (x=11'-1.5"=11.125 ft, ecc_in=+10 in, D=9.62k, L=6.01k) jumps -24.2 + k-ft (10.41 ft: +6.5 -> 11.12 ft: -17.7) -- close to + -(1.2*9.62+1.6*6.01)*10/12 = -17.63 k-ft (plus the linear slope over the + 0.7 ft gap to golden's own pre-jump row), NEGATIVE for a POSITIVE offset. + This is what pins `eccentric_moments`' `-(P*ecc_in/12)` sign, checked + directly on THIS engine's own D-case (LC2's D factor only, isolating the + mechanism from L/W): the D-only jump at each station must have the sign + the golden's own jump has. + """ + panel = plain_wall(length_ft=74.0 + 4.0 / 12.0) + p1_x, p1_ecc, p1_d = 11.0 + 1.5 / 12.0, 10.0, 9.62e3 + p6_x, p6_ecc, p6_d = 15.0 + 11.25 / 12.0, -10.0, 20.18e3 + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, panel.length_ft], + applied_axial=[ + inplace.AppliedAxial(x_ft=p1_x, dead_lb=p1_d, ecc_in=p1_ecc, + label='P1'), + inplace.AppliedAxial(x_ft=p6_x, dead_lb=p6_d, ecc_in=p6_ecc, + label='P6')]) + xs = [0.0, p1_x - 0.5, p1_x + 0.001, p6_x - 0.5, p6_x + 0.001, + panel.length_ft] + cases = inplace.build_load_cases(panel, inputs, sorted(xs)) + d = cases['D'] + # P6: negative offset -> POSITIVE jump. + assert d.moment_at(p6_x + 0.001) > d.moment_at(p6_x - 0.5) + # P1: positive offset -> NEGATIVE jump. + assert d.moment_at(p1_x + 0.001) < d.moment_at(p1_x - 0.5) + + +# ------------------------------------------------------ wind envelope (gap #3) + +def test_wind_envelope_picks_the_worse_of_pressure_and_suction(): + """Every check is enveloped over BOTH wind directions (Eriksson gap #3). + + `ASCE_7_16_STRENGTH` only ever carries a POSITIVE W factor, so without + the envelope a station only ever sees pressure. With an eccentric dead + bearing load ALSO putting a real moment into the same stations, pressure + and suction combine with it differently station to station -- this pins + that `inplace.run`'s reported per-station flex/shear utils are exactly + the max of a hand-run of BOTH `wind_suction_cases` directions, and that + the two directions really do disagree somewhere (otherwise the envelope + would be a no-op and this would not be testing anything). + """ + panel = strand_wall(length_ft=40.0) + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0, 40.0], + wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')], + applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0, + ecc_in=4.0, label='P1')]) + res = inplace.run(panel, inputs) + assert res.refusal is None + cases_suction = inplace.wind_suction_cases(res.cases) + assert cases_suction is not None + + checked = 0 + disagreed = False + for cr in res.combos: + if not cr.combo.factors.get('W', 0.0): + continue + for sd in cr.stations: + a = inplace._check_station(panel, res.stage, res.cases, cr.combo, + sd.x, inputs.restraint_xs) + b = inplace._check_station(panel, res.stage, cases_suction, + cr.combo, sd.x, inputs.restraint_xs) + want_flex = max(a.flex_util if a.flex_util is not None else -1.0, + b.flex_util if b.flex_util is not None else -1.0) + got_flex = sd.flex_util if sd.flex_util is not None else -1.0 + assert got_flex == pytest.approx(want_flex, abs=1e-9) + if a.mu_lbft != pytest.approx(b.mu_lbft): + disagreed = True + checked += 1 + assert checked > 0 + assert disagreed, ('pressure and suction produced the same Mu everywhere ' + '-- the fixture is not exercising the envelope') + + +# --------------------------------------------- LC2 magnitude at a full jump + +def test_lc2_reproduces_the_golden_p6_jump_at_close_to_full_magnitude(): + """`key_stations` forcing a node at (and just before) each load is what + lets a combo's factored jump reach close to its full magnitude. + + WPA2.0/A27's own P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k, + L=12.25k) jumps +38.9 k-ft crossing its station under the golden's own + LC2 (1.2D+1.6L) -- close to (1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft. + Before `key_stations` forced a node onto every eccentric load's own x + (and a hair before it), the coarse D/L `LoadField`s only sampled the + beam solve's fine diagram at the L/20 grid, and a jump landing INSIDE + one of those coarse intervals got smoothed into a gentle ramp across the + whole interval instead of a sharp step -- Mu magnitudes read ~50-60% of + the golden's own printed jumps. This runs `inplace.run` end to end (not + a hand-built `xs` list) and checks the jump `key_stations` itself + produces is within 10% of the LC2-factored P*e/12 (a looser tolerance + than the hand-solution tests because this station's own backoff point + is a genuine finite distance from the load, not an infinitesimal limit). + """ + panel = plain_wall(length_ft=74.0 + 4.0 / 12.0, width_ft=4.0, + thickness_in=8.0) + p6_x = 15.0 + 11.25 / 12.0 + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, panel.length_ft], + applied_axial=[inplace.AppliedAxial(x_ft=p6_x, dead_lb=20.18e3, + live_lb=12.25e3, ecc_in=-10.0, + label='P6')]) + xs = inplace.key_stations(panel, inputs) + cases = inplace.build_load_cases(panel, inputs, xs) + lc2 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC2') + + before = max(x for x in xs if x < p6_x) + after = min(x for x in xs if x >= p6_x) + _pu_a, mu_a, _vu_a = inplace.combination_demand(cases, lc2, before) + _pu_b, mu_b, _vu_b = inplace.combination_demand(cases, lc2, after) + jump = mu_b - mu_a + expected = (1.2 * 20.18e3 + 1.6 * 12.25e3) * 10.0 / 12.0 + assert jump == pytest.approx(expected, rel=0.10), ( + 'jump {0:.1f} lb-ft vs the LC2-factored P*e/12 {1:.1f} lb-ft' + .format(jump, expected)) + + +# ------------------------------------------------- manual per-zone wind (PANELANALYSIS-4) + +def test_manual_zones_pressure_and_suction_give_different_w_fields(): + """A zone with pressure != suction gives W+ != W- (not the old negate). + + `wind_zone_mode='manual'` plus a zone carrying DIFFERENT `pressure_psf` + and `suction_psf` magnitudes must NOT reproduce `wind_suction_cases`'s + "negate the pressure field" shortcut -- that shortcut is exact only when + the two magnitudes match, which this fixture deliberately violates + (10 psf pressure, 30 psf suction) so a bug that silently falls back to + the legacy path is caught by the moments actually disagreeing by more + than a sign flip. + """ + panel = plain_wall(length_ft=40.0) + zone = inplace.LateralPressureZone( + x0_ft=0.0, x1_ft=40.0, pressure_psf=10.0, suction_psf=30.0, + label='W1') + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone], + wind_zone_mode='manual') + res = inplace.run(panel, inputs) + assert res.refusal is None + key_xs = res.cases['D'].xs + cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs, + res.cases) + assert cases_suction is not None + w_plus = res.cases['W'] + w_minus = cases_suction['W'] + + # A plain negate would give w_minus.moment == -w_plus.moment everywhere; + # the 3x magnitude difference (30 vs 10 psf) must break that exactly. + ratios = [abs(m) for m in w_minus.moment_lbft if abs(m) > 1e-6] + assert ratios, 'suction case carries no moment at all' + peak_plus = max(abs(m) for m in w_plus.moment_lbft) + peak_minus = max(abs(m) for m in w_minus.moment_lbft) + assert peak_minus == pytest.approx(peak_plus * 3.0, rel=0.02), ( + 'suction (30 psf) should peak at 3x the pressure (10 psf) case, ' + 'got {0:.1f} vs {1:.1f}'.format(peak_minus, peak_plus)) + + +def test_manual_zone_with_pressure_only_contributes_nothing_to_suction(): + """A zone with pressure_psf and no suction_psf: W- has nothing from it. + + PANELANALYSIS-4: "a zone with pressure only contributes nothing to W-". + With every zone pressure-only, the whole deck has no suction data at all, + so `build_wind_suction_cases` returns None (no envelope pass) rather than + inventing a suction field off the pressure magnitude. + """ + panel = plain_wall(length_ft=40.0) + zone = inplace.LateralPressureZone( + x0_ft=0.0, x1_ft=40.0, pressure_psf=15.0, suction_psf=None, + label='W1') + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone], + wind_zone_mode='manual') + res = inplace.run(panel, inputs) + assert res.refusal is None + key_xs = res.cases['D'].xs + cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs, + res.cases) + assert cases_suction is None + + +def test_manual_zone_per_zone_width_scales_the_line_load(): + """A zone's OWN `width_ft` (not the panel width) sets its line load. + + Two otherwise-identical single-zone runs, one at the panel's width and + one at double it via the zone's own `width_ft`, must differ by exactly + that factor -- pins that `lateral_line_load_plf` reads the zone's width + ahead of the panel-wide fallback. + """ + panel = plain_wall(length_ft=40.0, width_ft=8.0) + narrow = inplace.LateralPressureZone( + x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=None, label='W1') + wide = inplace.LateralPressureZone( + x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=16.0, label='W1') + dia_narrow, _r = inplace.solve_lateral(panel, [0.0, 20.0, 40.0], + [narrow], panel.width_ft, + panel.fc_psi) + dia_wide, _r2 = inplace.solve_lateral(panel, [0.0, 20.0, 40.0], + [wide], panel.width_ft, + panel.fc_psi) + m_narrow = max(abs(dia_narrow.moment_at(x)) for x in + [i * 40.0 / 40.0 for i in range(41)]) + m_wide = max(abs(dia_wide.moment_at(x)) for x in + [i * 40.0 / 40.0 for i in range(41)]) + assert m_wide == pytest.approx(m_narrow * 2.0, rel=0.02) + + +def test_legacy_zones_still_use_the_negate_shortcut(): + """A deck with NO manual mode (every zone's `suction_psf` left at its + dataclass default None) keeps the exact old `wind_suction_cases` path -- + `build_wind_suction_cases` must dispatch to it byte for byte when + `wind_zone_mode` is left at its own default 'legacy'. + """ + panel = strand_wall(length_ft=40.0) + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0, 40.0], + wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')]) + assert inputs.wind_zone_mode == 'legacy' + res = inplace.run(panel, inputs) + key_xs = res.cases['D'].xs + dispatched = inplace.build_wind_suction_cases(panel, inputs, key_xs, + res.cases) + direct = inplace.wind_suction_cases(res.cases) + assert dispatched['W'].moment_lbft == direct['W'].moment_lbft + assert dispatched['W'].shear_lb == direct['W'].shear_lb diff --git a/panellift/tests/test_inplace_service.py b/panellift/tests/test_inplace_service.py new file mode 100644 index 0000000..88a9fec --- /dev/null +++ b/panellift/tests/test_inplace_service.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +"""The In-Place SERVICE slice: ASCE 2.4 combos, extreme-fibre stress vs the +cracking / compression limits, deflection, and the P-Delta magnifier. + +Neutral fixtures only -- the parity against the reference tool's real pieces +lives in the scratchpad harness (parity_service.py). These pin the identities +any correct service answer must satisfy: + + * the service set is ASCE 7-16 2.4.1(1)-(5), T at 0.75, no 0.6D+0.6W; + * SC5 = 1.0D + 0.6W assembles exactly 1.0*P_D and 0.6*M_W; + * extreme-fibre tension = M/S - P/A (mild), util = tension / 7.5 sqrt(f'c), + and a >1 util is REPORTED (the tool flags it, docs 6.1); + * the P-Delta magnifier is 1/(1-gamma), gamma = Pu L^2 / (8 EI_eff), and an + instability is NOT JUDGED, named -- never a fabricated number; + * a deflection with no L/n limit is computed but its LIMIT check is NOT + JUDGED, named. +""" +import math + +from panellift import capacity, inplace, section +from panellift.model import Panel + + +def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0, + fc_psi=6000.0): + return Panel(mark='SVC-WALL', length_ft=length_ft, width_ft=width_ft, + thickness_in=thickness_in, unit_weight_pcf=150.0, + fc_psi=fc_psi, fci_psi=3500.0, prestress=None) + + +def _service_cases(x_lo, x_hi, p_lb, m_lbft, wind_factor=0.6): + """A cases dict where SC5 (1.0D + 0.6W) assembles to (p_lb, m_lbft): D + carries the axial, W carries m/0.6 so 0.6*W = m.""" + xs = [x_lo, x_hi] + return { + 'D': inplace.LoadField('D', 'Dead', xs, [p_lb, p_lb], [0.0, 0.0], + [0.0, 0.0]), + 'W': inplace.LoadField('W', 'Wind', xs, [0.0, 0.0], + [m_lbft / wind_factor, m_lbft / wind_factor], + [0.0, 0.0]), + } + + +def _stage(panel): + return inplace.in_place_stage(panel, inplace.InPlaceInputs( + restraint_xs=[0.0, panel.length_ft])) + + +def _sc5(): + return next(c for c in inplace.ASCE_7_16_SERVICE if c.name == 'SC5') + + +# --------------------------------------------------------------- the combos + +def test_service_set_is_the_asce_2_4_basic_combinations(): + names = [c.name for c in inplace.ASCE_7_16_SERVICE] + assert names == ['SC1', 'SC2', 'SC3', 'SC4', 'SC5'] + for c in inplace.ASCE_7_16_SERVICE: + assert c.kind == 'service' + assert c.factors.get('T') == 0.75 + sc5 = _sc5() + assert sc5.factors['D'] == 1.0 and sc5.factors['W'] == 0.6 + # combination (7) 0.6D+0.6W is deliberately NOT in the reference set. + assert not any(c.factors.get('D') == 0.6 for c in inplace.ASCE_7_16_SERVICE) + + +def test_service_combo_assembles_the_right_demand(): + """SC5 = 1.0D + 0.6W puts 1.0*P_D and 0.6*M_W, nothing else.""" + panel = plain_wall() + cases = _service_cases(0.0, panel.length_ft, p_lb=40000.0, m_lbft=100000.0) + pu, mu, vu = inplace.combination_demand(cases, _sc5(), 12.0) + assert abs(pu - 40000.0) < 1e-6 + assert abs(mu - 100000.0) < 1e-6 + assert abs(vu) < 1e-9 + + +# --------------------------------------------------------------- the stress + +def test_service_tension_matches_hand_stress_and_cracking_util(): + """WPA1.0's governing service tension row: a 12 in x 164 in solid mild wall + at M = 260.99 k-ft, P = 36.9 k gives 776.9 psi vs 580.9 (7.5 sqrt 6000).""" + panel = Panel(mark='SVC', length_ft=43.08, width_ft=164.0 / 12.0, + thickness_in=12.0, unit_weight_pcf=150.0, fci_psi=3500.0, + fc_psi=6000.0, prestress=None) + cases = _service_cases(0.0, panel.length_ft, p_lb=36900.0, m_lbft=260990.0) + st = inplace.service_station(panel, _stage(panel), cases, [_sc5()], 25.1) + assert st.why is None + assert abs(st.cracking_limit_psi - 580.9) < 0.5 + assert abs(st.tension_psi - 776.9) < 1.0 + assert abs(st.compression_psi - 814.4) < 1.0 + # util = tension / cracking; over 1.0 here -- the tool FLAGS it, we report it + assert st.tension_util > 1.0 + assert abs(st.tension_util - 776.9 / 580.9) < 0.01 + + +def test_prestress_precompression_term_is_the_reused_primitive(): + """WPA5.0's developed-strand compression row: Pi = 123.3 k, M = 17.22 k-ft, + P = 19.7 k -> 815.6 psi through capacity.extreme_fibre_stresses + (-P/A).""" + A, i_in4, c_in = 313.5, 3161.0, 5.5 + f_form, f_far = capacity.extreme_fibre_stresses( + 123300.0, A, 0.0, c_in, i_in4, 17220.0) + f_form -= 19700.0 / A + f_far -= 19700.0 / A + comp = max(0.0, -f_form, -f_far) + assert abs(comp - 815.6) < 1.0 + + +def test_no_section_dashes_with_a_reason_not_a_zero(): + """A station where NetSection cannot be built is None+why, never a fake 0.""" + panel = plain_wall(thickness_in=0.0) + cases = _service_cases(0.0, panel.length_ft, 1000.0, 1000.0) + st = inplace.service_station(panel, _stage(panel), cases, [_sc5()], 5.0) + assert st.why is not None + assert st.tension_util is None and st.compression_util is None + + +def test_cracking_class_t_raises_the_limit_to_12_sqrt_fc(): + stage = _stage(plain_wall(fc_psi=6000.0)) + assert abs(inplace.cracking_limit_psi(stage, class_t=False) + - 7.5 * math.sqrt(6000.0)) < 0.1 + assert abs(inplace.cracking_limit_psi(stage, class_t=True) + - 12.0 * math.sqrt(6000.0)) < 0.1 + + +# -------------------------------------------------------------- the P-Delta + +def test_pdelta_magnifier_on_a_hand_case(): + """delta = 1/(1-gamma), gamma = Pu L^2 / (8 EI_eff), EI_eff = 0.875 Ec Ig.""" + panel = plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0, + fc_psi=6000.0) + pu, span_ft = 50000.0, 30.0 + m = inplace.pdelta_magnifier(panel, _stage(panel), span_ft, pu, 15.0, + beta_d=0.0) + ec = inplace.ec_aci_psi(6000.0, 150.0) + ig = section.NetSection(panel, 15.0).iz_in4 + ei = 0.875 * ec * ig + gamma = pu * (span_ft * 12.0) ** 2 / (8.0 * ei) + assert abs(m.gamma - gamma) < 1e-9 + assert abs(m.magnifier - 1.0 / (1.0 - gamma)) < 1e-9 + assert m.magnifier > 1.0 + + +def test_pdelta_reports_instability_not_a_fake_number(): + panel = plain_wall(length_ft=60.0, width_ft=4.0, thickness_in=6.0, + fc_psi=5000.0) + m = inplace.pdelta_magnifier(panel, _stage(panel), 60.0, 5.0e6, 30.0) + assert m.magnifier is None + assert m.why and 'stability' in m.why + + +def test_tension_axial_does_not_magnify(): + panel = plain_wall(fc_psi=6000.0) + m = inplace.pdelta_magnifier(panel, _stage(panel), panel.length_ft, + -30000.0, 15.0) + assert m.magnifier == 1.0 and m.gamma == 0.0 + + +# ------------------------------------------------------------ deflection/run + +def test_run_populates_service_with_deflection_limit(): + panel = plain_wall(length_ft=30.0, width_ft=10.0, fc_psi=6000.0) + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 30.0], + wind_zones=[inplace.LateralPressureZone(0.0, 30.0, 50.0, 'W')], + deflection_limit_ratio=150.0) + res = inplace.run(panel, inputs) + assert res.service is not None and res.service.stations + d = res.service.deflection + assert d.combo == 'SC5' + assert d.delta_wind_in > 0.0 + assert d.delta_total_in is not None and d.delta_total_in >= d.delta_i_in + assert abs(d.limit_in - 30.0 * 12.0 / 150.0) < 1e-6 + assert d.util is not None and d.util > 0.0 + + +def test_deflection_limit_not_supplied_is_not_judged_not_faked(): + panel = plain_wall(length_ft=30.0, width_ft=10.0, fc_psi=6000.0) + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 30.0], + wind_zones=[inplace.LateralPressureZone(0.0, 30.0, 50.0, 'W')]) + res = inplace.run(panel, inputs) + d = res.service.deflection + assert d.delta_total_in is not None # deflection still computed + assert d.util is None # but not judged against a limit + assert d.why and 'deflection limit' in d.why + + +def test_deflection_recovers_the_simple_beam_closed_form(): + """Uniform service wind, pinned-pinned: our double integration of the + moment field gives 5 w L^4 / (384 EI_eff) at midspan.""" + panel = plain_wall(length_ft=20.0, width_ft=8.0, thickness_in=10.0, + fc_psi=6000.0) + press = 40.0 + inputs = inplace.InPlaceInputs( + restraint_xs=[0.0, 20.0], + wind_zones=[inplace.LateralPressureZone(0.0, 20.0, press, 'W')], + deflection_limit_ratio=240.0) + d = inplace.run(panel, inputs).service.deflection + w_lb_per_in = (0.6 * press * panel.width_ft) / 12.0 + l_in = 20.0 * 12.0 + closed = 5.0 * w_lb_per_in * l_in ** 4 / (384.0 * d.ei_eff_lbin2) + assert abs(d.delta_wind_in - closed) / closed < 0.01 diff --git a/panellift/tests/test_layout_memo.py b/panellift/tests/test_layout_memo.py new file mode 100644 index 0000000..c53c181 --- /dev/null +++ b/panellift/tests/test_layout_memo.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +"""The search remembers layouts it has already costed, and two dead branches. + +`_peak_psi` is the inner loop of the whole package -- 884 calls on the median +job of the reference sweep, 15 ms each, 93% of the run. 17% of those calls ask +for a layout the search has already costed: the coarse sweep and the first +refinement pass over the same stations, and each of the three refinement sweeps +re-tries every line's CURRENT position before deciding it cannot improve on it. + +Memoising is only safe because the function is pure, and it is pure for a +reason that has to stay true: everything it reads apart from `xs` lives on the +frame, and the frame holds the parts of the problem that DO NOT MOVE when a +pick moves -- the load profile, the section, the station list. The first test +below is the one that matters; it is what catches somebody later making the +frame depend on the positions. +""" +import dataclasses + +import pytest + +from panellift import layout, section, statics +from conftest import LENGTH_FT, aspire_pockets, make_panel + + +def _frame(panel, stage, rigging, counts=(2, 2)): + return layout._make_frame(panel, stage, rigging, list(counts)) + + +# ------------------------------------------------------------- it is pure + +def test_the_frame_does_not_depend_on_where_the_picks_are(solid_panel, + stripping, rolling): + """The memo's whole licence. If costing a layout could change the frame, + a remembered answer would be an answer to a different question.""" + frame = _frame(solid_panel, stripping, rolling) + before = (list(frame.inv_s), list(frame.jumps), list(frame.counts), + list(frame.profile.w), frame.profile.dx) + for xs in ([4.0, 28.0], [8.0, 24.0], [2.0, 30.0], [15.0, 16.0]): + layout._peak_psi(frame, xs) + after = (list(frame.inv_s), list(frame.jumps), list(frame.counts), + list(frame.profile.w), frame.profile.dx) + assert before == after + + +def test_a_remembered_answer_is_the_answer(solid_panel, stripping, rolling): + """Bit-identical, not merely close. The search compares improvements at + 1e-9, so a memo that returned a differently-rounded number could pick a + different layout.""" + frame = _frame(solid_panel, stripping, rolling) + first = layout._peak_psi(frame, [6.0, 26.0]) + again = layout._peak_psi(frame, [6.0, 26.0]) + assert again == first + + fresh = _frame(solid_panel, stripping, rolling) + assert layout._peak_psi(fresh, [6.0, 26.0]) == first + + +def test_the_memo_actually_hits(solid_panel, stripping, rolling): + """A cache nobody hits is a dict that costs memory and buys nothing.""" + frame = _frame(solid_panel, stripping, rolling) + layout._peak_psi(frame, [6.0, 26.0]) + assert len(frame.memo) == 1 + layout._peak_psi(frame, [6.0, 26.0]) + assert len(frame.memo) == 1 # hit, not a second entry + layout._peak_psi(frame, [7.0, 26.0]) + assert len(frame.memo) == 2 + + +def test_a_real_search_repeats_itself_enough_to_be_worth_it(solid_panel, + stripping, + rolling): + """Measured 17% repeats on the reference sweep; this pins that the + repetition is structural and not a property of one job file.""" + calls = [] + real = layout._peak_psi + + def counting(frame, xs): + calls.append(tuple(xs)) + return real(frame, xs) + + layout._peak_psi = counting + try: + layout.best_stations(solid_panel, stripping, rolling, + start=[6.0, 26.0], counts=[2, 2], + free=[True, True]) + finally: + layout._peak_psi = real + assert len(calls) > len(set(calls)) + + +def test_an_unbuildable_layout_is_remembered_too(solid_panel, stripping, + rolling): + """Two lines on one station raise out of group_lifters and cost inf. That + is an answer like any other and re-deriving it is the same wasted solve.""" + frame = _frame(solid_panel, stripping, rolling) + value = layout._peak_psi(frame, [10.0, 10.0]) + assert value == float('inf') + assert frame.memo[(10.0, 10.0)] == float('inf') + + +def test_the_memo_is_bounded(solid_panel, stripping, rolling, monkeypatch): + """A pathological piece must not turn a cache into a memory problem. + Overflowing costs a recomputation, never a different answer.""" + monkeypatch.setattr(layout, '_MEMO_MAX', 2) + frame = _frame(solid_panel, stripping, rolling) + for x in (4.0, 5.0, 6.0, 7.0, 8.0): + layout._peak_psi(frame, [x, 26.0]) + assert len(frame.memo) == 2 + # and the answers are still right past the cap + assert layout._peak_psi(frame, [8.0, 26.0]) == \ + layout._peak_psi(_frame(solid_panel, stripping, rolling), [8.0, 26.0]) + + +# --------------------------------------------- the search answer is unchanged + +@pytest.mark.parametrize('with_pockets', [False, True]) +def test_the_recommendation_is_what_it_was_without_the_memo( + with_pockets, stripping, rolling, monkeypatch): + """The memo must be invisible in the answer. Run the same search with the + cache disabled (cap 0) and demand the same stations to the last bit.""" + panel = make_panel(blockouts=aspire_pockets() if with_pockets else None) + + def search(): + return layout.best_stations(panel, stripping, rolling, + start=[6.0, 26.0], counts=[2, 2], + free=[True, True]) + + monkeypatch.setattr(layout, '_MEMO_MAX', 0) + xs_off, best_off = search() + monkeypatch.setattr(layout, '_MEMO_MAX', 200000) + xs_on, best_on = search() + + assert xs_on == xs_off + assert best_on == best_off + + +# ------------------------------------------------- the branch that was deleted + +def test_the_section_is_solved_on_exactly_the_diagram_stations(solid_panel, + stripping, + rolling): + """`_peak_psi` used to carry a guard re-solving the section when the + station list "changed under us". It could not fire: `_make_frame` builds + the station list out of the same profile and the same discontinuities that + `diagram()` does, from the same expression. The guard is gone; this is + what it was guarding, asserted where it can actually fail. + + A dead branch in the hottest loop in the package is a branch nobody can + test, and the comment on it claimed a thing that could not happen. + """ + for panel in (solid_panel, make_panel(blockouts=aspire_pockets())): + frame = _frame(panel, stripping, rolling) + for xs in ([6.0, 26.0], [4.0, 28.0], [10.0, 22.0]): + lifters = [] + for i, (x, n) in enumerate(zip(xs, frame.counts)): + for j in range(n): + lifters.append(layout.Lifter( + mark='r{0}.{1}'.format(i, j), family='?', + x=x, y=0.0, role='strip')) + staged = dataclasses.replace(panel, lifters=lifters) + rig = statics.group_lifters(staged, rolling, role='strip') + statics.solve_rolling(rig, frame.profile) + dia = statics.diagram(rig, frame.profile, + extra_stations=frame.jumps) + assert len(dia.xs) == len(frame.inv_s) + + +def test_the_frame_carries_no_spare_lifters(): + """`_Frame._spares` claimed a reuse optimisation that did not exist -- + nothing ever read it or wrote it. A field whose comment describes code + that is not there is worse than no field.""" + names = [f.name for f in dataclasses.fields(layout._Frame)] + assert '_spares' not in names + assert 'memo' in names diff --git a/panellift/tests/test_layout_pinning.py b/panellift/tests/test_layout_pinning.py new file mode 100644 index 0000000..0898eaf --- /dev/null +++ b/panellift/tests/test_layout_pinning.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +"""Two constraints, not one, and they are not the same inserts. + +A panel erected on its END carries its anchors in that end: they cannot move +along the piece, and where they sit across it is still a choice. + +A panel erected on its long EDGE carries them along that edge: they may slide +freely down the length, and they may not come off the edge. + +Collapsing both into one "pinned" flag gets one of the two wrong, and the +wrong one is a recommendation to put an anchor where the piece has no edge. +""" +import dataclasses + +import pytest + +from conftest import LENGTH_FT, WIDTH_FT, make_panel +from panellift import cli, layout, statics +from panellift.model import Lifter, Rigging, Stage + + +def build(anchor_kwargs, anchor_xs=(LENGTH_FT,), anchor_ys=None): + panel = make_panel() + face = [dataclasses.replace(l, capacity_lb=12000.0) + for l in panel.lifters if l.x < 0.5 * LENGTH_FT] + anchors = [] + for n, x in enumerate(anchor_xs): + for m, y in enumerate(anchor_ys or (0.25 * WIDTH_FT, 0.75 * WIDTH_FT)): + anchors.append(Lifter( + mark='E{0}{1}'.format(n, m), family='EDGE_ANCHOR', x=x, y=y, + role='strip', capacity_lb=7690.0, shear_capacity_lb=4010.0, + on_edge=True, **anchor_kwargs)) + return dataclasses.replace(panel, lifters=face + anchors, + weight_lb=35000.0) + + +def recommend(panel, stage, rigging): + a = statics.analyse(panel, stage, rigging, role='strip') + return layout.recommend([(a, rigging, [])]) + + +def row_at(rec, x, tol=0.5): + return [r for r in rec.rows if abs(r.x - x) < tol][0] + + +# ------------------------------------------------- erected on its end + +def test_an_end_anchor_row_holds_its_station(stripping, rolling): + rec = recommend(build({'pinned': True}), stripping, rolling) + row = row_at(rec, LENGTH_FT) + assert row.to_x == pytest.approx(LENGTH_FT) + assert row.pinned and not row.pinned_y + + +def test_an_end_anchor_row_may_still_be_moved_across_the_width(stripping, + rolling): + """Being in the end says nothing about where across the piece it sits. + + Drawn hard against one side, so there is something to move: the pair keeps + its spacing and is brought back onto the centreline. + """ + rec = recommend(build({'pinned': True}, anchor_ys=(1.0, 2.0)), + stripping, rolling) + moves = [m for m in rec.moves if m.family == 'EDGE_ANCHOR'] + assert all(m.dx_in == pytest.approx(0.0) for m in moves) + assert all(abs(m.dy_in) > 0.5 for m in moves) + assert sum(m.to_y for m in moves) / len(moves) == pytest.approx( + WIDTH_FT / 2.0) + + +# ------------------------------------------- erected on its long edge + +def test_an_edge_anchor_row_is_not_lifted_off_its_edge(stripping, rolling): + """The one that was broken: a horizontal panel's anchors live on the top + edge, and recommending a y in the middle of the panel asks for an anchor + where there is no edge to cast it into.""" + rec = recommend(build({'pinned_y': True}, + anchor_xs=(8.0, 24.0), + anchor_ys=(WIDTH_FT, WIDTH_FT)), + stripping, rolling) + for move in [m for m in rec.moves if m.family == 'EDGE_ANCHOR']: + assert move.to_y == pytest.approx(WIDTH_FT) + assert move.dy_in == pytest.approx(0.0) + + +def test_an_edge_anchor_row_may_still_slide_along_the_piece(stripping, + rolling): + """Sliding along the edge is the one degree of freedom it has, and it is + exactly the one the search exists to use.""" + rec = recommend(build({'pinned_y': True}, + anchor_xs=(1.0,), anchor_ys=(WIDTH_FT, WIDTH_FT)), + stripping, rolling) + row = row_at(rec, 1.0) + assert not row.pinned and row.pinned_y + assert row.to_x != pytest.approx(1.0) + + +# ------------------------------------------------------ the job file + +def test_pinned_y_survives_the_job_file(): + """It is written by the Revit side; if the reader drops it the constraint + is silently gone and only a horizontal panel would ever show it.""" + job = { + 'schema': cli.SCHEMA, + 'panel': { + 'mark': 'H1', 'length_ft': 30.0, 'width_ft': 8.0, + 'thickness_in': 8.0, 'weight_lb': 20000.0, + 'lifters': [ + {'mark': 'A', 'family': 'F', 'x': 5.0, 'y': 8.0, + 'pinned_y': True, 'on_edge': True, 'capacity_lb': 7690.0}, + {'mark': 'B', 'family': 'F', 'x': 25.0, 'y': 8.0, + 'pinned_y': True, 'on_edge': True, 'capacity_lb': 7690.0}, + ], + }, + 'stages': [{'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0}], + } + panel, _rig, _cases = cli.analyse_job(job) + assert all(l.pinned_y and not l.pinned for l in panel.lifters) diff --git a/panellift/tests/test_mild_flexure.py b/panellift/tests/test_mild_flexure.py new file mode 100644 index 0000000..ea700bb --- /dev/null +++ b/panellift/tests/test_mild_flexure.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +"""The mild-reinforced flexural path: capacity.mild_flexure_at. + +The reference tool (Eriksson Wall) computes a Whitney-block flexural capacity +for a NON-prestressed (mild-steel) wall panel -- and for a COMBINED piece -- +which `capacity_at` dashes ('no prestressing on this piece'). This pins that +path, which is additive: `capacity_at` and the strand-only `handling_flexure_at` +are untouched, and a pure-strand piece never reaches the mild solver. + +Every number in the hand-check section is HAND COMPUTED, with the arithmetic in +the comment beside it, on a rectangular section chosen so the closed forms are +exact: + + b = 48 in. t = 8 in. f'c = 4000 psi w_c = 150 pcf fy = 60 ksi + +f'c = 4000 is the corner of ACI Table 22.2.2.4.3, so beta1 = 0.85 with no +interpolation; w_c = 150 puts lambda at 1.0. Es = 29000 ksi (mild). + +The parity against the reference tool's own golden phi*Mn (SWA1.0 172.1 k-ft, +WPA1.0-12in 480.1 k-ft) lives in scratchpad/parity_check.py, off the client +decks; this file is the reproducible-on-paper unit check. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, 'src')) + +from panellift import capacity, pci # noqa: E402 +from panellift.model import (Panel, Prestress, Reinforcement, # noqa: E402 + Stage, Strand) + + +B_IN = 48.0 +T_IN = 8.0 +FC = 4000.0 + + +def mild_panel(layers, width_ft=B_IN / 12.0, thickness_in=T_IN): + return Panel(mark='MILD-1', length_ft=32.0, width_ft=width_ft, + thickness_in=thickness_in, unit_weight_pcf=150.0, + fci_psi=FC, fc_psi=FC, reinforcement=layers) + + +def one_layer(area_in2, z_in, fy=60.0, count=1): + return Reinforcement(kind='bar', label='M', area_in2=area_in2, z_in=z_in, + grade_ksi=fy, count=count) + + +STAGE = Stage(name='mild', multiplier=1.0, fc_psi=FC) + + +# ---------------------------------------------------- 1. hand calculations + +def test_mild_mn_no_axial_matches_the_hand_calculation(): + """One layer As = 2.0 in^2 at d = 6.0 in (z = t - d = 2.0), fy = 60, Pu = 0. + + T = As*fy = 2.0 * 60,000 = 120,000 lb (yields) + a = T/(0.85 f'c b) = 120,000/(0.85*4000*48) = 0.735294 in + c = a/beta1 = 0.735294/0.85 = 0.865052 in + eps_t = 0.003*(6 - 0.865052)/0.865052 = 0.017808 -> phi 0.90 + M_n = T*(d - a/2) = 120,000*(6 - 0.367647) = 675,882.4 lb-in + = 56,323.53 lb-ft + phi*M_n = 0.9 * 56,323.53 = 50,691.18 lb-ft + """ + # sagging (+M): compression on the z=t face, so d = t - z = 8 - 2 = 6. + mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]), + STAGE, x=16.0, moment_lbft=+10000.0) + assert abs(mp.as_in2 - 2.0) < 1e-9 + assert abs(mp.a_in - 0.735294) < 1e-4 + assert abs(mp.c_in - 0.865052) < 1e-4 + assert abs(mp.strain_t - 0.017808) < 1e-5 + assert mp.phi == pci.PHI_FLEXURE_TENSION_CONTROLLED == 0.90 + assert abs(mp.mn_lbft - 56323.53) < 1.0 + assert abs(mp.phi_mn_lbft - 50691.18) < 1.0 + assert abs(mp.m_over_phi_mn - 10000.0 / 50691.18) < 1e-6 + + +def test_mild_mn_with_axial_shifts_the_neutral_axis_and_raises_mn(): + """Same layer, now with Pu = 50,000 lb COMPRESSION on the P-M surface. + + Equilibrium C - Pu - T = 0, steel still yields, so + C = Pu + T = 50,000 + 120,000 = 170,000 lb + a = C/(0.85 f'c b) = 170,000/163,200 = 1.041667 in + c = a/beta1 = 1.041667/0.85 = 1.225490 in + M_n = C*(c - a/2) - T*(c - d) - Pu*(c - Cy), Cy = t/2 = 4 + = 170,000*0.704657 - 120,000*(-4.774510) - 50,000*(-2.774510) + = 119,791.7 + 572,941.2 + 138,725.5 = 831,458.4 lb-in + = 69,288.2 lb-ft + phi*M_n = 0.9 * 69,288.2 = 62,359.4 lb-ft + + The axial compression deepens the block and raises M_n -- the P-M coupling + (docs sec.4.2). A tool that ignored Pu would report the 56.3 k-ft above. + """ + mp = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]), + STAGE, x=16.0, moment_lbft=+10000.0, + pu_lb=50000.0) + assert abs(mp.a_in - 1.041667) < 1e-4 + assert abs(mp.c_in - 1.225490) < 1e-4 + assert abs(mp.mn_lbft - 69288.2) < 1.0 + assert abs(mp.phi_mn_lbft - 62359.4) < 1.0 + # and it is strictly larger than the no-axial capacity + base = capacity.mild_flexure_at(mild_panel([one_layer(2.0, z_in=2.0)]), + STAGE, x=16.0, moment_lbft=+10000.0) + assert mp.mn_lbft > base.mn_lbft + + +def test_two_symmetric_layers_reproduce_the_SWA_style_couple(): + """(2)#11 at d-CG 3 and 9 in a 12 in x 14 ft solid section, f'c = 6 ksi, + Pu = 21.1 k hogging -- the SWA1.0 shape. Golden phi*Mn = 172.1 k-ft, and + the strain-compat couple here lands at 171.1 k-ft (-0.6%). Kept as a + reproducible echo of the parity harness inside the suite.""" + panel = Panel(mark='SWA1.0', length_ft=32.0, width_ft=14.0, + thickness_in=12.0, unit_weight_pcf=150.0, + fci_psi=2500.0, fc_psi=6000.0, + reinforcement=[one_layer(1.56, z_in=3.0, count=2), + one_layer(1.56, z_in=9.0, count=2)]) + stage = Stage(name='inplace', multiplier=1.0, fc_psi=6000.0) + mp = capacity.mild_flexure_at(panel, stage, x=16.0, moment_lbft=-100300.0, + pu_lb=21100.0) + assert abs(mp.as_in2 - 6.24) < 1e-9 + assert mp.phi == 0.90 + assert abs(mp.phi_mn_lbft / 1000.0 - 172.1) / 172.1 < 0.03 # within 3% + assert abs(mp.m_over_phi_mn - 0.58) < 0.02 + + +# ------------------------------------------------ 2. the strain-based phi + +def test_phi_uses_the_MILD_075_floor_not_the_prestressed_065(): + """A heavily reinforced layer drops eps_t into the transition band, so phi + is between the floor and 0.90 -- and the floor is the MILD 0.75, read + through the SAME phi_flexure_from_strain helper (docs sec.2.2).""" + mp = capacity.mild_flexure_at(mild_panel([one_layer(8.0, z_in=2.0)]), + STAGE, x=16.0, moment_lbft=+10000.0) + assert pci.PHI_FLEXURE_COMPRESSION_CONTROLLED_MILD == 0.75 + assert capacity.PHI_FLEXURE_MILD_FLOOR == 0.75 + # eps_t is in the 0.002..0.005 transition, so phi is strictly between + assert 0.002 < mp.strain_t < 0.005 + assert 0.75 < mp.phi < 0.90 + # and it is exactly the helper's value on the mild floor + assert abs(mp.phi - capacity.phi_flexure_from_strain( + mp.strain_t, phi_min=0.75)) < 1e-12 + # the prestressed 0.65 floor would give a strictly lower phi here + assert capacity.phi_flexure_from_strain(mp.strain_t, phi_min=0.65) < mp.phi + + +# ------------------------------------------------------- 3. combined piece + +def test_combined_strand_plus_mild_credits_both(): + """A piece with BOTH strand and mild steel carries them on one P-M surface; + adding a strand group to a mild section raises M_n and flags combined.""" + mild_only = mild_panel([one_layer(2.0, z_in=2.0)]) + strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153, + initial_pull_lb=30000.0) for i in range(4)] + combined = mild_panel([one_layer(2.0, z_in=2.0)]) + combined.prestress = Prestress(strands=strands, loss_pct=10.0, + diameter_in=0.5, transfer_length_in=30.0) + mp_mild = capacity.mild_flexure_at(mild_only, STAGE, x=16.0, + moment_lbft=+10000.0) + mp_comb = capacity.mild_flexure_at(combined, STAGE, x=16.0, + moment_lbft=+10000.0) + assert mp_mild.combined is False + assert mp_comb.combined is True + # both bars are on the tension side (z=2 -> d=6 sagging), so the extra + # strand tension raises the couple + assert mp_comb.mn_lbft > mp_mild.mn_lbft + # as_in2 counts MILD only; the strand rides its own layer + assert abs(mp_comb.as_in2 - 2.0) < 1e-9 + + +# ----------------------------------------------- 4. could-not-judge, dashes + +def test_a_piece_with_no_mild_steel_says_so_rather_than_computing_zero(): + mp = capacity.mild_flexure_at(mild_panel([]), STAGE, x=16.0, + moment_lbft=+10000.0) + assert mp.phi_mn_lbft is None + assert mp.mn_lbft is None + assert 'no mild reinforcement' in mp.why + + +def test_has_mild_reinforcement_predicate(): + assert capacity.has_mild_reinforcement( + mild_panel([one_layer(2.0, z_in=2.0)])) is True + assert capacity.has_mild_reinforcement(mild_panel([])) is False + # a strand layer is not mild + strand_layer = Reinforcement(kind='strand', label='S', area_in2=0.153, + z_in=2.0, grade_ksi=270.0, count=4, + initial_pull_lb=30000.0) + assert capacity.has_mild_reinforcement(mild_panel([strand_layer])) is False + + +# ------------------------------- 5. the strand-only path is byte-unchanged + +def test_flexure_at_leaves_a_pure_strand_piece_on_the_existing_path(): + """flexure_at must not route a strand-only piece through the mild solver. + On such a piece it returns EXACTLY handling_flexure_at(capacity_at(...)) -- + the additive path the task requires to stay byte-for-byte unchanged.""" + strands = [Strand(y=0.5 + i, z_in=2.0, area_in2=0.153, + initial_pull_lb=30000.0) for i in range(4)] + panel = Panel(mark='STRAND-1', length_ft=32.0, width_ft=B_IN / 12.0, + thickness_in=T_IN, unit_weight_pcf=150.0, + fci_psi=FC, fc_psi=FC, + prestress=Prestress(strands=strands, loss_pct=10.0, + diameter_in=0.5, transfer_length_in=30.0)) + assert capacity.has_mild_reinforcement(panel) is False + cap = capacity.capacity_at(panel, STAGE, 16.0, 10000.0, 0.0) + want = capacity.handling_flexure_at(cap, 10000.0) + got = capacity.flexure_at(panel, STAGE, 16.0, 10000.0, cap=cap) + assert got == want + assert got[1] is not None # it really did compute one + + +# --------------------- 6. from_ft/to_ft range (PANELANALYSIS-4 mild_bars) + +def test_layer_with_a_range_contributes_only_inside_it(): + """A `Reinforcement` carrying `from_ft`/`to_ft` is present only there -- + phi*Mn INSIDE the run must exceed phi*Mn at a station OUTSIDE it (the + outside station falls back to whatever other steel is on the panel, here + none at all, so it dashes with 'no mild reinforcement'). + """ + layer = Reinforcement(kind='bar', label='M', area_in2=2.0, z_in=2.0, + grade_ksi=60.0, count=1, from_ft=10.0, to_ft=20.0) + panel = mild_panel([layer], width_ft=B_IN / 12.0, thickness_in=T_IN) + + inside = capacity.mild_flexure_at(panel, STAGE, x=15.0, + moment_lbft=+10000.0) + assert inside.why is None + assert inside.phi_mn_lbft is not None + assert inside.as_in2 == pytest.approx(2.0) + + outside = capacity.mild_flexure_at(panel, STAGE, x=25.0, + moment_lbft=+10000.0) + assert outside.why == 'no mild reinforcement on this piece' + assert outside.phi_mn_lbft is None + + +def test_layer_with_no_range_is_unchanged_everywhere(): + """`from_ft`/`to_ft` both None (every layer before this field existed) + behaves identically at two different stations -- the byte-identical + guarantee for every deck that never sets a range.""" + layer = Reinforcement(kind='bar', label='M', area_in2=2.0, z_in=2.0, + grade_ksi=60.0, count=1) + panel = mild_panel([layer], width_ft=B_IN / 12.0, thickness_in=T_IN) + a = capacity.mild_flexure_at(panel, STAGE, x=4.0, moment_lbft=+10000.0) + b = capacity.mild_flexure_at(panel, STAGE, x=28.0, moment_lbft=+10000.0) + assert a.phi_mn_lbft == pytest.approx(b.phi_mn_lbft) + assert a.as_in2 == pytest.approx(b.as_in2) == pytest.approx(2.0) + + +def test_mild_bars_with_depth_raise_phi_mn_over_bars_without(): + """A depth-bearing mild-bar layer measurably raises phi*Mn over a bare + section (no reinforcement at all) at the same station -- pins that a + `mild_bars` row with a real depth genuinely enters the capacity, not + just parses without error.""" + bare = mild_panel([], width_ft=B_IN / 12.0, thickness_in=T_IN) + reinforced = mild_panel( + [Reinforcement(kind='bar', label='#4', area_in2=4.8, z_in=2.0, + grade_ksi=60.0, count=1)], + width_ft=B_IN / 12.0, thickness_in=T_IN) + + bare_pt = capacity.mild_flexure_at(bare, STAGE, x=16.0, + moment_lbft=+10000.0) + reinforced_pt = capacity.mild_flexure_at(reinforced, STAGE, x=16.0, + moment_lbft=+10000.0) + assert bare_pt.why == 'no mild reinforcement on this piece' + assert bare_pt.phi_mn_lbft is None + assert reinforced_pt.phi_mn_lbft is not None + assert reinforced_pt.phi_mn_lbft > 0.0 diff --git a/panellift/tests/test_no_recommend.py b/panellift/tests/test_no_recommend.py new file mode 100644 index 0000000..4f97bca --- /dev/null +++ b/panellift/tests/test_no_recommend.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +"""Switching the position search off, and saying so. + +The search is 93% of a run -- measured 2026-08-30 across the 184-piece +reference sweep: 12.6 s of a 13.6 s median, and 41 minutes of a 42 minute +batch. `--no-recommend` is what makes a batch usable. + +Everything here is about the same one risk. A report whose "Recommended +positions" section had simply gone missing reads exactly like a report on a +piece with nothing to recommend -- and the report's own words for that case +are "the inserts as drawn are already at the best position this search can +find", which is a sentence about a search nobody ran. Same rule as a stage +with `enabled: false`: NOT RUN is never silent. + +The other half is that the default must not move. `searched` is written into +the JSON summary only when the search was skipped, the same way the job writer +emits `enabled` only when it is false, so a run with no flag produces the same +bytes it did before the flag existed. +""" +import io +import json + +from panellift import cli, layout, report +from test_cli import aspire_job + + +def _run(tmp_path, *flags): + """The CLI, end to end, on a real job file. Returns (code, out, html).""" + tmp_path.mkdir(parents=True, exist_ok=True) + job_path = tmp_path / 'job.json' + out_path = tmp_path / 'report.html' + with io.open(str(job_path), 'w', encoding='utf-8') as fh: + fh.write(json.dumps(aspire_job())) + code = cli.main([str(job_path), '-o', str(out_path)] + list(flags)) + with io.open(str(out_path), 'r', encoding='utf-8') as fh: + return code, out_path, fh.read() + + +# ----------------------------------------------------- the flag does its job + +def test_no_recommend_leaves_every_verdict_alone(tmp_path, capsys): + """The flag drops an ANSWER, never a CHECK. If it could change a verdict + it would be a different calculation, not a faster one.""" + with_search = _run(tmp_path / 'a', ) + capsys.readouterr() + without = _run(tmp_path / 'b', '--no-recommend') + capsys.readouterr() + assert with_search[0] == without[0] + + job = aspire_job() + _p, _r, cases = cli.analyse_job(job) + full = cli.summarise(cases, layout.recommend(cases)) + skipped = cli.summarise(cases, layout.not_searched()) + assert full['verdict'] == skipped['verdict'] + assert [s['verdict'] for s in full['stages']] == \ + [s['verdict'] for s in skipped['stages']] + assert [s['peak_tension_psi'] for s in full['stages']] == \ + [s['peak_tension_psi'] for s in skipped['stages']] + + +def test_no_recommend_is_actually_faster(tmp_path, capsys): + """Not a behaviour test -- a claim test. The flag exists for one reason + and a version of it that still searched would pass every other test + here.""" + import time + job = aspire_job() + _p, _r, cases = cli.analyse_job(job) + + t0 = time.perf_counter() + layout.recommend(cases) + searched = time.perf_counter() - t0 + + t0 = time.perf_counter() + layout.not_searched() + skipped = time.perf_counter() - t0 + + assert skipped < searched / 10.0 + + +# ------------------------------------------------------- and says it did it + +def test_the_report_says_the_positions_were_not_computed(tmp_path, capsys): + """Present, closed, flagged NOT RUN, honest when opened. + + Skipping the search is the ordinary way this runs now, so the section is + no longer a warning box on an open heading -- it is a collapsed + disclosure like every other section on the page, carrying NOT RUN on the + line you can read without opening it. + """ + _code, _path, html = _run(tmp_path, '--no-recommend') + capsys.readouterr() + assert 'Recommended positions' in html # the section stays + assert 'Not computed' in html + # the closed summary line says so, and nothing shouts + assert 'not run' in html + assert 'class="caveat"' not in html + # and none of the things a search that RAN would have said + assert 'already at the best position' not in html + assert 'recommended x' not in html + + +def test_the_not_run_section_is_a_collapsed_details(tmp_path, capsys): + """The engineer's own words: it is always there, and closed by default. + + Both halves matter. A section that vanishes on a skipped search reads as + a piece with nothing to move; a section that opens itself puts a notice + nobody asked for above the numbers they did. + """ + _code, _path, skipped_html = _run(tmp_path / 'skipped', '--no-recommend') + capsys.readouterr() + _code, _path, ran_html = _run(tmp_path / 'ran') + capsys.readouterr() + + for html, tagged in ((skipped_html, True), (ran_html, False)): + i = html.index('Recommended positions') + head = html[max(0, i - 200):i] + # inside a
              , and never an open one + assert '
              ' in head + assert '
              Recommended positions' not in html + assert ('notrun-tag">not run' in html) is tagged + + +def test_a_skipped_search_never_reads_as_nothing_to_move(): + """The exact confusion this is all here to prevent.""" + ran = report._recommendation(layout.Recommendation()) + skipped = report._recommendation(layout.not_searched()) + assert 'already at the best position' in ran + assert 'already at the best position' not in skipped + assert 'did not run' in skipped + + +def test_the_stdout_line_says_so_too(tmp_path, capsys): + """A batch scrolling past has to be visible without opening a report.""" + _run(tmp_path, '--no-recommend') + out = capsys.readouterr().out + assert 'NOT COMPUTED' in out + + _run(tmp_path / 'again') + out = capsys.readouterr().out + assert 'NOT COMPUTED' not in out + + +def test_the_summary_carries_searched_false(tmp_path, capsys): + job = aspire_job() + _p, _r, cases = cli.analyse_job(job) + skipped = cli.summarise(cases, layout.not_searched()) + assert skipped['recommendation']['searched'] is False + assert skipped['recommendation']['note'] + assert skipped['recommendation']['moves'] == [] + + +def test_searched_is_absent_when_the_search_ran(): + """Written only when it was skipped -- the same rule the job writer + follows for `enabled`. Absent means it ran, which is what every summary + written before this flag existed means. + """ + job = aspire_job() + _p, _r, cases = cli.analyse_job(job) + full = cli.summarise(cases, layout.recommend(cases)) + assert 'searched' not in full['recommendation'] + assert full['recommendation'].get('searched', True) is True + + +# --------------------------------------------------------- nothing else moved + +def test_a_default_run_is_byte_for_byte_what_it_was(tmp_path, capsys): + """The flag is additive. With nothing asked for, every byte of the + report and of the JSON summary is what it was before the flag existed -- + which is checked here on one job and, out of band, on all 184 of the + reference sweep with the clock frozen.""" + job = aspire_job() + _p, _r, cases = cli.analyse_job(job) + rec = layout.recommend(cases) + + html_default = report.build_many(cases, recommendation=rec) + summary_default = json.dumps(cli.summarise(cases, rec), sort_keys=True) + + # the same call the pre-flag code made, with no recommendation passed in + _p2, _r2, cases2 = cli.analyse_job(aspire_job()) + html_implicit = report.build_many(cases2) + summary_implicit = json.dumps(cli.summarise(cases2), sort_keys=True) + + assert html_default == html_implicit + assert summary_default == summary_implicit + + +def test_the_flag_is_off_unless_asked_for(tmp_path, capsys): + """A default that has to be asked for is a default nobody gets.""" + _code, _path, html = _run(tmp_path) + capsys.readouterr() + assert 'Not computed' not in html diff --git a/panellift/tests/test_notation.py b/panellift/tests/test_notation.py new file mode 100644 index 0000000..ca3fcd9 --- /dev/null +++ b/panellift/tests/test_notation.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +"""Real typography in the HTML report, and why the source strings stay ASCII. + +`report.notate()` and `report.notate_plain()` are the ONE place engineering +shorthand -- "phi-Mn", "5*sqrt(f'c)", "f_se", "60deg" -- becomes real +subscripts, a real radical, a real degree sign. Everywhere else in this +package, in `checks.py`, `capacity.py` and `model.py`, that text stays plain +ASCII on purpose: the SAME strings are written to sweep/problems.txt, printed +to the console, exported into the job/problem JSON, and printed inside +Revit's pyRevit output window under IronPython 2.7 (see +`WillTools.extension/lib/vibe/liftexport.py`). A radical sign or a raw +`` tag in any of those is a crash or mojibake, not a nicer report -- +this project has already been bitten by exactly that shape of bug once, in +the Discord bridge. + +So these tests do two things: pin the rendering (given a known ASCII string, +the HTML/plain output is exactly this), and guard the boundary (the +producers stay ASCII; nothing here should ever need to change if `notate()` +changes). + +Every string tested against notate()/notate_plain() below is copied VERBATIM +from `checks.py`, `capacity.py` or `model.py` -- not invented -- because an +untested token is exactly the "regex over prose" failure this was built to +avoid (`psi` the unit must never become `ψ`; there is no rule for it at +all, on purpose -- see test_units_and_ratios_pass_through_untouched below). +""" +import re + +from conftest import aspire_pockets, make_panel +from panellift import checks, report, statics +from panellift.model import Blockout, Prestress, Rigging, Stage, Strand + + +# --------------------------------------------------------- notate(): HTML + +def test_the_radical_is_real_mathml_with_a_plain_fallback_beside_it(): + """A bare √ glued to a CSS overline is the old half-measure: the + glyph and the border never actually join. RADICAL_FC now has to carry a + real -- drawn as one shape by the browser's own MathML layout -- + AND, right beside it, the plain-Unicode reading a non-MathML viewer gets + instead, because has no fallback of its own.""" + assert report.RADICAL_FC == ( + 'f′' + 'c' + '√f′c') + assert report.RADICAL_FCI == ( + 'f′' + 'ci' + '√f′ci') + # The CSS-overline technique is gone; nothing should still reference it. + assert '.rt{' not in report.CSS + assert 'class="rt"' not in report.RADICAL_FC + # The fallback switch: hidden by default, shown only behind a real + # MathML capability test, never behind a guess or a browser sniff. + assert 'math{display:none}' in report.CSS + assert '@supports (math-depth:0)' in report.CSS + assert '.radical-fallback{display:none}' in report.CSS + + +def test_fc_prime_gets_a_real_prime_and_a_real_subscript(): + # Copied from Stage.tension_limit_text's shape: "5*sqrt(f'c)". + assert report.notate("5*sqrt(f'c)") == '5' + report.RADICAL_FC + assert report.notate("7.5*sqrt(f'c)") == '7.5' + report.RADICAL_FC + # f'ci (stripping strength) is a DIFFERENT subscript and must not be + # truncated to f'c's -- "f'ci" contains "f'c" as a substring. + assert report.notate("f'ci") == 'f′ci' + assert report.notate("f'c") == 'f′c' + # Bare, mid sentence -- from capacity.py's `point.why[col] = "stage + # states no f'c"`. + assert report.notate("stage states no f'c") == ( + 'stage states no f′c') + + +def test_phi_mn_and_phi_vn_are_real_subscripts_not_hyphenated_ascii(): + # From capacity.py's _FLEX_BASIS / _SHEAR_BASIS and checks.py's RATIO_WORD. + assert report.notate('phi-Mn') == 'φMn' + assert report.notate('phi-Vn') == 'φVn' + assert report.notate('M/phi-Mn') == 'M/φMn' + assert report.notate('V/phi-Vn') == 'V/φVn' + # Bare phi, e.g. checks.py's `detail += ' (phi {0:.2f})'`. + assert report.notate('(phi 0.90)') == '(φ 0.90)' + + +def test_a_real_finding_message_renders_with_real_markup(): + """Not a synthetic string -- an actual CapacityPoint.why / + development_note() sentence, copied from capacity.py, run through the + same function the Checks table uses.""" + why = ('within a strand\'s development length of a free end (36.0 in. ' + 'from the end of the piece; l_d 63.7 in.) -- f_ps is 187.9 of ' + '243.4 ksi here, so phi-Mn, which is strand only, tends to zero ' + 'while M_cr does not') + out = report.notate(why) + assert 'ℓd 63.7 in.' in out + assert 'fps is 187.9 of 243.4 ksi' in out + assert 'φMn, which is strand only' in out + assert 'Mcr does not' in out + # And nothing here should ever come back out as raw ASCII shorthand. + assert 'phi-Mn' not in out and 'M_cr' not in out and 'l_d' not in out + + +def test_the_shear_basis_sentence_renders_v_n_equals_v_c(): + # capacity.py's _SHEAR_BASIS, verbatim. + basis = ('phi-Vn here is CONCRETE ALONE -- ACI 318-19 22.5.5.1 ' + 'simplified, no shear reinforcement in the handling direction, ' + 'so V_n = V_c') + out = report.notate(basis) + assert 'φVn here is CONCRETE ALONE' in out + assert 'Vn = Vc' in out + + +def test_aps_in2_in3_and_degrees(): + # checks.py: 'P {0} lb over Aps {1:.2f} in2 ...' + assert report.notate('Aps 0.61 in2') == 'Aps 0.61 in²' + # checks.py: 'S {6} in3' + assert report.notate('S 1,472 in3') == 'S 1,472 in³' + # checks.py: 'sin {4:.0f}deg' and 'the 60deg usually taken as ...' + assert report.notate('sin 60deg') == 'sin 60°' + assert report.notate('below the 60deg usually') == ( + 'below the 60° usually') + + +def test_the_multiplication_idiom_not_the_coordinate_one(): + # checks.py SLING-ANGLE: 'line force is {1:.2f}x the vertical reaction' + assert report.notate('1.03x the vertical reaction') == ( + '1.03× the vertical reaction') + # A coordinate is always written "x=1.23 ft" in this package -- no digit + # immediately in front of the letter -- so it must NOT be touched. + assert report.notate('the pick line at x=1.23 ft') == ( + 'the pick line at x=1.23 ft') + + +def test_units_and_ratios_pass_through_untouched(): + """The owner's own example: psi/ksi/plf/pli/lb/kips are already correct + and must never be touched, and a ratio like 4:1 stays exactly as typed. + There is no rule for any of these in _NOTATION_RULES -- this asserts the + omission, not an exception that could rot.""" + for s in ('444 psi', '270 ksi', '109.4 plf', '80 pli', '34,638 lb', + '8 kips', 'Capacity in Concrete 4:1 SWL', '0.207'): + assert report.notate(s) == report.esc(s) + + +def test_data_is_still_escaped_around_the_markup(): + """A panel mark or a job-authored reason can carry HTML-significant + characters; notate() has to escape those exactly like esc() does, in the + SAME string that also carries real notation.""" + out = report.notate("Tom & Jerry's f'c 4000 psi") + assert '&' in out + assert '<panel>' in out + assert "Jerry's" in out + assert 'f′c' in out + # And never literal angle brackets from the data itself. + assert '' not in out + + +def test_le_ge_and_none_input(): + assert report.notate('a <= b') == 'a ≤ b' + assert report.notate('a >= b') == 'a ≥ b' + assert report.notate(None) == '' + + +# ---------------------------------------------- notate_plain(): SVG / JS text + +def test_plain_mode_never_emits_a_tag(): + """ in an node or in JS textContent renders as four + literal characters, not a subscript -- notate_plain() must never emit + one.""" + samples = ["5*sqrt(f'c)", 'phi-Mn', 'f_t', 'f_se', 'M_cr', 'Aps 0.61 in2', + 'sin 60deg', '1.03x the vertical reaction', '(phi 0.90)'] + for s in samples: + out = report.notate_plain(s) + assert '<' not in out and '>' not in out, (s, out) + assert '&' not in out, (s, out) + + +def test_plain_mode_uses_unicode_subscripts_where_they_exist(): + assert report.notate_plain('f_t') == 'fₜ' # fₜ + assert report.notate_plain('phi-Mn') == 'φMₙ' # φMₙ + assert report.notate_plain("5*sqrt(f'c)") == "5√f′c" + # No Unicode subscript 'c' exists -- f_c degrades to plain "fc", not a + # fabricated subscript and not the bare underscore. + assert report.notate_plain('f_c') == 'fc' + assert '_' not in report.notate_plain('f_c') + + +# ---------------------------------------------------- the architecture edge + +def test_the_source_strings_stay_ascii(): + """The producers -- Stage.tension_limit_text, and a real finding message + -- must still be pure ASCII after this change. Only report.py's renderer + may carry Unicode or HTML; console/sweep/JSON/IronPython read this.""" + stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0, + tension_limit_sqrt_fc=7.5, tension_limit_source='job') + text = stage.tension_limit_text + assert text == "7.5*sqrt(f'c)" + text.encode('ascii') # raises if it is not + + panel = make_panel(blockouts=aspire_pockets()) + analysis = statics.analyse(panel, stage, Rigging(mode='rolling')) + findings = checks.run_all(analysis, Rigging(mode='rolling')) + for f in findings: + f.message.encode('ascii') + + +def test_a_rendered_report_carries_no_raw_ascii_shorthand_in_its_body(): + """End to end: build a real report and check the BODY (not the CSS + comments, which are developer prose, not rendered text) for the ASCII + forms this whole module exists to replace.""" + panel = make_panel(blockouts=aspire_pockets()) + stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0, + tension_limit_sqrt_fc=7.5, tension_limit_source='job') + analysis = statics.analyse(panel, stage, Rigging(mode='rolling')) + findings = checks.run_all(analysis, Rigging(mode='rolling')) + page = report.build(analysis, Rigging(mode='rolling'), findings, + recommendation=False) + body = page[page.index(''):] + for pattern in (r"\bphi-Mn\b", r"\bphi-Vn\b", r"f'c\b", r"f'ci\b", + r"\bf_t\b", r"\bf_c\b", r"\bf_se\b", r"\bf_pu\b", + r"\bf_ps\b", r"\bl_d\b", r"\bl_t\b", r"\bM_cr\b", + r"\bV_n\b", r"\bV_c\b", r"\bAps\b", r"\bin2\b", + r"\bin3\b", r"\bd_b\b", r"\beps_t\b"): + assert not re.search(pattern, body), pattern + # And the positive side of the same assertion: the markup is actually + # there, not just absent. + assert '' in body and report.RADICAL_FC in body + + +# --------------------------------------- _prestress_line() vs. _rows()/notate() + +def _panel_with_a_cut_strand(): + """A panel with real prestress and a blockout that severs the one + strand -- enough to exercise every clause of `_prestress_line()`, + including the `...cut by a blockout` branch.""" + panel = make_panel(blockouts=[ + Blockout(name='OPENING', x0=15.0, y0=1.5, length=2.0, width=1.0)]) + panel.prestress = Prestress( + strands=[Strand(y=2.0, z_in=4.0, area_in2=0.153, + initial_pull_lb=0.153 * 270_000 * 0.75)], + loss_pct=8.0, diameter_in=0.5) + return panel + + +def test_prestress_line_returns_real_markup_never_escaped(): + """`_prestress_line()` builds its own
              // markup and + escapes only the free-form pieces itself (see its own docstring) -- this + pins that raw output. See the end-to-end test below for the regression + it was written to catch: `_rows()` running this same markup through + notate() a second time, printing the literal text "
              " on the page.""" + line = report._prestress_line(_panel_with_a_cut_strand()) + assert '
              ' in line + assert 'ps' in line and '²' in line + assert '1 cut by a blockout' in line + assert '<' not in line and '>' not in line + + +def test_the_rendered_piece_table_prestress_row_is_not_double_escaped(): + """Regression: the Piece table used to build the Prestress row with + `_rows()`, which runs every value through `notate()` -- safe for the + other rows' plain prose, but `_prestress_line()`'s value is already real + HTML it built and escaped itself, so notate() escaped it a SECOND time + and the report printed the literal text "
              " / "" instead of a + line break and bold. Build a whole report and check the actual row.""" + panel = _panel_with_a_cut_strand() + stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0, + tension_limit_sqrt_fc=7.5, tension_limit_source='job') + analysis = statics.analyse(panel, stage, Rigging(mode='rolling')) + findings = checks.run_all(analysis, Rigging(mode='rolling')) + page = report.build(analysis, Rigging(mode='rolling'), findings, + recommendation=False) + + assert '<br>' not in page and '<strong>' not in page + + assert 'Prestress' in page + row = page[page.index('Prestress'):] + row = row[:row.index('') + len('')] + assert '
              ' in row + assert '' in row and 'cut by a blockout' in row diff --git a/panellift/tests/test_pci_fig832.py b/panellift/tests/test_pci_fig832.py new file mode 100644 index 0000000..54c5f77 --- /dev/null +++ b/panellift/tests/test_pci_fig832.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""The beam solver must reproduce PCI Figure 8.3.2 from first principles. + +This is the test the whole design rests on. Figure 8.3.2's coefficients are +not a separate method -- they are the closed-form solution of a uniformly +loaded beam with two overhangs, with the supports placed where the positive +and negative moments come out equal. If our solver cannot land on 0.0214 with +the supports there, it cannot be trusted with the supports somewhere else, +which is the only case anyone actually needs it for. +""" +import math + +from panellift import pci, section, statics +from panellift.model import Stage + +from conftest import ASPIRE, LENGTH_FT, WIDTH_FT + + +def test_optimal_support_fraction_is_the_root_of_the_quadratic(): + a = pci.OPTIMAL_TWO_LINE_FRACTION + assert abs(4 * a ** 2 + 4 * a - 1) < 1e-12 + assert abs(a - 0.20710678) < 1e-7 + + +def test_closed_form_coefficient_is_pcis_0_0214(): + """M = w*(aL)^2/2 with a = (sqrt(2)-1)/2 gives 0.02145, printed as 0.0214.""" + a = pci.OPTIMAL_TWO_LINE_FRACTION + coefficient = a ** 2 / 2.0 + assert abs(coefficient - 0.0214) < 0.0001 + + +def test_solver_reproduces_my_coefficient(solid_panel, rolling): + """The numerical diagram must match 0.0214 w a b^2 to within 0.5%.""" + stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0) + result = statics.analyse(solid_panel, stage, rolling, + reconcile_weight=False) + + w_psf = section.unit_weight_psf(solid_panel) + _, expected = pci.fig_832_four_point(w_psf, WIDTH_FT, LENGTH_FT) + + _, m_pos = result.diagram.max_positive + _, m_neg = result.diagram.max_negative + + assert abs(m_pos - expected) / expected < 0.005 + assert abs(abs(m_neg) - expected) / expected < 0.005 + + +def test_positive_and_negative_moments_are_equal_at_the_optimum(solid_panel, + rolling): + """That equality is the definition of the Figure 8.3.2 layout.""" + stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0) + result = statics.analyse(solid_panel, stage, rolling, + reconcile_weight=False) + _, m_pos = result.diagram.max_positive + _, m_neg = result.diagram.max_negative + assert abs(m_pos - abs(m_neg)) / m_pos < 0.01 + + +def test_unit_weight_matches_the_article(solid_panel): + assert abs(section.unit_weight_psf(solid_panel) - + ASPIRE['w_psf']) < 0.1 + + +def _peak_abs_moment(result): + """The governing moment, whichever sign it has. + + Bunching the picks inboard turns the panel into two long cantilevers, and + then the whole interior hogs -- the largest positive moment is zero, at + the ends. Comparing only sagging would read that as an improvement. + """ + _, positive = result.diagram.max_positive + _, negative = result.diagram.max_negative + return max(abs(positive), abs(negative)) + + +def test_moving_the_supports_inboard_raises_the_governing_moment(solid_panel, + rolling): + """A sanity check that the solver is solving, not returning a constant.""" + stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0) + optimum = _peak_abs_moment( + statics.analyse(solid_panel, stage, rolling, reconcile_weight=False)) + + for lifter in solid_panel.lifters: + lifter.x = LENGTH_FT / 2.0 + math.copysign( + 4.0, lifter.x - LENGTH_FT / 2.0) + moved = _peak_abs_moment( + statics.analyse(solid_panel, stage, rolling, reconcile_weight=False)) + + assert moved > optimum * 1.5 + + +def test_the_optimum_layout_really_is_the_optimum(solid_panel, rolling): + """No other support position beats (sqrt(2)-1)/2. Worth proving once. + + This is what makes the PCI layout a benchmark the report can measure a + detailer's actual layout against, rather than just another arrangement. + """ + stage = Stage(name='unfactored', multiplier=1.0, fc_psi=4000.0) + best = _peak_abs_moment( + statics.analyse(solid_panel, stage, rolling, reconcile_weight=False)) + + for fraction in (0.10, 0.15, 0.18, 0.23, 0.26, 0.30): + left = fraction * LENGTH_FT + for lifter in solid_panel.lifters: + lifter.x = left if lifter.x < LENGTH_FT / 2.0 else LENGTH_FT - left + trial = _peak_abs_moment( + statics.analyse(solid_panel, stage, rolling, + reconcile_weight=False)) + assert trial >= best * 0.999, ( + 'fraction {0} beat the supposed optimum'.format(fraction)) diff --git a/panellift/tests/test_plots.py b/panellift/tests/test_plots.py new file mode 100644 index 0000000..40ad7b4 --- /dev/null +++ b/panellift/tests/test_plots.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +"""The figures have to be the data, and the data has to be readable. + +Two complaints from the engineer using this, both about the same thing -- +a picture that does not answer a question is decoration on a calculation: + + 1. The free body's load was the same drawing on every piece. It was: the + tall stroke at each station came out of `beam_y - 30 - h + h`, so the + load height cancelled itself out and the one stroke that DID scale was + drawn inside the constant one. A panel with a run of openings looked + exactly like a solid slab. `test_two_load_profiles_draw_differently` is + the guard. + + 2. Shear, moment and stress were curves with a peak label and nothing else, + so reading a value at a station meant measuring the page with your eye. + They now carry their own samples and the cursor reads them off, in the + units the engineer states them in: kip, kip-ft, psi. + +The rule underneath both: the report never shows a number the engine did not +compute. The hover values are the plotted samples, converted once at the point +of display, written onto the SVG -- not recalculated in the browser. +""" +import re + +from conftest import aspire_pockets, make_panel +from panellift import checks, layout, report, statics +from panellift.model import Blockout + + +def _analyse(panel, stage, rigging): + return statics.analyse(panel, stage, rigging) + + +def _svg_of(page, title_fragment): + """The one whose title contains this fragment.""" + i = page.index(title_fragment) + return page[page.rindex('', i)] + + +# --------------------------------------------------- 1. the free body is data + +def test_the_load_band_steps_where_the_section_steps(stripping, rolling): + """A blockout takes width out of the piece, so w drops over it. The + drawing has to drop over it too, or it is not the free body of anything. + """ + a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling) + svg = _svg_of(report.free_body(a), 'Free body') + band = re.search(r'= 3 # beam line, full load, reduced load + + +def test_two_load_profiles_draw_differently(stripping, rolling): + """The complaint, as a test. Same piece, same pick, same everything but + the openings -- and before this the two free bodies were identical.""" + solid = _analyse(make_panel(), stripping, rolling) + pocketed = _analyse(make_panel(blockouts=aspire_pockets()), + stripping, rolling) + + a = _svg_of(report.free_body(solid), 'Free body') + b = _svg_of(report.free_body(pocketed), 'Free body') + + band_a = re.search(r']*>([^<]*)<', + svg).group(1) + + +def test_the_segments_carry_their_own_magnitudes(stripping, rolling): + """Per-segment labels, in the unit the profile is in -- plf, factored.""" + panel = make_panel(blockouts=[ + Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)]) + a = _analyse(panel, stripping, rolling) + svg = _svg_of(report.free_body(a), 'Free body') + labels = re.findall(r']*>([^<]*)', svg) + assert len(labels) >= 2 + assert len(set(labels)) >= 2 # not the same number twice + note = re.search(r']*>([^<]*)<', svg).group(1) + assert 'plf factored' in note and ' to ' in note + + +def test_the_load_segments_are_the_profile_not_an_average(stripping, rolling): + """load_segments must return the real w in each run, and cover the piece + end to end -- the free body's numbers are quoted, so they are exact.""" + panel = make_panel(blockouts=[ + Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)]) + a = _analyse(panel, stripping, rolling) + segs = report.load_segments(a.profile) + assert len(segs) == 3 + assert segs[0][0] == 0.0 + assert abs(segs[-1][1] - panel.length_ft) < 1e-9 + for i in range(len(segs) - 1): + assert segs[i][1] == segs[i + 1][0] + assert segs[1][2] < segs[0][2] # the opening lightens the middle + # every value is one the profile actually holds + held = set(a.profile.w) + assert all(w in held for _x0, _x1, w in segs) + + +# ------------------------------------------------- 2 and 3. hover, and units + +def test_the_diagrams_carry_their_samples_in_display_units(stripping, + rolling): + """Shear in kip, moment in kip-ft, stress in psi, x in feet. Converted + once, here, and written onto the plot -- nothing converts in the browser. + """ + a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling) + page = report.build(a, rolling, checks.run_all(a, rolling), + recommendation=False) + + units = re.findall(r'data-yu="([^"]*)" data-yl="([^"]*)"', page) + # 'fₜ' (real Unicode subscript), not 'f_t': this label lands in an + # node and in the hover script's textContent, which parse a + # tag as four literal characters rather than a subscript -- see + # report.notate_plain(). + assert units == [('kip', 'V'), ('kip-ft', 'M'), ('psi', 'fₜ')] + assert page.count('data-xu="ft"') == 3 + + shear_svg = _svg_of(page, 'Shear V(x)') + hy = re.search(r'data-hy="([^"]*)"', shear_svg).group(1).split() + peak_kip = max(abs(float(v)) for v in hy) + peak_lb = max(abs(v) for v in a.diagram.shear_lb) + assert abs(peak_kip - peak_lb / report.LB_PER_KIP) < 0.02 + assert peak_kip < peak_lb # it really was divided + + moment_svg = _svg_of(page, 'Moment M(x)') + hy = re.search(r'data-hy="([^"]*)"', moment_svg).group(1).split() + peak_kipft = max(abs(float(v)) for v in hy) + assert abs(peak_kipft - + max(abs(m) for m in a.diagram.moment_lbft) + / report.LB_PER_KIP) < 0.2 + assert all(len(v.split('.')[1]) == 1 for v in hy) # 1 dp, consistent + + +def test_every_hover_sample_has_a_pixel_and_a_value(stripping, rolling): + """Four arrays of the same length, or the lookup is off by one somewhere + and the tooltip quietly reports the wrong station.""" + a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling) + page = report.build(a, rolling, checks.run_all(a, rolling), + recommendation=False) + for svg in re.findall(r']*class="hoverplot"[^>]*>', page): + got = [len(re.search(k + r'="([^"]*)"', svg).group(1).split()) + for k in ('data-hx', 'data-hy', 'data-px', 'data-py')] + assert len(set(got)) == 1 and got[0] > 10 + assert re.search(r'data-t="([-0-9.]+)"', svg) + assert re.search(r'data-b="([-0-9.]+)"', svg) + + +def test_the_stations_are_feet_to_two_decimals(stripping, rolling): + a = _analyse(make_panel(), stripping, rolling) + svg = _svg_of(report.stress_plot(a), 'Tensile stress') + hx = re.search(r'data-hx="([^"]*)"', svg).group(1).split() + assert all(len(v.split('.')[1]) == 2 for v in hx) + assert float(hx[0]) >= 0.0 + assert float(hx[-1]) <= round(a.panel.length_ft, 2) + 0.01 + + +def test_the_script_is_present_exactly_once(stripping, rolling): + """One document, one listener. build() nested inside build_many() must + not bring a second copy with it.""" + a = _analyse(make_panel(), stripping, rolling) + found = checks.run_all(a, rolling) + + one = report.build(a, rolling, found, recommendation=False) + assert one.count('') == 1 + + nested = report.build(a, rolling, found, recommendation=False, + standalone=False) + assert '') == 1 + assert many.count("querySelectorAll('svg.hoverplot')") == 1 + + +def test_a_page_with_no_script_still_shows_every_curve(stripping, rolling): + """Hover is additive. Strip the script out and the report is exactly the + report it was -- same curves, same peak rules, same disclosures, and the + print overrides untouched. That is the no-JS and the printed case.""" + a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling) + page = report.build(a, rolling, checks.run_all(a, rolling), + recommendation=False) + without = page[:page.index(' + + + diff --git a/tools/deckcheck.py b/tools/deckcheck.py new file mode 100644 index 0000000..68af7d1 --- /dev/null +++ b/tools/deckcheck.py @@ -0,0 +1,777 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Is a frozen job deck still what CURRENT knowledge would freeze today? + + python tools/deckcheck.py JOBS_DIR + python tools/deckcheck.py JOBS_DIR --json OUT.json + python tools/deckcheck.py JOBS_DIR --job-root DIR # skip the auto-guess + +JOBS_DIR is a sweep's `jobs/` folder -- `*.job.json` files, each one panel's +complete frozen input deck (`docs/lift-check.md`, `docs/plan-panellift- +three-stage.md` SS5). Job files are input decks on purpose: a deck must +reproduce its own result forever, so `liftexport.py` freezes every knowledge- +derived value into it at export and `panellift` never re-reads the catalogue. +That is correct, and it is not what this tool checks. + +WHAT WENT WRONG, TWICE, ON 2026-09-01 +-------------------------------------- +1. A capacity in `knowledge/catalogue.yaml` was corrected after 282 decks had + already been exported carrying the old number. Five real panels showed + FAIL at 113% off the stale value before anyone could say why, and + explaining it took a git-history dig across two repositories. +2. A frozen `panel.design_number` was read as CURRENT by an agent answering + the owner, and the model had moved since the export. + +Freezing was never the defect. The defect is that staleness was +UNDETECTABLE -- nothing recorded which knowledge version froze a value, and +nothing could tell a live deck from a fossil. This tool detects it, for the +knowledge half only (point 4 below); a full-fidelity version, with a sha per +layer folded into every future deck, is designed in +`docs/plan-panellift-three-stage.md` SS5 and is not built here. + +WHAT IT DOES +------------ +For every `*.job.json` in JOBS_DIR, it re-runs the SAME knowledge lookups +`liftexport.py` ran at export time -- `knowledge.lifter_capacity()`, +`lifter_basis_fc()`, `lifter_edge_distance()`, `lifter_safety_factor()`, and +the stage-level `handling.tension_limit_sqrt_fc` -- against knowledge +re-resolved from the files ON DISK RIGHT NOW, and compares the fresh answer +to the number the deck already carries. A field is judged only when the deck +actually carries it (an older deck missing a field is silently not compared +on that field, never treated as a match). + +Four outcomes, never collapsed into each other: + + current the deck's frozen value is what current knowledge gives too + stale they disagree -- named: mark, field, frozen value, current + value, and (when `lifter_capacity`'s own `why` names one) + which layer moved + conditional `lifter_capacity`'s with-tension-bar branch depends on + `assembly_families` -- every OTHER member's family name on + the same assembly, which `liftexport.py` reads off the + live model and NEVER WRITES INTO THE DECK (see its own + comment on this: the basis/why strings ride along + specifically so this information is not lost, and the + gate's corroborating evidence is lost anyway). So when the + family is gated and the deck cannot settle which side of + the gate applies, this reports BOTH values the current + catalogue could give and does not guess which one this + piece would get. This is not a shortfall in this tool; it + is a real gap in what the deck records, named rather than + papered over. + unjudgeable current knowledge cannot resolve the field at all (no + catalogue alias, no matching stage spec, ...) -- named, + with why. An unjudgeable deck is NOT a passing deck; the + summary counts it apart from `current` for exactly that + reason (see CLAUDE.md SS3, "could not judge" and "judged, + found nothing" are different answers). + +WHAT IT DOES NOT DO +-------------------- +This checks a deck against KNOWLEDGE, not against the MODEL. A deck can pass +every check here and still describe a panel whose geometry has since changed +in Revit -- design numbers, lifter positions, strand layouts. Verifying that +half needs the live model and is out of scope offline; incident 2 above is +that half, and it is not what this tool answers. The caveat prints once, at +the top of every run, so it cannot be scrolled past. + +WHY THIS DOES NOT CALL `knowledge.load(doc)` +--------------------------------------------- +`load()` takes a live Revit `doc` and its file layers merge through +`knowledge._merge_file`, which reaches for `pyrevit.coreutils.yaml` -- a +reader that exists only inside Revit. Off Revit that import fails on every +layer, `_merge_file` records each one "unreadable" and returns the tree +unmerged, and every lookup would silently run against bare `DEFAULTS`. This +tool is the same substitution `tools/knowledge_drift.py` documents making, +for the same reason: `build_knowledge()` below re-implements the identical +five-layer order and the identical `job.plant` two-pass `load()`'s own +docstring insists on, reading each YAML file with PyYAML instead and reusing +`knowledge._coerce` / `knowledge._deep_update` / `knowledge.DEFAULTS` +directly -- so a coercion rule fixed in `knowledge.py` (the leading-zero +label rule, for one) is not a second bug waiting to happen here. + +CONSTRAINTS THIS RESPECTS +-------------------------- +Host CPython, no Revit, standalone. Imports `vibe.knowledge` and +`vibe.plantpath` the way `tools/ticket_delta.py` / `tools/snapshot_defer.py` +do -- see the stub registration below, and +`WillTools.extension/tests/conftest.py`'s docstring for why a plain +`import vibe.knowledge` cannot work off Revit. + +Prints no plant or client name of its own. Family names, marks and plant ids +read out of a job file are DATA flowing through, exactly as `sweep.py` and +`knowledge_drift.py` already print them at runtime; nothing about that is +baked into this file's own strings. See `.scrub-terms` and CLAUDE.md SS "No +client or job information in the repo" -- design numbers, marks and part +codes are not client information. +""" +from __future__ import annotations + +import argparse +import io +import json +import math +import os +import sys +import time +import types + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, os.pardir)) +LIB = os.path.join(REPO, 'WillTools.extension', 'lib') +if LIB not in sys.path: + sys.path.insert(0, LIB) + +# Same trick `tools/ticket_delta.py`, `tools/snapshot_defer.py` and +# `WillTools.extension/tests/conftest.py` all use, and for the same reason: +# `vibe/__init__.py` imports the Revit API at module scope, which is right +# inside Revit and fatal on host CPython. Register a bare `vibe` package +# pointed at the directory so the Revit-free submodules import normally and +# `__init__.py` never runs. +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [os.path.join(LIB, 'vibe')] + sys.modules['vibe'] = _stub + +from vibe import knowledge # noqa: E402 +from vibe import plantpath # noqa: E402 + +try: + import yaml +except ImportError: # pragma: no cover + sys.stderr.write('PyYAML is required: pip install pyyaml\n') + raise SystemExit(2) + + +CURRENT = 'current' +STALE = 'stale' +CONDITIONAL = 'conditional' +UNJUDGEABLE = 'unjudgeable' + +# Worse first. A deck's overall status is the worst of its findings' -- the +# same "not-judged-is-not-passed" rule as everywhere else in this codebase: +# an unjudged deck is not a clean deck, and a conditional one is not either. +_SEVERITY = {STALE: 3, CONDITIONAL: 2, UNJUDGEABLE: 1, CURRENT: 0} + + +# ------------------------------------------------------------- knowledge + +class KnowledgeLayers(object): + """What `build_knowledge` resolved, and what it found on disk. + + Kept apart from the merged dict itself so a report can name which + layer's file is newest, and which layers were absent or unreadable, + without re-deriving any path a second time. + """ + + def __init__(self): + self.defaults_path = None + self.catalogue_path = None + self.catalogue_status = None + self.plant_id = None + self.plant_path = None + self.plant_status = None + self.job_path = None + self.job_status = None + self.config_path = None + self.config_status = None + + def rows(self): + """(label, path, status) for every layer that names a path.""" + return [ + ('defaults', self.defaults_path, 'source'), + ('catalogue', self.catalogue_path, self.catalogue_status), + ('plant', self.plant_path, self.plant_status), + ('job', self.job_path, self.job_status), + ('config', self.config_path, self.config_status), + ] + + def newest_mtime(self): + best = None + for _label, path, status in self.rows(): + if not path or status in ('absent', 'missing', None): + continue + if not os.path.isfile(path): + continue + m = os.path.getmtime(path) + if best is None or m > best: + best = m + return best + + +def _read_yaml(path): + with io.open(path, 'r', encoding='utf-8') as fh: + return yaml.safe_load(fh) + + +def _merge_layer(merged, path): + """Merge one YAML layer file, PyYAML instead of pyRevit's reader. + + Mirrors `knowledge._merge_file`'s contract: a file that is not there is + ordinary and silent ('absent'); a file that IS there and will not parse + is recorded and skipped WHOLE, never partially merged ('unreadable') -- + the same discipline `_merge_file`'s own docstring explains, because a + layer that fails to load must never look like a layer that agrees with + the defaults. + """ + if not path or not os.path.isfile(path): + return 'absent' + try: + data = _read_yaml(path) + except Exception: + return 'unreadable' + if isinstance(data, dict): + knowledge._deep_update(merged, knowledge._coerce(data)) + return 'merged' + + +def build_knowledge(job_root): + """Re-resolve knowledge the way `knowledge.load(doc)` does, no `doc`. + + Same five layers, same order, same `job.plant` two-pass `load()`'s own + docstring insists cannot be collapsed into one pass (the plant file has + to be read from the name the job file states, and the job file merges + AFTER the plant layer -- so the name is read on its own, first, exactly + as `knowledge.plant_id()` does for the live-doc path). + + `job_root` is the folder that holds the job's OWN `knowledge.yaml` -- + for a sweep folder `/sweep/jobs/*.job.json`, that is + `/knowledge.yaml`, the same relationship + `designspec.root_dir()` / `sub_dir(doc, 'sweep')` establish for the live + path. See `_guess_job_root` for how the CLI finds it when not told. + + The project config layer (last of the five) is included only in the + thin sense the rest of this tool needs: unwrap `knowledge:` if present, + else merge the file whole. The `decisions:` friendly-name mapping + `knowledge._merge_decisions` does for a live project config is NOT + replicated here -- no active-config.txt exists on the reference sweep + this was built against, and a config layer written entirely under + `decisions:` would be under-read by this tool. Named here rather than + silently: if that ever matters, `_merge_config_file`'s mapping needs a + host-side twin, not a guess bolted on here. + """ + layers = KnowledgeLayers() + merged = knowledge._deep_copy(knowledge.DEFAULTS) + layers.defaults_path = os.path.abspath(knowledge.__file__) + + layers.catalogue_path = knowledge.catalogue_path() + layers.catalogue_status = _merge_layer(merged, layers.catalogue_path) + + job_path = os.path.join(job_root, 'knowledge.yaml') if job_root else None + layers.job_path = job_path + + plant = None + if job_path and os.path.isfile(job_path): + try: + raw = _read_yaml(job_path) or {} + except Exception: + raw = {} + job_block = raw.get('job') if isinstance(raw, dict) else None + if isinstance(job_block, dict) and job_block.get('plant') is not None: + plant = str(job_block['plant']).strip() or None + layers.plant_id = plant + + if plant is not None and not plantpath.valid_plant_id(plant): + layers.plant_status = 'invalid_plant_id' + else: + plant_path = plantpath.plant_knowledge_path(plant) + layers.plant_path = plant_path + if plant is not None and not (plant_path and os.path.isfile(plant_path)): + # Same discipline as `knowledge.load()`'s `_plant_missing`: + # NEVER fall back to the machine-wide file when a plant was + # named and its folder is not there. + layers.plant_status = 'missing' + else: + layers.plant_status = _merge_layer(merged, plant_path) + + layers.job_status = _merge_layer(merged, job_path) if job_path else 'absent' + + if job_root: + pointer = os.path.join(job_root, 'active-config.txt') + if os.path.isfile(pointer): + with io.open(pointer, 'r', encoding='utf-8') as fh: + config_path = fh.read().strip() + layers.config_path = config_path or None + if config_path: + if os.path.isfile(config_path): + try: + holder = _read_yaml(config_path) or {} + except Exception: + layers.config_status = 'unreadable' + else: + body = holder.get('knowledge') + body = body if isinstance(body, dict) else holder + knowledge._deep_update(merged, knowledge._coerce(body)) + layers.config_status = 'merged' + else: + layers.config_status = 'missing' + + merged['_plant_id'] = plant + return merged, layers + + +def _guess_job_root(jobs_dir): + """`/sweep/jobs` -> ``, verified against disk. + + `tools/sweep.py`'s `sweep_dir()` writes `jobs/` at + `designspec.sub_dir(doc, 'sweep')/jobs`, i.e. two levels under + `root_dir(doc)` -- the same folder `knowledge_path(doc)` puts + `knowledge.yaml` in. So the parent of JOBS_DIR's parent is tried first; + if it holds no `knowledge.yaml`, every ancestor up to five levels is + tried in turn (a renamed sweep folder, or `--check`'s custom `--out`, + can move the depth). None found returns the two-level guess anyway, so + the caller always has a slightly wrong parent to complain about instead + of no parent at all. + """ + jobs_dir = os.path.abspath(jobs_dir) + two_up = os.path.dirname(os.path.dirname(jobs_dir)) + candidate = two_up + node = jobs_dir + for _ in range(5): + node = os.path.dirname(node) + if not node or node == os.path.dirname(node): + break + if os.path.isfile(os.path.join(node, 'knowledge.yaml')): + return node + return two_up + + +# ------------------------------------------------------------ comparisons + +def _num_eq(a, b): + if a is None or b is None: + return a is b + try: + return math.isclose(float(a), float(b), rel_tol=1e-9, abs_tol=1e-6) + except (TypeError, ValueError): + return a == b + + +def _verdict(current_val, frozen_val): + """current/stale/unjudgeable for one plain numeric field. + + Both None: current (frozen agreement on 'nothing known'). current is + None, frozen is not: unjudgeable -- current knowledge cannot confirm OR + deny the frozen value, which is not the same as it being wrong. frozen + is None, current is not: stale -- something is knowable now that was not + at export, which is exactly the kind of drift this tool exists to catch. + Both known: compared with a float tolerance. + """ + if current_val is None and frozen_val is None: + return CURRENT + if current_val is None: + return UNJUDGEABLE + if frozen_val is None: + return STALE + return CURRENT if _num_eq(current_val, frozen_val) else STALE + + +def check_capacity(know, family, kind, frozen_val, tension_bar_families): + """(status, detail) for `capacity_lb` / `shear_capacity_lb`. + + The gate `knowledge.lifter_capacity` applies (tension only; see its own + docstring) needs `assembly_families` -- every OTHER member's family + name on the same assembly -- which the deck never carries (see this + module's own docstring). Calling with `assembly_families=None` always + resolves SOMETHING (the conservative without-bar figure, by design -- + "an omitted list never earns the 2x rating by accident"), and that + number can even happen to equal the frozen one. Trusting that match + would be exactly the guess point 1 of the spec this was built against + forbids: it is not evidence the piece lacks its tension bar, only that + nothing here was told either way. So whenever the without-bar branch is + the one that resolved, the with-bar branch is also computed (by forcing + corroboration with the plant's own `tension_bar_families`) and, if it + gives a different number, this reports CONDITIONAL naming both -- + regardless of which one the frozen value happens to match. + """ + val, basis, why = knowledge.lifter_capacity( + know, family, kind=kind, assembly_families=None) + if kind == 'tension' and basis == 'without_bar': + alt_val, alt_basis, alt_why = knowledge.lifter_capacity( + know, family, kind=kind, assembly_families=tension_bar_families) + if alt_basis == 'with_bar' and not _num_eq(alt_val, val): + matches = [b for b, v in (('without_bar', val), ('with_bar', alt_val)) + if _num_eq(v, frozen_val)] + return CONDITIONAL, { + 'frozen': frozen_val, + 'without_bar': val, 'without_bar_why': why, + 'with_bar': alt_val, 'with_bar_why': alt_why, + 'frozen_matches': matches, + 'reason': ( + "gated on a welded tension bar; this deck does not " + "carry the assembly's other member families, so " + "whether the bar was corroborated on THIS piece cannot " + "be re-derived offline. Current catalogue gives {0} lb " + "without the bar or {1} lb with it corroborated; " + "frozen {2} lb matches {3}." + ).format(val, alt_val, frozen_val, + '/'.join(matches) if matches else 'neither'), + } + status = _verdict(val, frozen_val) + return status, {'current': val, 'basis': basis, 'why': why, 'frozen': frozen_val} + + +def _match_current_stage_spec(know, stage): + specs = knowledge.get(know, 'handling.stages', []) or [] + pci = stage.get('pci_case') + name = stage.get('name') + for spec in specs: + if pci and spec.get('pci_case') == pci: + return spec + for spec in specs: + if name and spec.get('name') == name: + return spec + return None + + +def check_stage_tension_limit(know, stage, job_default_limit): + frozen = stage.get('tension_limit_sqrt_fc') + source = stage.get('tension_limit_source') + if source in (None, 'job'): + current = job_default_limit + why = 'handling.tension_limit_sqrt_fc, current job layer' + elif source == 'stage': + spec = _match_current_stage_spec(know, stage) + if spec is None: + return UNJUDGEABLE, { + 'frozen': frozen, + 'reason': ( + 'no current handling.stages entry matches pci_case={0!r}' + ' / name={1!r} to compare the pinned limit against' + .format(stage.get('pci_case'), stage.get('name'))), + } + spec_limit = spec.get('tension_limit_sqrt_fc') + current = spec_limit if spec_limit is not None else job_default_limit + why = ('handling.stages[pci_case={0!r}].tension_limit_sqrt_fc' + .format(stage.get('pci_case')) if spec_limit is not None else + 'handling.tension_limit_sqrt_fc, current job layer (matched ' + 'stage spec states none of its own)') + else: + return UNJUDGEABLE, { + 'frozen': frozen, + 'reason': 'unrecognised tension_limit_source {0!r} on this deck' + .format(source), + } + return _verdict(current, frozen), {'current': current, 'frozen': frozen, + 'why': why} + + +# ------------------------------------------------------------- per deck + +def check_deck(path, know): + """The findings for one job file. Never raises on a well-formed deck.""" + with io.open(path, 'r', encoding='utf-8') as fh: + deck = json.load(fh) + + panel = deck.get('panel') or {} + mark = panel.get('mark') or os.path.splitext(os.path.basename(path))[0] + tension_bar_families = knowledge.get(know, 'lifters.tension_bar_families', + []) or [] + findings = [] + + def add(field, status, detail, lifter_mark=None, family=None): + findings.append({ + 'deck': path, 'mark': mark, 'lifter_mark': lifter_mark, + 'family': family, 'field': field, 'status': status, + 'detail': detail, + }) + + for lifter in panel.get('lifters') or []: + family = lifter.get('family') + lmark = lifter.get('mark') + if not family: + add('family', UNJUDGEABLE, + {'reason': 'this lifter carries no family name in the deck'}, + lmark, family) + continue + + if 'capacity_lb' in lifter: + status, detail = check_capacity( + know, family, 'tension', lifter['capacity_lb'], + tension_bar_families) + add('capacity_lb', status, detail, lmark, family) + + if 'shear_capacity_lb' in lifter: + status, detail = check_capacity( + know, family, 'shear', lifter['shear_capacity_lb'], + tension_bar_families) + add('shear_capacity_lb', status, detail, lmark, family) + + if 'basis_fc_psi' in lifter: + cur = knowledge.lifter_basis_fc(know, family) + add('basis_fc_psi', _verdict(cur, lifter['basis_fc_psi']), + {'current': cur, 'frozen': lifter['basis_fc_psi']}, + lmark, family) + + if 'min_edge_distance_in' in lifter: + cur = (knowledge.lifter_edge_distance(know, family) + or knowledge.get(know, 'lifters.min_edge_distance_in')) + add('min_edge_distance_in', + _verdict(cur, lifter['min_edge_distance_in']), + {'current': cur, 'frozen': lifter['min_edge_distance_in']}, + lmark, family) + + if 'tension_fos' in lifter: + cur, src, why = knowledge.lifter_safety_factor( + know, family, kind='tension') + add('tension_fos', _verdict(cur, lifter['tension_fos']), + {'current': cur, 'frozen': lifter['tension_fos'], + 'source': src, 'why': why}, lmark, family) + + if 'shear_fos' in lifter: + cur, src, why = knowledge.lifter_safety_factor( + know, family, kind='shear') + add('shear_fos', _verdict(cur, lifter['shear_fos']), + {'current': cur, 'frozen': lifter['shear_fos'], + 'source': src, 'why': why}, lmark, family) + + job_default_limit = knowledge.get(know, 'handling.tension_limit_sqrt_fc', + 5.0) + for i, stage in enumerate(deck.get('stages') or []): + if 'tension_limit_sqrt_fc' not in stage: + continue + status, detail = check_stage_tension_limit(know, stage, + job_default_limit) + field = 'stages[{0}:{1}].tension_limit_sqrt_fc'.format( + i, stage.get('name') or stage.get('pci_case') or '?') + add(field, status, detail) + + return findings + + +def deck_status(findings): + if not findings: + return CURRENT + return max((f['status'] for f in findings), key=lambda s: _SEVERITY[s]) + + +# ----------------------------------------------------------------- CLI + +def _fmt_num(v): + if v is None: + return 'null' + if isinstance(v, float) and v == int(v): + return str(int(v)) + return str(v) + + +def _group_key(f): + """Findings that would print an identical line collapse into one. + + A deck with several lifters of the same gated family produces the same + CONDITIONAL (or STALE) numbers once per lifter; grouping is what keeps + "one line per stale deck" true instead of one line per lifter on it. + Two lifters of the same family that land on genuinely DIFFERENT numbers + -- a plant override that only matched one of them, say -- still get + separate lines, because the key includes the numbers themselves. + """ + d = f['detail'] + if f['status'] == CONDITIONAL: + tail = (d.get('frozen'), d.get('without_bar'), d.get('with_bar')) + elif f['status'] == STALE: + tail = (d.get('frozen'), d.get('current')) + else: + tail = (d.get('reason') or d.get('why'),) + return (f['field'], f['status']) + tail + + +def _deck_lines(mark, findings): + """One line per DISTINCT (field, status, values) on this deck. + + Not one line per lifter -- see `_group_key`. `current` findings never + print here; pass `--all` for the raw per-finding dump instead. + """ + non_current = [f for f in findings if f['status'] != CURRENT] + if not non_current: + return [] + groups = {} + for f in non_current: + groups.setdefault(_group_key(f), []).append(f) + lines = [] + for key in sorted(groups, key=lambda k: (-_SEVERITY[k[1]], k)): + fs = groups[key] + f0 = fs[0] + d = f0['detail'] + families = sorted(set(x['family'] for x in fs if x['family'])) + marks = sorted(set(x['lifter_mark'] for x in fs if x['lifter_mark'])) + note = '' + if marks: + note = ' ({0} lifter{1}{2})'.format( + len(marks), '' if len(marks) == 1 else 's', + ' ' + '/'.join(families) if families else '') + status = f0['status'] + if status == CONDITIONAL: + line = (' CONDITIONAL {0}{1} {2}: frozen={3} without_bar={4} ' + 'with_bar={5} -- {6}' + .format(mark, note, f0['field'], _fmt_num(d.get('frozen')), + _fmt_num(d.get('without_bar')), + _fmt_num(d.get('with_bar')), d.get('reason'))) + elif status == STALE: + why = d.get('why') or d.get('reason') or '' + line = (' STALE {0}{1} {2}: frozen={3} -> current={4}{5}' + .format(mark, note, f0['field'], _fmt_num(d.get('frozen')), + _fmt_num(d.get('current')), + (' (' + why + ')') if why else '')) + else: + line = (' UNJUDGEABLE {0}{1} {2}: {3}' + .format(mark, note, f0['field'], + d.get('reason') or d.get('why') or + 'current knowledge could not resolve this')) + lines.append(line) + return lines + + +def _finding_line(f): + d = f['detail'] + who = f['mark'] + if f.get('lifter_mark'): + who = '{0} lifter {1} ({2})'.format(f['mark'], f['lifter_mark'], + f['family']) + if f['status'] == CONDITIONAL: + return (' CONDITIONAL {0} {1}: frozen={2} without_bar={3} ' + 'with_bar={4} -- {5}' + .format(who, f['field'], _fmt_num(d.get('frozen')), + _fmt_num(d.get('without_bar')), + _fmt_num(d.get('with_bar')), d.get('reason'))) + if f['status'] == STALE: + why = d.get('why') or d.get('reason') or '' + return (' STALE {0} {1}: frozen={2} -> current={3}{4}' + .format(who, f['field'], _fmt_num(d.get('frozen')), + _fmt_num(d.get('current')), + (' (' + why + ')') if why else '')) + if f['status'] == UNJUDGEABLE: + return (' UNJUDGEABLE {0} {1}: {2}' + .format(who, f['field'], d.get('reason') or d.get('why') or + 'current knowledge could not resolve this')) + return ' current {0} {1}'.format(who, f['field']) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('jobs_dir', help="a sweep's jobs/ folder of *.job.json") + ap.add_argument('--job-root', help='the folder holding this job\'s own ' + 'knowledge.yaml (default: guessed ' + 'from jobs_dir, see _guess_job_root)') + ap.add_argument('--json', dest='json_out', help='write machine-readable ' + 'output here too') + ap.add_argument('--all', action='store_true', + help='print every finding, not just non-current ones') + args = ap.parse_args(argv) + + jobs_dir = args.jobs_dir + if not os.path.isdir(jobs_dir): + sys.stderr.write('deckcheck: not a directory: {0}\n'.format(jobs_dir)) + return 2 + + job_root = args.job_root or _guess_job_root(jobs_dir) + try: + know, layers = build_knowledge(job_root) + except Exception as exc: # pragma: no cover + sys.stderr.write('deckcheck: could not build current knowledge: ' + '{0}: {1}\n'.format(type(exc).__name__, exc)) + return 2 + + names = sorted(n for n in os.listdir(jobs_dir) if n.endswith('.job.json')) + if not names: + sys.stderr.write('deckcheck: no *.job.json under {0}\n'.format(jobs_dir)) + return 2 + + deck_paths = [os.path.join(jobs_dir, n) for n in names] + newest_deck_mtime = max(os.path.getmtime(p) for p in deck_paths) + newest_knowledge_mtime = layers.newest_mtime() + + print('=' * 78) + print('DECKCHECK -- knowledge staleness only, NOT model staleness.') + print('This verifies each deck against KNOWLEDGE, never against the live') + print('MODEL: a deck can pass every check here and still describe a') + print('panel whose geometry has since changed in Revit.') + print('=' * 78) + if newest_knowledge_mtime is not None: + age_hours = (newest_knowledge_mtime - newest_deck_mtime) / 3600.0 + if age_hours > 0: + print('these decks predate the current knowledge by {0:.1f} hour(s)' + .format(age_hours)) + elif -age_hours > 0: + print('current knowledge is {0:.1f} hour(s) OLDER than the newest ' + 'deck -- decks were exported after the last knowledge edit' + .format(-age_hours)) + else: + print('newest deck and newest knowledge layer share a timestamp') + else: + print('no knowledge layer file found to compare a timestamp against') + + print('') + print('job root: {0}'.format(job_root)) + for label, path, status in layers.rows(): + print(' {0:<10} {1} [{2}]'.format(label, path or '(none)', status)) + print('') + + all_findings = [] + deck_statuses = [] + stale_lines = [] + for path in deck_paths: + try: + findings = check_deck(path, know) + except Exception as exc: + findings = [{ + 'deck': path, 'mark': os.path.basename(path), + 'lifter_mark': None, 'family': None, 'field': '(deck read)', + 'status': UNJUDGEABLE, + 'detail': {'reason': '{0}: {1}'.format(type(exc).__name__, exc)}, + }] + all_findings.extend(findings) + status = deck_status(findings) + deck_statuses.append(status) + mark = findings[0]['mark'] if findings else os.path.basename(path) + if args.all: + stale_lines.extend(_finding_line(f) for f in findings) + elif status != CURRENT: + stale_lines.extend(_deck_lines(mark, findings)) + + if stale_lines: + print('-' * 78) + for line in stale_lines: + print(line) + print('-' * 78) + + n_total = len(deck_statuses) + n_current = sum(1 for s in deck_statuses if s == CURRENT) + n_stale = sum(1 for s in deck_statuses if s == STALE) + n_conditional = sum(1 for s in deck_statuses if s == CONDITIONAL) + n_unjudgeable = sum(1 for s in deck_statuses if s == UNJUDGEABLE) + + unjudgeable_reasons = sorted(set( + f['detail'].get('reason') or f['detail'].get('why') or '(no reason given)' + for f in all_findings if f['status'] == UNJUDGEABLE + )) + + print('') + print('{0} deck(s) checked, {1} current, {2} stale, {3} conditional, ' + '{4} unjudgeable.' + .format(n_total, n_current, n_stale, n_conditional, n_unjudgeable)) + if n_unjudgeable: + print('unjudgeable because:') + for r in unjudgeable_reasons: + print(' - {0}'.format(r)) + + if args.json_out: + payload = { + 'job_root': job_root, + 'layers': [{'layer': label, 'path': path, 'status': status} + for label, path, status in layers.rows()], + 'newest_deck_mtime': newest_deck_mtime, + 'newest_knowledge_mtime': newest_knowledge_mtime, + 'decks_checked': n_total, 'current': n_current, 'stale': n_stale, + 'conditional': n_conditional, 'unjudgeable': n_unjudgeable, + 'findings': all_findings, + } + with io.open(args.json_out, 'w', encoding='utf-8') as fh: + json.dump(payload, fh, indent=1, default=str) + print('\njson written to {0}'.format(args.json_out)) + + return 0 if n_current == n_total else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/dsh-here.cmd b/tools/dsh-here.cmd new file mode 100644 index 0000000..7a6fd17 --- /dev/null +++ b/tools/dsh-here.cmd @@ -0,0 +1,15 @@ +@echo off +rem Old launcher for the Revit ribbon button that is now WillTools tab > Lab +rem panel > Claude (it was the AI tab's GOD_MOD when this was written). +rem Nothing references this file any more; it is kept for the owner to bin. +title DeepSeek Harness - Revit +cd /d "%~dp0.." +echo Revit dev folder: %CD% +echo. +rem `dsh web` boots the DeepSeek Harness browser UI and opens it in the +rem default browser. It serves from THIS folder, so the agent starts where +rem the project instructions and tools/rvt.py are. +call dsh web %* +echo. +echo [dsh exited - this window stays open so you can read the output] +pause >nul diff --git a/tools/dsh_pool.py b/tools/dsh_pool.py new file mode 100644 index 0000000..f93e871 --- /dev/null +++ b/tools/dsh_pool.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""dsh_pool - fan N tasks out to dsh_worker concurrently, bounded by a worker +cap, and hand back one result per task, in input order, each independently +marked ok-or-refused. + + python tools/dsh_pool.py --tasks tasks.json --out results.json + python tools/dsh_pool.py --tasks tasks.json --max-workers 8 --task-timeout 120 + +`tasks.json` is a JSON array. Each element is either a plain string (the +task text) or an object with a `"task"` key (any other keys on that object +-- an id, a label -- are echoed back unchanged alongside that task's +result, so a caller can match results back to whatever it cared about +without relying on array position). Example: + + ["summarize file_a.py", "summarize file_b.py"] + + [{"id": "a", "task": "summarize file_a.py"}, + {"id": "b", "task": "summarize file_b.py"}] + +Output is a JSON array, same length and same order as the input, one +object per task: + + {"ok": true, "reply": ""} + {"ok": false, "refused": true, "reason": ""} + +plus whatever extra keys that task's input object carried. Printed to +stdout by default, or written to `--out `. + +WHY A POOL, NOT JUST N CALLS TO dsh_worker.py +----------------------------------------------- +`tools/dsh_worker.py`'s own docstring says a cheap backend's value is in +wide, cheap fan-out -- "N similar small tasks where a wrong one in isolation +is not costly" (docs/dsh-worker.md's routing table). Running dsh_worker.py N +times in a row gets none of that: it is still one task at a time, at +dsh_worker's own per-call timeout, serialized. This file is what actually +makes it a *pool*: a bounded ThreadPoolExecutor runs up to `max_workers` +dsh CLI processes at once (dsh_worker.run_task shells out to a real +subprocess per task, so there is no GIL contention worth worrying about -- +the threads are waiting on I/O, not competing for the interpreter), and +every task gets its own DshRefusal-or-reply outcome independently of every +other task's. + +THE RULE THIS FILE MUST NOT BREAK: one task failing must never take the +batch down, and a refused task must be distinguishable from a task that +succeeded with a short answer. Both are enforced the same way dsh_worker.py +enforces "could not judge" vs "judged, found nothing" (CLAUDE.md SS3/SS5): +every task's outcome is captured into its own result dict inside the worker +function passed to the executor, so an exception in task 3 becomes task 3's +`{"ok": false, "refused": true, ...}" entry, not an exception that aborts +tasks 4..N or corrupts the ordering promise. + +Standard library only (concurrent.futures). CPython 3.8+, host-only -- +same exemption from the IronPython 2.7 rules as tools/dsh_worker.py, which +this module imports as a sibling in tools/. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed + +_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _TOOLS_DIR not in sys.path: + sys.path.insert(0, _TOOLS_DIR) + +import dsh_worker # noqa: E402 (sibling module, path set up above) + +DEFAULT_MAX_WORKERS = int(os.environ.get('DSH_POOL_MAX_WORKERS', '4')) +DEFAULT_TASK_TIMEOUT_S = int(os.environ.get( + 'DSH_POOL_TASK_TIMEOUT_S', str(dsh_worker.DEFAULT_TIMEOUT_S))) + + +def _run_one(task_text, profile, timeout_s, run_task): + """Run one task and turn any DshRefusal (or any other exception -- a + task must never be allowed to kill the batch just because it raised + something dsh_worker did not anticipate) into a result dict shaped + exactly like dsh_worker's own CLI output. + """ + try: + reply = run_task(task_text, profile=profile, timeout_s=timeout_s) + return {'ok': True, 'reply': reply} + except dsh_worker.DshRefusal as exc: + return {'ok': False, 'refused': True, 'reason': str(exc)} + except Exception as exc: # noqa: BLE001 - deliberately broad: see + # module docstring, "must never take the + # batch down" + return {'ok': False, 'refused': True, + 'reason': 'unexpected error running task ({0}): {1}' + .format(type(exc).__name__, exc)} + + +def run_pool(tasks, profile=None, max_workers=DEFAULT_MAX_WORKERS, + timeout_s=DEFAULT_TASK_TIMEOUT_S, run_task=None): + """Run `tasks` (a list of task strings) concurrently, bounded by + `max_workers` at once. Returns a list the same length as `tasks`, in + the same order as `tasks`, each entry either + `{"ok": True, "reply": ...}` or + `{"ok": False, "refused": True, "reason": ...}`. + + `run_task` is injected the same way dsh_worker.run_task injects `which` + / `runner` -- so a test can fake the whole dsh round trip per task + without a real dsh install. Left as `None` it is resolved to + `dsh_worker.run_task` at call time (not bound as a default at import + time), so a caller/test that monkeypatches `dsh_worker.run_task` still + reaches its fake through `tools/dsh_pool.py main()`, which never passes + `run_task` explicitly. + """ + if run_task is None: + run_task = dsh_worker.run_task + profile = profile or dsh_worker.DEFAULT_PROFILE + max_workers = max(1, int(max_workers)) + results = [None] * len(tasks) + if not tasks: + return results + + with ThreadPoolExecutor(max_workers=max_workers) as pool: + future_to_index = { + pool.submit(_run_one, task_text, profile, timeout_s, run_task): i + for i, task_text in enumerate(tasks) + } + for future in as_completed(future_to_index): + i = future_to_index[future] + # _run_one already catches everything it can reach; .result() + # cannot raise here except for a framework-level cancellation, + # which this code never issues. + results[i] = future.result() + + return results + + +def _load_tasks(path): + """Read the tasks file. Returns (task_texts, extras) where `extras[i]` + is the dict of any non-"task" keys the i'th input object carried (empty + for a plain-string task), to be merged back into that task's result. + """ + with open(path, 'r', encoding='utf-8') as fh: + data = json.load(fh) + if not isinstance(data, list): + raise SystemExit('tasks file must contain a JSON array, got {0}' + .format(type(data).__name__)) + + task_texts = [] + extras = [] + for i, item in enumerate(data): + if isinstance(item, str): + task_texts.append(item) + extras.append({}) + elif isinstance(item, dict) and 'task' in item: + task_texts.append(item['task']) + extras.append({k: v for k, v in item.items() if k != 'task'}) + else: + raise SystemExit( + 'tasks[{0}] must be a string or an object with a "task" ' + 'key, got: {1!r}'.format(i, item)) + return task_texts, extras + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--tasks', required=True, + help='JSON file: array of task strings, or array of ' + '{"task": ...} objects') + ap.add_argument('--out', + help='write the JSON results array here (default: ' + 'stdout)') + ap.add_argument('--profile', default=dsh_worker.DEFAULT_PROFILE, + help='dsh profile every task boots (default: {0}; also ' + 'settable with $DSH_WORKER_PROFILE)' + .format(dsh_worker.DEFAULT_PROFILE)) + ap.add_argument('--max-workers', type=int, default=DEFAULT_MAX_WORKERS, + help='how many dsh calls run at once (default: {0}; ' + 'also settable with $DSH_POOL_MAX_WORKERS)' + .format(DEFAULT_MAX_WORKERS)) + ap.add_argument('--task-timeout', type=int, + default=DEFAULT_TASK_TIMEOUT_S, + help='seconds to wait before refusing any one task ' + '(default: {0}; also settable with ' + '$DSH_POOL_TASK_TIMEOUT_S)' + .format(DEFAULT_TASK_TIMEOUT_S)) + args = ap.parse_args(argv) + + task_texts, extras = _load_tasks(args.tasks) + + results = run_pool(task_texts, profile=args.profile, + max_workers=args.max_workers, + timeout_s=args.task_timeout) + + merged = [] + for extra, result in zip(extras, results): + row = dict(extra) + row.update(result) + merged.append(row) + + payload = json.dumps(merged, ensure_ascii=False, indent=2) + if args.out: + with open(args.out, 'w', encoding='utf-8') as fh: + fh.write(payload) + fh.write('\n') + else: + print(payload) + + # The batch itself ran to completion -- that is what this exit code + # reports. Whether any individual task was refused is in the JSON, + # per-task, by design (see module docstring): one refusal must never + # look like the whole run failing, or a caller scripting around this + # would stop trusting the N-1 tasks that actually succeeded. + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/dsh_watch.py b/tools/dsh_watch.py new file mode 100644 index 0000000..d1b7316 --- /dev/null +++ b/tools/dsh_watch.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +"""Feedback watcher for a dsh (DeepSeek Harness) background sub-session. + +The contract: run me in the background; my EXIT is the wake signal. +------------------------------------------------------------------ +A coordinator spawns a dsh session to do a piece of work, then launches THIS +script as a background job and goes back to its own business. dsh does not push +a "your sub is done / your sub is stuck" event to the coordinator, so this +watcher stands in for one: it polls the session and exits the instant the +session finishes, stalls (typically sitting on a permission/approval prompt in +the dsh web UI), or errors. The coordinator's harness delivers the watcher's +stdout when it exits, and that exit is what wakes the coordinator back up. + +It prints exactly ONE line and exits with a code that says which of the five +things happened, so the coordinator can branch on the code alone: + + DONE exit 0 turn/end reached, activity counter frozen + CUT_OFF exit 5 turn/end reached but the reply was truncated at the + model's output cap (finish reason "max-tokens") + ERROR exit 2 RPC error / result.ok false / session not found + STALLED exit 3 activity counter frozen past --stall, turn not ended + TIMEOUT exit 4 overall --max-minutes cap hit, session still active + +How liveness is judged +---------------------- +dsh serves a local JSON-RPC at POST /api/. We call +`session.history` and read four small things out of the (possibly huge) +response: + + * `value.projections.asOfSeq` -- a monotonically increasing ACTIVITY counter. + It climbs while the session does work and FREEZES while the session sits + idle or blocked on a prompt. This is the liveness signal. + * `value.events[-1]["event"]["type"]` -- the tail event type. A finished/idle + turn ends with `turn/end`. + * the last `assistant/chunk` event whose `data.chunk.type == "finish"` -- + its `data.chunk.reason.kind` says HOW the model's turn ended. dsh emits a + `finish` chunk on a normal stop (`reason.kind == "stop"`) AND when the + reply is truncated by the output cap (`reason.kind == "max-tokens"`), so + this is what tells a genuinely finished turn from a cut-off one. + * `value.projections.values.permissions.currentValue` -- the current sandbox + level, surfaced in the STALLED line so the coordinator can see whether the + sub is blocked wanting a higher level. + +The events array can carry tens of thousands of entries; we never print it. +The tail event is enough for liveness; distinguishing DONE from CUT_OFF means +scanning back through the events for the last `finish` chunk, which is a cheap +walk over the same array we already hold. `session.history` + `asOfSeq` is a +correct, sufficient baseline. A lighter RPC (e.g. a per-session running/idle +flag from `session.list`/`session.get`) could be swapped into `_poll` if one is +confirmed available; nothing else would have to change. + +CPython 3, stdlib only. Lives beside rvt.py / qna_watch.py, not under the +IronPython extension. +""" + +import argparse +import collections +import json +import os +import sys +import time +import uuid + +try: # py3 + import urllib.request as _urlreq + import urllib.error as _urlerr +except ImportError: # pragma: no cover - py2 never runs this + import urllib2 as _urlreq + _urlerr = _urlreq + +DEFAULT_BASE_URL = 'http://127.0.0.1:3080' +DASH = u'—' # em dash used in every output line + +# One decision per cycle. kind is for programs/tests, message is the one line +# printed to stdout, code is the process exit status. +Verdict = collections.namedtuple('Verdict', 'kind code message') + + +def _base_url(base_url=None): + if base_url: + return base_url + return os.environ.get('DSH_BASE_URL', DEFAULT_BASE_URL) + + +def _post_json(url, body, timeout): + """The one and only network call, isolated so a test can monkeypatch it. + + Returns the decoded JSON response (a dict). Raises on any transport or + decode failure -- the caller (`_poll`) turns that into an ok:False dict. + """ + data = json.dumps(body).encode('utf-8') + req = _urlreq.Request(url, data=data, + headers={'Content-Type': 'application/json'}) + resp = _urlreq.urlopen(req, timeout=timeout) + try: + raw = resp.read() + finally: + try: + resp.close() + except Exception: + pass + return json.loads(raw.decode('utf-8')) + + +def _poll(sid, base_url=None, timeout=10.0): + """One liveness sample. Returns a compact dict the loop can classify: + + {"ok": True, "asOfSeq": , "tail": , "perm": , + "finish": } + {"ok": False, "reason": } + + `finish` is the `reason.kind` of the LAST assistant/finish chunk in the + events ("stop" on a normal end, "max-tokens" when the reply was cut off by + the output cap, None when no finish chunk is present or it cannot be read). + + Never raises: a transport hiccup, a malformed body, result.ok false, or a + missing session all come back as ok:False with a reason. The loop retries a + couple of times before it believes an ok:False, so a single hiccup is not a + dead session. + """ + url = _base_url(base_url).rstrip('/') + '/api/session.history' + body = {'type': 'client-request', + 'rpcId': str(uuid.uuid4()), + 'method': 'session.history', + 'payload': {'sessionId': sid}} + try: + resp = _post_json(url, body, timeout) + except Exception as exc: # transport / decode failure + return {'ok': False, 'reason': 'poll failed: {0}'.format(exc)} + + if not isinstance(resp, dict): + return {'ok': False, 'reason': 'malformed response (not an object)'} + + result = resp.get('result') + if not isinstance(result, dict) or not result.get('ok'): + reason = None + if isinstance(result, dict): + reason = result.get('error') or result.get('reason') + return {'ok': False, + 'reason': reason or 'rpc result not ok (session not found?)'} + + value = result.get('value') or {} + proj = value.get('projections') or {} + seq = proj.get('asOfSeq') + if seq is None: + return {'ok': False, 'reason': 'no asOfSeq in projections'} + + perms = (proj.get('values') or {}).get('permissions') or {} + perm = perms.get('currentValue') + + tail = None + finish = None + events = value.get('events') or [] + if events: + last = events[-1] + ev = last.get('event') if isinstance(last, dict) else None + if isinstance(ev, dict): + tail = ev.get('type') + # The LAST assistant/finish chunk carries how the model's turn ended: + # its reason.kind is "stop" on a normal end and "max-tokens" when the + # reply was truncated by the output cap. Walk back so a finish chunk + # after unrelated events is still found; read defensively throughout + # (any dict in the chain may be missing a key). + for entry in reversed(events): + if not isinstance(entry, dict): + continue + ev = entry.get('event') + if not isinstance(ev, dict): + continue + if ev.get('type') != 'assistant/chunk': + continue + data = ev.get('data') + if not isinstance(data, dict): + continue + chunk = data.get('chunk') + if not isinstance(chunk, dict): + continue + if chunk.get('type') != 'finish': + continue + reason = chunk.get('reason') + if isinstance(reason, dict): + finish = reason.get('kind') + break + + try: + seq = int(seq) + except (TypeError, ValueError): + return {'ok': False, 'reason': 'asOfSeq not an int: {0!r}'.format(seq)} + + return {'ok': True, 'asOfSeq': seq, 'tail': tail, 'perm': perm, + 'finish': finish} + + +def _poll_with_retry(sid, base_url, sleep, attempts=3, retry_delay=1.0): + """Call `_poll` up to `attempts` times; a transient failure is not a dead + session. Looks `_poll` up on the module at call time so tests can swap it. + """ + reason = 'poll failed' + for i in range(attempts): + try: + res = _poll(sid, base_url) + except Exception as exc: # a monkeypatched/broken poll + res = {'ok': False, 'reason': 'poll raised: {0}'.format(exc)} + if res.get('ok'): + return res + reason = res.get('reason') or reason + if i < attempts - 1: + sleep(retry_delay) + return {'ok': False, 'reason': reason} + + +def watch(sid, interval=15.0, stall=60.0, max_minutes=120.0, base_url=None, + sleep=None, now=None): + """Poll `sid` until it finishes, stalls, errors, or the cap is hit. + + Returns a Verdict(kind, code, message). Pure of I/O: `main` prints and + exits with it. `sleep` and `now` are injectable so tests run instantly with + a fake clock; production uses time.sleep / time.monotonic. + """ + sleep = sleep or time.sleep + now = now or time.monotonic + + prev_seq = None + start = now() + last_change = start + + while True: + res = _poll_with_retry(sid, base_url, sleep) + if not res.get('ok'): + return Verdict('ERROR', 2, + u'ERROR {0} {1} {2}'.format(sid, DASH, res.get('reason'))) + + seq = res['asOfSeq'] + tail = res.get('tail') + perm = res.get('perm') + finish = res.get('finish') + t = now() + + advanced = (prev_seq is None) or (seq != prev_seq) + if advanced: + last_change = t + prev_seq = seq + + frozen = t - last_change + elapsed = t - start + + # DONE / CUT_OFF: the turn has ended and the counter is no longer + # advancing. We require a non-advancing cycle so we never call a turn + # done off the very poll that emitted turn/end (that poll itself + # advances the seq). The last finish chunk's reason.kind splits the two + # cases: "max-tokens" means dsh ended the turn because the reply hit + # the model's output cap -- a CUT_OFF the coordinator should resume + # with "continue", not treat as a finished job. + if tail == 'turn/end' and not advanced: + if finish == 'max-tokens': + return Verdict( + 'CUT_OFF', 5, + u'CUT_OFF {0} {1} reply cut off at max-tokens (turn/end ' + u'reached); send "continue" to resume'.format(sid, DASH)) + return Verdict('DONE', 0, + u'DONE {0} steps={1} {2} turn ended'.format(sid, seq, DASH)) + + # STALLED: counter frozen past the threshold and the turn is NOT over -- + # the classic "waiting on a permission/approval prompt" shape. + if frozen >= stall and tail != 'turn/end': + return Verdict('STALLED', 3, + u'STALLED {0} {1} asOfSeq frozen {2}s at seq={3}, ' + u'tail={4}, perm={5}; check the dsh web UI for a ' + u'pending approval/prompt'.format( + sid, DASH, int(round(frozen)), seq, tail, perm)) + + # TIMEOUT: overall cap; the session is still doing something. + if elapsed > max_minutes * 60.0: + return Verdict('TIMEOUT', 4, + u'TIMEOUT {0} {1} watched {2}m, still active'.format( + sid, DASH, int(round(elapsed / 60.0)))) + + sleep(interval) + + +def _emit(verdict): + try: + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + except Exception: + pass + try: + print(verdict.message) + except UnicodeEncodeError: + sys.stdout.write(verdict.message.encode('ascii', 'replace').decode('ascii')) + sys.stdout.write('\n') + return verdict.code + + +def main(argv=None): + ap = argparse.ArgumentParser( + description='Watch a dsh background sub-session and exit when it ' + 'finishes, stalls, or errors.') + ap.add_argument('session_id', help='the dsh sessionId to watch') + ap.add_argument('--interval', type=float, default=15.0, + help='seconds between polls (default 15)') + ap.add_argument('--stall', type=float, default=60.0, + help='seconds of frozen asOfSeq before STALLED (default 60)') + ap.add_argument('--max-minutes', type=float, default=120.0, + help='overall watch cap in minutes (default 120)') + ap.add_argument('--base-url', default=_base_url(), + help='dsh RPC base URL (default $DSH_BASE_URL or ' + + DEFAULT_BASE_URL + ')') + args = ap.parse_args(argv) + + verdict = watch(args.session_id, + interval=args.interval, + stall=args.stall, + max_minutes=args.max_minutes, + base_url=args.base_url) + return _emit(verdict) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/dsh_worker.py b/tools/dsh_worker.py new file mode 100644 index 0000000..f500be7 --- /dev/null +++ b/tools/dsh_worker.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""dsh_worker - shell out one task to a dsh (DeepSeek Harness) headless agent +and hand the reply back, or an honest, named REFUSAL if it did not get one. + + python tools/dsh_worker.py "task text on the command line" + python tools/dsh_worker.py --file task.md + echo "task text" | python tools/dsh_worker.py - + python tools/dsh_worker.py --plain "task text" # reply/reason only, no JSON + +Prints one JSON object to stdout and sets the exit code to match: + + {"ok": true, "reply": ""} -> exit 0 + {"ok": false, "refused": true, "reason": ""} -> exit 1 + +`--plain` is the only non-default output shape: it prints just the reply +text (on success) or just the reason text (on refusal), for a human running +this at a terminal. Every caller that is code, not a person, should use the +default JSON shape -- that is the "structured mode" a script can rely on to +tell a reply from a diagnostic without parsing prose. `--json` also exists, +as a no-op, for a caller that wants to say so explicitly. + +WHY THIS EXISTS +---------------- +The owner's instruction (2026-09-02): stand up dsh and let it take the +routine work a Claude subagent would otherwise do, because dsh's models are +cheaper. `dsh --profile headless ` is dsh's own one-shot mode -- +"Answer one task, print the final assistant message, and exit" (its own +--help text) -- which is exactly the CLI-worker shape a coordinator can +shell out to. There is no dsh SDK or importable client on this machine; +`tools/aiextract_run.py` talks to dsh's session.* RPC directly because it +needs to keep a session open and poll it (see that file's module docstring +for the wire contract), but a one-shot worker has no reason to reimplement +that protocol when the CLI already does the same round trip in one call. + +THE RULE THIS FILE IS BUILT AROUND (CLAUDE.md SS3 / SS5): "could not judge" and +"judged, found nothing" are different answers, and this file must never +collapse them. A dsh call that errors, times out, or returns a blank reply is +reported as a REFUSAL naming what went wrong -- never as an empty-but-"ok" +result, which is indistinguishable from "the task legitimately produced +nothing" and is exactly the silent-pass failure mode CLAUDE.md's skip-probe +table exists to prevent for Ticket QC checks. Same discipline, different +place it can bite. + +THE PROFILE IS CONFIGURABLE, NOT HARDCODED. The owner's actual working setup +(confirmed live 2026-09-02) does not use dsh's own `deepseek-official` route +-- it routes through an "opencode go" API instead: dsh's `headless` profile +plus `~/.dsh/settings.yaml`'s `agent-default-model` override, which points +the `llm-pi-ai` plugin's `openocde-go` provider (yes, that's the literal id +in his config -- do not "fix" the letter order, it is not this file's +config to rename) at `deepseek-v4-flash`, with the API key held by dsh's own +credentials service (`~/.dsh/.credentials.yaml`, keyed `OPENOCDE_GO_API_KEY` +-- never an OS environment variable on this machine). None of that is +this file's business to know beyond "which profile to boot": the profile is +`DEFAULT_PROFILE`, overridable with `DSH_WORKER_PROFILE` or `--profile`, and +whichever route that profile's own settings.yaml points at is what runs. +Someone else's machine with a real DEEPSEEK_API_KEY and no settings.yaml +override gets dsh's stock `deepseek-official` route through the exact same +`headless` profile, with no code change here. + +THE WINDOWS ARGV TRAP: dsh installs on Windows as a `dsh.CMD` shim that +launches `node \\node_modules\\@deepseek-ai\\dsh\\lib\\bin.js`. +Handing that shim a task argument through Python's subprocess module (a +plain list, no shell=True) still means Windows routes it through cmd.exe's +own command-line parser to resolve the .CMD file, and cmd.exe treats a +newline inside an argument as a command separator -- **silently truncating +the task at the first newline, exit 0, no error**. Measured 2026-09-02: a +two-line task (`"line one\\nline two with quotes"`) reached dsh as `"line +one"` alone, and dsh answered a real, well-formed reply to that truncated +text -- which is the most dangerous shape of this bug, because nothing +about the reply looks wrong on its own. Any task longer than one line +(which is most real work) hits this. So on Windows, when `which()` resolves +`dsh` to a `.cmd`/`.bat` shim, `_resolve_launch_cmd` skips the shim and +invokes `node.exe` on the underlying `bin.js` directly -- the same argv +element, no cmd.exe in the middle, newlines and all. Off Windows, or when +the shim can't be resolved to its underlying script, the shim is invoked as +before. + +Standard library only. CPython 3.8+ -- this runs on the host, not inside +Revit; see CLAUDE.md's "Write for both Pythons" -- this file is exempt from +the IronPython 2.7 rules because nothing under WillTools.extension/ imports +it. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import time + +DEFAULT_PROFILE = os.environ.get('DSH_WORKER_PROFILE', 'headless') +DEFAULT_TIMEOUT_S = int(os.environ.get('DSH_WORKER_TIMEOUT_S', '300')) +DSH_EXE = 'dsh' + + +class DshRefusal(Exception): + """The task did not get a usable reply. `reason` is what a caller prints.""" + + +def _find_dsh(which=shutil.which): + path = which(DSH_EXE) + if not path: + raise DshRefusal( + 'dsh is not on PATH -- install it with ' + '"npm install -g @deepseek-ai/dsh" and see docs/dsh-worker.md') + return path + + +def _resolve_launch_cmd(dsh_path): + """Return the argv prefix to actually invoke `dsh` with. + + On Windows, `which()` resolves `dsh` to a `.CMD`/`.BAT` shim that + forwards its arguments through cmd.exe to `node \\ + node_modules\\@deepseek-ai\\dsh\\lib\\bin.js`. cmd.exe's own + command-line parsing truncates any argument at its first newline -- + see the module docstring's "Windows argv trap" -- so a multi-line task + silently loses everything after line one. Skip the shim: call node + directly on the resolved bin.js, so the task text reaches dsh exactly + as Python handed it to subprocess, newlines included. + + Anywhere this can't be resolved (not Windows, not a .cmd/.bat shim, or + the expected files aren't where the shim would put them) this returns + `[dsh_path]` unchanged -- the previous, always-correct-off-Windows + behaviour. + """ + if os.name != 'nt': + return [dsh_path] + if not dsh_path.lower().endswith(('.cmd', '.bat')): + return [dsh_path] + + shim_dir = os.path.dirname(dsh_path) + bin_js = os.path.join(shim_dir, 'node_modules', '@deepseek-ai', 'dsh', + 'lib', 'bin.js') + if not os.path.isfile(bin_js): + return [dsh_path] + + node_exe = os.path.join(shim_dir, 'node.exe') + if not os.path.isfile(node_exe): + node_exe = shutil.which('node') + if not node_exe: + return [dsh_path] + + return [node_exe, bin_js] + + +def run_task(task, profile=DEFAULT_PROFILE, timeout_s=DEFAULT_TIMEOUT_S, + which=shutil.which, runner=subprocess.run, clock=time.time): + """Run one task through a dsh headless profile. + + Returns the reply text. Raises DshRefusal, always with a `reason` that + names the layer that failed, on any of: dsh missing, a non-zero exit, + a timeout, or a zero exit with an empty/whitespace-only reply -- the + last of those being the one an unwary caller would mistake for success. + + `which` and `runner` are injected the same way `tools/aiextract_run.py` + injects `transport` -- so a test can fake the subprocess boundary + without a real dsh install or a real API key. + """ + task = (task or '').strip() + if not task: + raise DshRefusal('empty task text -- nothing to send to dsh') + + dsh_path = _find_dsh(which) + launch_cmd = _resolve_launch_cmd(dsh_path) + cmd = launch_cmd + ['--profile', profile, task] + started = clock() + try: + proc = runner(cmd, capture_output=True, text=True, timeout=timeout_s, + encoding='utf-8', errors='replace') + except subprocess.TimeoutExpired: + raise DshRefusal( + 'dsh --profile {0} did not answer within {1}s' + .format(profile, timeout_s)) + except OSError as exc: + raise DshRefusal('could not start dsh ({0}): {1}' + .format(dsh_path, exc)) + elapsed = clock() - started + + if proc.returncode != 0: + stderr = (proc.stderr or '').strip() + raise DshRefusal( + 'dsh --profile {0} exited {1} after {2:.1f}s: {3}' + .format(profile, proc.returncode, elapsed, + stderr or '(no stderr)')) + + reply = (proc.stdout or '').strip() + if not reply: + # THE TRAP: exit 0 with nothing on stdout is NOT success. Treat it + # exactly like the CLAUDE.md skip-probe table treats an empty check + # result with no probe registered -- as a defect to surface, never + # as a quiet "ran, found nothing" pass. See module docstring. + stderr = (proc.stderr or '').strip() + raise DshRefusal( + 'dsh --profile {0} exited 0 but printed no reply after {1:.1f}s ' + '(stderr: {2}) -- could not judge, not judged-empty' + .format(profile, elapsed, stderr or '(none)')) + + return reply + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('task', nargs='?', + help='task text, or "-" to read it from stdin') + ap.add_argument('--file', help='read the task text from this file') + ap.add_argument('--profile', default=DEFAULT_PROFILE, + help='dsh profile to boot (default: {0}; also settable ' + 'with $DSH_WORKER_PROFILE)'.format(DEFAULT_PROFILE)) + ap.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT_S, + help='seconds to wait before refusing (default: {0}; ' + 'also settable with $DSH_WORKER_TIMEOUT_S)' + .format(DEFAULT_TIMEOUT_S)) + ap.add_argument('--json', action='store_true', + help='structured JSON output (default; this flag is a ' + 'no-op kept for callers that want to say so ' + 'explicitly)') + ap.add_argument('--plain', action='store_true', + help='print only the reply text (success) or reason ' + 'text (refusal), no JSON -- for a human at a ' + 'terminal, not for a caller that is code') + args = ap.parse_args(argv) + + if args.file: + with open(args.file, 'r', encoding='utf-8') as fh: + task = fh.read() + elif args.task == '-' or (args.task is None and not sys.stdin.isatty()): + task = sys.stdin.read() + elif args.task is not None: + task = args.task + else: + reason = ('no task given -- pass it as an argument, --file , ' + 'or on stdin') + if args.plain: + print(reason) + else: + print(json.dumps({'ok': False, 'refused': True, + 'reason': reason})) + return 1 + + try: + reply = run_task(task, profile=args.profile, timeout_s=args.timeout) + except DshRefusal as exc: + if args.plain: + print(str(exc)) + else: + print(json.dumps({'ok': False, 'refused': True, + 'reason': str(exc)})) + return 1 + + if args.plain: + print(reply) + else: + print(json.dumps({'ok': True, 'reply': reply})) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/knowledge_drift.py b/tools/knowledge_drift.py new file mode 100644 index 0000000..a3a3058 --- /dev/null +++ b/tools/knowledge_drift.py @@ -0,0 +1,1056 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Compare the knowledge keys that are DECLARED against the ones that are READ. + + python tools/knowledge_drift.py # findings, exit 1 if any + python tools/knowledge_drift.py --all # + every suppressed line + python tools/knowledge_drift.py --blind # only the blind-spot report + python tools/knowledge_drift.py --json # machine readable + +WHY THIS EXISTS +--------------- +The tool's behaviour is driven by layered YAML (knowledge.DEFAULTS -> +catalogue.yaml -> plant -> job -> project config) read through +`knowledge.get(know, 'dotted.path', default)`. Nothing anywhere compared the +two sides of that contract, and seven separate drifts were found in one day by +accident. Every one looked normal from the outside: + + * a key written under the wrong parent -- parsed cleanly, did nothing + * a key documented in a job file with a careful comment, read by no code + * a value read by the AI prompt builder and by no check + * a value written, tested, and never wired to the check it was for + +They are one missing mechanism, not seven bugs. A key that is declared and +never read is a documented promise the tool does not keep. A key that is read +and declared nowhere is a default nobody can see or override. + +WHAT "READ" MEANS HERE, AND THE THING THAT MATTERS MOST +------------------------------------------------------ +A reader is graded, not counted. `rails.left_rail_is_fixed` HAD a reader -- +the AI prompt builder -- and was still drift, because no check consumed it, so +a plant that set it was silently ignored. `weight_three_way` had a reader in +the test suite. So every call site is put in a tier: + + behaviour a check, a solver, an export -- the tool acts on it + surface the config screen, the config dump, the AI prompt. These + DISPLAY a value. A key only they read is settable, visible, + and inert. + test only the suite touches it + +and a key whose best reader is `surface` or `test` is reported, separately, +with that word attached. + +THE DISCIPLINE: "could not judge" MUST NOT READ AS "judged and found nothing" +---------------------------------------------------------------------------- +A path built from a variable, a subtree fetched and walked by hand, a key +reached through a helper -- this detector cannot see all of those. Every run +therefore ends with a blind-spot report naming the call sites it could not +resolve and the subtrees it has deliberately stopped judging. A clean +findings list on top of a long blind-spot list is not a clean codebase, and +the output says so in those words. + +CONSTRAINTS THIS RESPECTS +------------------------- +Host CPython 3.12. The code it reads -- the tree under CODE_ROOTS -- is +IronPython 2.7 and CPython, so THAT is only ever PARSED (`ast`), never +imported: importing `vibe` for real pulls in the Revit API and dies. YAML is +read with PyYAML, not `vibe.yamlkit` (a hand-rolled subset for design-spec +files that would silently parse a fraction of a knowledge file). The real +plant and job layers are gitignored: this runs, and is useful, when they are +absent, and it never prints a filename or a value from them -- only key +paths, under the generic labels 'plant layer' / 'job layer'. + +One narrow exception to "never imports": `vibe.plantpath` and `vibe.joblocate` +have no Revit import at all -- see their own module docstrings -- so they are +loaded, via the same `sys.modules['vibe']` stub `tools/snapshot_cycle.py` and +`WillTools.extension/tests/conftest.py` use, purely to ask WHERE the plant and +job layers live on THIS machine (`_plant_root` / `_jobs_root` below). That is +resolution-rule reuse, not a parallel guess at the same question, and it is +the fix for the gap `machine-setup.md` in the private repo used to flag as +"a recommendation, not implemented": WILLTOOLS_KNOWLEDGE and WILLTOOLS_JOBS +now say where to look, the same as every other reader of these layers. +""" +from __future__ import annotations + +import argparse +import ast +import io +import json +import os +import re +import sys +import types + +try: + import yaml +except ImportError: # pragma: no cover + sys.stderr.write('PyYAML is required: pip install pyyaml\n') + raise SystemExit(2) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +KNOWLEDGE_PY = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe', + 'knowledge.py') +CONFIGMODEL_PY = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe', + 'configmodel.py') +CATALOGUE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml') +ALLOWLIST = os.path.join(ROOT, 'tools', 'knowledge_drift_allow.txt') + +# `vibe.plantpath` / `vibe.joblocate` only -- see the docstring above. Neither +# imports the Revit API, so the stub is enough to make `from vibe import +# plantpath` resolve as an ordinary submodule instead of running +# `vibe/__init__.py` (which does, and dies on host CPython). +_VIBE_DIR = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe') +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [_VIBE_DIR] + _stub.__file__ = os.path.join(_VIBE_DIR, '__init__.py') + sys.modules['vibe'] = _stub +from vibe import joblocate # noqa: E402 +from vibe import plantpath # noqa: E402 + +CODE_ROOTS = ('WillTools.extension', 'panellift', 'tools') +SKIP_DIRS = {'__pycache__', '.git', 'dist', '_archive', '_scratch', + '.pytest_cache', 'node_modules'} + +# The name a module is bound to when it is the knowledge module. +KNOWLEDGE_ALIASES = {'K', 'knowledge', 'kn'} + +CATEGORIES = ('unread', 'undeclared', 'defaults', 'surface-only', 'test-only') + + +# --------------------------------------------------------------- reader tiers + +def tier_of(relpath): + """behaviour / surface / test, from where the call site lives. + + The split is the whole point of the tool, so it is stated as data here + rather than being inferred. `configdump`, `configmodel` and the Project + Config screen render settings; `aiextract` builds prompt text. None of + them makes the tool DO anything, so a key only they read is a key a plant + can set and be ignored on -- which is exactly the shape of the + `rails.left_rail_is_fixed` drift. + """ + p = relpath.replace('\\', '/') + if '/tests/' in p or os.path.basename(p).startswith('test_') \ + or os.path.basename(p) == 'conftest.py': + return 'test' + for mark in ('lib/vibe/configdump.py', 'lib/vibe/configmodel.py', + 'lib/vibe/aiextract.py', 'ProjectConfig.pushbutton'): + if mark in p: + return 'surface' + return 'behaviour' + + +TIER_RANK = {'behaviour': 0, 'surface': 1, 'test': 2} + + +# ------------------------------------------------------------- path flattening + +def flatten(node, prefix=''): + """Every dotted path in a nested mapping, containers included. + + Containers are emitted as well as leaves because a container IS a + read target here -- `strands.area_in2` is fetched whole and walked -- + so the reader side and the declaration side have to speak about the + same set of names. + """ + out = [] + if isinstance(node, dict): + for key, value in node.items(): + if not isinstance(key, str): + continue + path = prefix + '.' + key if prefix else key + out.append(path) + out.extend(flatten(value, path)) + return out + + +# ------------------------------------------------------------- declaration side + +def _literal(node): + try: + return ast.literal_eval(node) + except Exception: + return None + + +def declarations(): + """{path: set(source labels)} plus the raw trees, keyed by source. + + Sources are labels, never paths: the plant and job layers are gitignored + and a colleague's clone has neither, so nothing that identifies a file -- + or a client -- may reach the output. + """ + decls = {} + trees = {} + + def add(label, tree): + if not isinstance(tree, dict): + return + trees.setdefault(label, {}) + _deep_update(trees[label], tree) + for path in flatten(tree): + decls.setdefault(path, set()).add(label) + + # 1. DEFAULTS / STARTER / PLANT_STARTER, parsed out of knowledge.py. + src = _read(KNOWLEDGE_PY) + if src is not None: + mod = ast.parse(src) + for stmt in mod.body: + if not isinstance(stmt, ast.Assign): + continue + names = [t.id for t in stmt.targets if isinstance(t, ast.Name)] + if 'DEFAULTS' in names: + add('knowledge.DEFAULTS', _literal(stmt.value)) + elif 'STARTER' in names or 'PLANT_STARTER' in names: + text = _literal(stmt.value) + if isinstance(text, str): + add(names[0], _safe_yaml(text, names[0])) + + # 2. The public catalogue. + if os.path.isfile(CATALOGUE): + add('catalogue.yaml', _safe_yaml(_read(CATALOGUE), 'catalogue.yaml')) + + # 3. The private layers, if this checkout has them. + for label, files in (('plant layer', _plant_files()), + ('job layer', _job_files())): + for path in files: + add(label, _unwrap_config(_safe_yaml(_read(path), label))) + + return decls, trees + + +def _deep_update(base, override): + for key, value in (override or {}).items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + _deep_update(base[key], value) + else: + base[key] = value + return base + + +def _unwrap_config(tree): + """A project config file wraps its knowledge under `knowledge:`. + + Mirrors `knowledge._merge_config_file`. The sibling sections describe the + configuration rather than being part of it, so merging the document whole + would plant junk branches and then report every one of them as unread. + """ + if isinstance(tree, dict) and isinstance(tree.get('knowledge'), dict) \ + and ('project' in tree or 'output_locations' in tree): + return tree['knowledge'] + return tree + + +def _plant_root(): + """Where this run looks for the plant layer(s). + + WILLTOOLS_KNOWLEDGE wins when it is set, resolved the SAME way every + other reader resolves it -- `vibe.plantpath.plant_root()`, reused rather + than re-derived, so a machine whose env var points at a private clone + somewhere else on disk (the setup `machine-setup.md` in the private repo + documents) is walked from the folder the real tool would actually read. + + Unset, this is `/knowledge/plant` -- NOT `plantpath`'s own + unset-fallback of `%APPDATA%/WillTools`, on purpose: that fallback names + a per-machine install folder that has nothing to do with auditing a + checkout and will not even exist on a CI runner. `/knowledge/plant` + is where a private clone has always lived when nobody has set the env + var, and is exactly what the hardcoded path here used to be. + """ + if os.environ.get(plantpath.PLANT_ENV): + return plantpath.plant_root() + return os.path.join(ROOT, 'knowledge', 'plant') + + +def _jobs_root(): + """Where this run looks for job layers -- WILLTOOLS_JOBS, or the checkout + default. Same reasoning as `_plant_root`, and the same env var every + other reader of the job layer (`designspec.root_dir`, `vibe.joblocate`) + honours; unlike the plant file, WILLTOOLS_JOBS already names a folder + directly, so there is no file-vs-folder resolution to reuse here.""" + return os.environ.get(joblocate.JOBS_ENV) or os.path.join( + ROOT, 'knowledge', 'job') + + +def _plant_files(): + """Every plant knowledge file this run can see. + + WALKED, not listed, because the plant layer is ONE FOLDER PER PLANT: + `//knowledge.yaml`, with the older single + `/knowledge.yaml` still supported. Listing only the top directory + stopped finding anything the day the folders appeared, and the failure + was quiet in the worst way -- the run still succeeded, the layer count + silently dropped to four, every key declared only by a plant became + "never declared", and five allowlist entries were reported STALE. A + checker that stops checking and says it is fine is the thing this file + exists to prevent, so it is walked and the layer count is printed. + + `.bak-*` snapshots are skipped for the same reason `_job_files()` skips + them: yesterday's file would resurrect a key that was removed on purpose. + """ + out = [] + base = _plant_root() + if not os.path.isdir(base): + return out + for dirpath, dirnames, filenames in os.walk(base): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in sorted(filenames): + if name.endswith(('.yaml', '.yml')) and '.bak-' not in name: + out.append(os.path.join(dirpath, name)) + return out + + +def _job_files(): + """Job knowledge and project-config files, from any job folder this run + can see -- see `_jobs_root` for where that is. + + `.bak-*` snapshots are skipped: they are yesterday's file, and a key that + was removed on purpose would come back as a finding forever. + """ + out = [] + base = _jobs_root() + if not os.path.isdir(base): + return out + for dirpath, dirnames, filenames in os.walk(base): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in sorted(filenames): + if name.endswith(('.yaml', '.yml')) and '.bak-' not in name: + out.append(os.path.join(dirpath, name)) + return out + + +PARSE_NOTES = [] + + +def _safe_yaml(text, label): + if not text: + return {} + try: + return yaml.safe_load(text) or {} + except Exception as exc: + PARSE_NOTES.append('%s did not parse as YAML (%s) -- its keys are ' + 'NOT in this run' % (label, exc.__class__.__name__)) + return {} + + +def _read(path): + try: + with io.open(path, 'r', encoding='utf-8') as fh: + return fh.read() + except Exception: + return None + + +# ------------------------------------------------------------------ reader side + +class Reader(object): + __slots__ = ('path', 'default', 'file', 'line', 'tier', 'how', 'covering') + + def __init__(self, path, default, file, line, how, covering=True): + self.path = path + self.default = default + self.file = file + self.line = line + self.tier = tier_of(file) + self.how = how + # A COVERING read fetches the node and everything under it -- a real + # knowledge.get -- so nothing below it can be called unread. A config + # row or a subtree caption names the node without fetching its + # children, so it must not silence them: `parameters` appearing on the + # config screen says nothing about whether `parameters.sheet.weight` + # is used by anything. + self.covering = covering + + @property + def where(self): + return '%s:%d' % (self.file, self.line) + + +# One-level wrappers around knowledge.get whose path is a literal prefix plus a +# caller-supplied key. Resolving them by hand is a judgement call: the +# alternative is to treat the whole prefix as opaque, which would hide every +# nomenclature and parameter key from the checker. Each entry names the arg +# index that carries the tail. +HELPERS = { + '_nomen': ('nomenclature.', 1), # designspec._nomen + 'param_names': ('parameters.', 1), # knowledge.param_names +} + +# Tuples of role names that are fed to a wrapper in a loop rather than one at a +# time. knowledge.unmapped_roles() walks PARAMETER_ROLES and calls +# param_names on each, so every role in it is genuinely read -- but by a +# variable, which the AST scan cannot follow. +ROLE_TUPLES = {'PARAMETER_ROLES': 'parameters.', 'OPTIONAL_ROLES': 'parameters.'} + +# Module-level literals in the SURFACE layer that name whole knowledge +# subtrees to display. `configdump.SECTION_ORDER` and `configmodel.SUBTREES` +# are how a branch reaches the config dump and the config screen, and without +# them a branch shown on both would be reported as read by nothing at all -- +# which is a different, and wrong, statement about it. Every string in the +# literal is taken; the ones that are not declared paths are dropped. +SURFACE_TABLES = { + ('lib/vibe/configmodel.py', 'SUBTREES'), +} +# `configdump.SECTION_ORDER` / `SECTION_NOTES` are deliberately NOT here. +# They order and caption whole branches; treating them as reads made every +# top-level branch look read and swallowed the real findings underneath. +# Display order is not consumption. + + +class Scan(ast.NodeVisitor): + def __init__(self, relpath): + self.rel = relpath + self.readers = [] + self.blind = [] + # knowledge.py calls its own `get` bare, with no module prefix, and + # that is where is_strand / catalogue_item / param_names live -- so + # skipping bare calls would report the families and lifters tables as + # unread when they are the most-read keys in the tool. Only inside + # that one file, where `get` cannot mean anything else. + self.bare_get = relpath.endswith('lib/vibe/knowledge.py') + self.fn_stack = [] + self.locals_stack = [] + + def visit_FunctionDef(self, node): + self.fn_stack.append(node.name) + self.locals_stack.append(_local_strings(node)) + self.generic_visit(node) + self.locals_stack.pop() + self.fn_stack.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def _resolve_name(self, node): + """A local variable holding one or two string literals, or None. + + `liftexport._fast_box` picks between two literal paths with a ternary + and passes the result to `knowledge.get`. Refusing to follow that + would report both `handling.fast_lifter_box` keys as read only by the + config screen -- a false accusation, and the worst kind, because it + is indistinguishable from the true ones. + """ + if not isinstance(node, ast.Name): + return None + for scope in reversed(self.locals_stack): + if node.id in scope: + return scope[node.id] + return None + + def visit_Call(self, node): + self._knowledge_get(node) + self._helper(node) + self._config_row(node) + self.generic_visit(node) + + # knowledge.get(know, 'a.b', default) ------------------------------- + def _knowledge_get(self, node): + fn = node.func + if isinstance(fn, ast.Attribute) and fn.attr == 'get': + base = fn.value + name = base.id if isinstance(base, ast.Name) else ( + base.attr if isinstance(base, ast.Attribute) else None) + if name not in KNOWLEDGE_ALIASES: + return + elif self.bare_get and isinstance(fn, ast.Name) and fn.id == 'get': + pass + else: + return + if len(node.args) < 2: + return + arg = node.args[1] + default = (_unparse(node.args[2]) if len(node.args) > 2 else 'None') + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + self.readers.append(Reader(arg.value, default, self.rel, + node.lineno, 'literal')) + elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add) \ + and isinstance(arg.left, ast.Constant) \ + and isinstance(arg.left.value, str): + if self.fn_stack and self.fn_stack[-1] in HELPERS: + # This IS the body of a wrapper whose callers are resolved + # above. Blinding its own prefix here would hide every + # nomenclature and parameter key behind the very helper that + # makes them visible. + return + self.blind.append((self.rel, node.lineno, 'concatenated path', + arg.left.value + '')) + else: + resolved = self._resolve_name(arg) + if resolved: + for path in sorted(resolved): + self.readers.append(Reader(path, default, self.rel, + node.lineno, 'local literal')) + else: + self.blind.append((self.rel, node.lineno, 'non-literal path', + _unparse(arg))) + + # _nomen(know, 'design_folder', ...) / param_names(know, 'sheet.fc') -- + def _helper(self, node): + fn = node.func + name = fn.id if isinstance(fn, ast.Name) else ( + fn.attr if isinstance(fn, ast.Attribute) else None) + if name not in HELPERS: + return + prefix, idx = HELPERS[name] + if len(node.args) <= idx: + return + arg = node.args[idx] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + default = (_unparse(node.args[idx + 1]) + if len(node.args) > idx + 1 else 'None') + self.readers.append(Reader(prefix + arg.value, default, self.rel, + node.lineno, 'helper ' + name)) + else: + self.blind.append((self.rel, node.lineno, + 'non-literal key into ' + name, + prefix + _unparse(arg))) + + # configmodel._s('handling.rig_mode', ...) -------------------------- + def _config_row(self, node): + fn = node.func + name = fn.id if isinstance(fn, ast.Name) else None + if name != '_s' or not node.args: + return + arg = node.args[0] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + self.readers.append(Reader(arg.value, 'None', self.rel, + node.lineno, 'config row', + covering=False)) + + +def _local_strings(fn): + """{local name: set of string literals it can hold}, one level deep. + + Only plain `x = 'a'` and `x = 'a' if c else 'b'`. Anything cleverer is + left unresolved on purpose: a wrong resolution is worse than an admitted + blind spot, because it turns into a confident finding about the wrong key. + """ + out = {} + for node in ast.walk(fn): + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name): + continue + value = node.value + got = set() + if isinstance(value, ast.Constant) and isinstance(value.value, str): + got.add(value.value) + elif isinstance(value, ast.IfExp): + for side in (value.body, value.orelse): + if isinstance(side, ast.Constant) and isinstance(side.value, + str): + got.add(side.value) + else: + got = set() + break + if got: + out.setdefault(target.id, set()).update(got) + else: + out[target.id] = set() # reassigned to something opaque + return {k: v for k, v in out.items() if v} + + +def _surface_tables(tree, rel): + out = [] + for stmt in ast.walk(tree): + if not isinstance(stmt, ast.Assign): + continue + for target in stmt.targets: + if not isinstance(target, ast.Name): + continue + if not any(rel.endswith(f) and target.id == v + for f, v in SURFACE_TABLES): + continue + for text in _strings_in(_literal(stmt.value)): + out.append(Reader(text, 'None', rel, stmt.lineno, + 'surface table ' + target.id, + covering=False)) + return out + + +def _strings_in(value): + if isinstance(value, str): + return [value] + out = [] + if isinstance(value, dict): + for key, val in value.items(): + out.extend(_strings_in(key)) + elif isinstance(value, (list, tuple, set)): + for item in value: + out.extend(_strings_in(item)) + return out + + +def _unparse(node): + try: + return ast.unparse(node) + except Exception: + return '' + + +def scan_code(): + readers, blind, parse_fail = [], [], [] + for root in CODE_ROOTS: + base = os.path.join(ROOT, root) + for dirpath, dirnames, filenames in os.walk(base): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in filenames: + if not name.endswith('.py'): + continue + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, ROOT).replace('\\', '/') + text = _read(full) + if text is None: + continue + try: + tree = ast.parse(text) + except SyntaxError as exc: + # IronPython 2.7 print statements and the like. A file + # that will not parse is a file whose readers are INVISIBLE + # to this tool, so it is reported, never skipped quietly. + parse_fail.append((rel, 'line %s: %s' % (exc.lineno, + exc.msg))) + continue + scan = Scan(rel) + scan.visit(tree) + readers.extend(scan.readers) + blind.extend(scan.blind) + readers.extend(_role_tuples(tree, rel)) + readers.extend(_surface_tables(tree, rel)) + return readers, blind, parse_fail + + +def _role_tuples(tree, rel): + """PARAMETER_ROLES = (...) -> a reader for each 'parameters.'.""" + out = [] + for stmt in ast.walk(tree): + if not isinstance(stmt, ast.Assign): + continue + for target in stmt.targets: + if not isinstance(target, ast.Name) or target.id not in ROLE_TUPLES: + continue + prefix = ROLE_TUPLES[target.id] + for role in (_literal(stmt.value) or ()): + if isinstance(role, str): + out.append(Reader(prefix + role, 'None', rel, + stmt.lineno, 'role table')) + return out + + +# ------------------------------------------------------------------- allowlist + +def load_allowlist(): + """` -- `, one per line. + + An allowlist FILE rather than a YAML comment, chosen deliberately: two of + the three categories are about keys that exist only in Python or only in a + gitignored file, so a comment convention could not express them and half + the exceptions would have nowhere to live. One file, greppable, in the + repo, next to the tool that reads it. The reason is MANDATORY -- an + exception with no sentence is refused, because an unexplained suppression + is how a checker quietly stops checking -- and every run prints the + suppression count and any entry that no longer matches anything, so the + file cannot rot unseen. + """ + entries, errors = {}, [] + text = _read(ALLOWLIST) + if text is None: + return entries, errors + for num, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if not line or line.startswith('#'): + continue + if ' -- ' not in line: + errors.append('%s:%d needs a reason: " -- why"' + % (os.path.basename(ALLOWLIST), num)) + continue + head, reason = line.split(' -- ', 1) + bits = head.split() + if len(bits) != 2 or bits[0] not in CATEGORIES: + errors.append('%s:%d unreadable; category must be one of %s' + % (os.path.basename(ALLOWLIST), num, + ', '.join(CATEGORIES))) + continue + if not reason.strip(): + errors.append('%s:%d has an empty reason' % + (os.path.basename(ALLOWLIST), num)) + continue + entries[(bits[0], bits[1])] = reason.strip() + return entries, errors + + +# ---------------------------------------------------------------------- analyse + +def analyse(): + decls, _trees = declarations() + readers, blind, parse_fail = scan_code() + allow, allow_errors = load_allowlist() + + # A surface table is a list of branch names mixed with the help text + # beside them. Only the entries that are real declared paths are readers; + # the prose is dropped rather than reported as a hidden default. + readers = [r for r in readers + if not r.how.startswith('surface table') or r.path in decls] + + by_path = {} + for r in readers: + by_path.setdefault(r.path, []).append(r) + + read_paths = set(by_path) + covering_paths = {p for p, rs in by_path.items() + if any(r.covering for r in rs)} + # A path is covered when it, or any ancestor, is read by a COVERING read: + # fetching `strands.area_in2` whole and walking it reads every size in it. + covered = set() + for path in decls: + parts = path.split('.') + for i in range(1, len(parts) + 1): + head = '.'.join(parts[:i]) + if head in covering_paths or (head == path and head in read_paths): + covered.add(path) + break + + # Subtrees a dynamic path makes unjudgeable. `K.get(know, 'nomenclature.' + # + key)` means SOME nomenclature key is read and the tool cannot say + # which -- so the prefix is suppressed from findings and named in the + # blind-spot report with the number of keys it hides. + dyn_prefixes = sorted({b[3][:-len('')].rstrip('.') + for b in blind if b[2] == 'concatenated path'}) + + def under_dyn(path): + return any(path == p or path.startswith(p + '.') for p in dyn_prefixes) + + # ---- 1. declared, never read. Report the SHALLOWEST unread node: a + # 40-entry insert table nobody reads is one finding, not 200 lines. + tree = _paths_to_tree(sorted(decls)) + unread = [] + _collect_unread(tree, '', read_paths, covering_paths, unread) + unread = [p for p in unread if not under_dyn(p)] + dyn_hidden = sorted(p for p in decls + if under_dyn(p) and p not in covered) + + # ---- 2. read, never declared. + undeclared = sorted(p for p in by_path + if p not in decls and not _is_prefix_of_decl(p, decls)) + + # ---- 3. same path, different fallbacks. + inconsistent = [] + for path, rs in sorted(by_path.items()): + seen = {} + for r in rs: + # Two kinds of call site are excluded, both because they do not + # actually express an opinion about what "unset" means: + # * a config row or a subtree caption NAMES a key, it does not + # fetch it, so it has no fallback to disagree with; + # * a test that reads a value with no default is asserting what + # is there, not declaring what should happen when it is not. + # Counting either produced findings that were pure artefacts of + # this tool -- the fastest way to teach people to ignore it. + if not r.covering or r.tier == 'test': + continue + seen.setdefault(r.default, []).append(r) + if len(seen) > 1: + inconsistent.append((path, seen)) + + # ---- 4/5. graded readers. + surface_only, test_only = [], [] + behaviour_paths = {p for p, rs in by_path.items() + if any(r.tier == 'behaviour' for r in rs)} + for path, rs in sorted(by_path.items()): + tiers = {r.tier for r in rs} + if 'behaviour' in tiers: + continue + # A BRANCH the config screen names, whose leaves a check does read, + # is not inert -- `parameters` is the example. Reporting the branch + # would be a second, wrong sentence about keys already judged one + # level down. + if any(b.startswith(path + '.') for b in behaviour_paths): + continue + (test_only if tiers == {'test'} else surface_only).append((path, rs)) + + findings = { + 'unread': [(p, sorted(decls[p])) for p in unread], + 'undeclared': [(p, sorted({r.where for r in by_path[p]})) + for p in undeclared], + 'defaults': inconsistent, + 'surface-only': surface_only, + 'test-only': test_only, + } + + suppressed = {} + for cat, rows in findings.items(): + keep, gone = [], [] + for row in rows: + if (cat, row[0]) in allow: + gone.append((row[0], allow[(cat, row[0])])) + else: + keep.append(row) + findings[cat] = keep + suppressed[cat] = gone + + fired = {(c, r[0]) for c, rows in findings.items() for r in rows} + used = {(c, p) for c, rows in suppressed.items() for p, _why in rows} + stale = sorted(k for k in allow if k not in used and k not in fired) + + return { + 'findings': findings, + 'suppressed': suppressed, + 'stale_allow': stale, + 'allow_errors': allow_errors, + 'blind': blind, + 'dyn_prefixes': dyn_prefixes, + 'dyn_hidden': dyn_hidden, + 'parse_fail': parse_fail, + 'parse_notes': list(PARSE_NOTES), + 'layers': sorted({lbl for s in decls.values() for lbl in s}), + 'counts': {'declared': len(decls), 'read': len(by_path), + 'readers': len(readers)}, + } + + +def _is_prefix_of_decl(path, decls): + """A read path that is a prefix of a declared one is declared enough. + + Reading `catalogue.inserts` when only `catalogue.inserts.QLPD4T5WG...` + exists is not a hidden default; it is a table fetched whole. + """ + head = path + '.' + return any(d.startswith(head) for d in decls) + + +def _paths_to_tree(paths): + tree = {} + for path in paths: + node = tree + for part in path.split('.'): + node = node.setdefault(part, {}) + return tree + + +def _collect_unread(node, prefix, read_paths, covering_paths, out): + for key in sorted(node): + path = prefix + '.' + key if prefix else key + if path in covering_paths: + continue # subtree fetched whole + kids = node[key] + named = path in read_paths # named, but not fetched whole + below = kids and _any_read_below(kids, path, read_paths) + if below: + _collect_unread(kids, path, read_paths, covering_paths, out) + elif named: + # The config screen names this branch and nothing under it is + # read. One finding about the branch (in surface-only) says the + # whole thing; listing every leaf here would say it again, worse. + continue + else: + out.append(path) + + +def _any_read_below(node, prefix, read_paths): + head = prefix + '.' + return any(p.startswith(head) for p in read_paths) + + +# ------------------------------------------------------------------- reporting + +def _p(out, text=''): + out.write(text + '\n') + + +def report(res, out, show_all=False, blind_only=False): + c = res['counts'] + _p(out, '=' * 72) + _p(out, 'KNOWLEDGE DRIFT') + _p(out, '=' * 72) + _p(out, '%d declared paths across %d layer(s): %s' + % (c['declared'], len(res['layers']), ', '.join(res['layers']))) + _p(out, '%d distinct paths read by %d call sites.' + % (c['read'], c['readers'])) + private = [l for l in res['layers'] if l in ('plant layer', 'job layer')] + if not private: + _p(out, 'No private plant or job layer in this checkout -- only the ' + 'keys that ship in the repo were judged.') + _p(out) + + total = 0 + if not blind_only: + total = _findings(res, out) + + _blind_report(res, out) + + if not blind_only: + _p(out) + sup = sum(len(v) for v in res['suppressed'].values()) + _p(out, '%d finding(s); %d suppressed by %s.' + % (total, sup, os.path.basename(ALLOWLIST))) + if show_all and sup: + for cat in CATEGORIES: + for path, why in res['suppressed'][cat]: + _p(out, ' [%s] %s -- %s' % (cat, path, why)) + if res['stale_allow']: + _p(out, 'STALE allowlist entries (nothing matches them any more; ' + 'delete them):') + for cat, path in res['stale_allow']: + _p(out, ' %s %s' % (cat, path)) + for err in res['allow_errors']: + _p(out, 'ALLOWLIST ERROR: ' + err) + return total + + +SECTIONS = ( + ('unread', 'DECLARED BUT NEVER READ', + 'A key someone can set that no code looks at. The file says the tool ' + 'honours it; the tool does not.'), + ('undeclared', 'READ BUT NEVER DECLARED', + 'A hidden default: nothing in DEFAULTS, the starters, the catalogue or ' + 'a real layer names it, so nobody can discover or override it.'), + ('defaults', 'READ WITH INCONSISTENT DEFAULTS', + 'The same path fetched with different fallbacks. Two call sites ' + 'disagree about what "unset" means, and only one of them can be right.'), + ('surface-only', 'READ ONLY BY THE CONFIG SCREEN OR THE PROMPT', + 'Settable, displayed, and inert: no check, solver or export consumes ' + 'it. This is the shape rails.left_rail_is_fixed had.'), + ('test-only', 'READ ONLY BY THE TEST SUITE', + 'Written and tested but never wired to production code -- the shape ' + 'weight_three_way had.'), +) + + +def _findings(res, out): + total = 0 + for cat, title, why in SECTIONS: + rows = res['findings'][cat] + total += len(rows) + _p(out, '-' * 72) + _p(out, '%s (%d)' % (title, len(rows))) + _p(out, ' ' + why) + _p(out) + if not rows: + _p(out, ' none') + _p(out) + continue + if cat == 'defaults': + for path, seen in rows: + _p(out, ' %s' % path) + for default, rs in sorted(seen.items()): + _p(out, ' default %-24s %s' + % (default, ', '.join(sorted(r.where for r in rs)))) + elif cat in ('surface-only', 'test-only'): + for path, rs in rows: + _p(out, ' %-52s %s' + % (path, ', '.join(sorted(set( + '%s [%s]' % (r.where, r.tier) for r in rs))))) + else: + for path, where in rows: + _p(out, ' %-52s %s' % (path, ', '.join(where))) + _p(out) + return total + + +def _blind_report(res, out): + _p(out, '=' * 72) + _p(out, 'WHAT THIS TOOL COULD NOT SEE') + _p(out, '=' * 72) + _p(out, 'Read this before treating a short findings list as a clean ' + 'codebase.') + _p(out, '"Could not judge" is not "judged and found nothing".') + _p(out) + + if res['dyn_prefixes']: + _p(out, 'Subtrees reached by a computed path -- SOME key under each ' + 'is read and') + _p(out, 'this tool cannot say which, so nothing under them is ' + 'reported:') + for prefix in res['dyn_prefixes']: + hidden = [p for p in res['dyn_hidden'] + if p == prefix or p.startswith(prefix + '.')] + _p(out, ' %-28s hides %d declared key(s) from judgement' + % (prefix, len(hidden))) + _p(out) + + other = [b for b in res['blind'] if b[2] != 'concatenated path'] + if other: + _p(out, 'Call sites whose path is not a literal at all:') + for rel, line, kind, shown in sorted(other): + _p(out, ' %s:%d %s %s' % (rel, line, kind, shown)) + _p(out) + + if res['parse_fail']: + _p(out, 'Python files that would not parse on CPython 3.12 -- any ' + 'reader in them is INVISIBLE:') + for rel, msg in res['parse_fail']: + _p(out, ' %s (%s)' % (rel, msg)) + _p(out) + + for note in res['parse_notes']: + _p(out, 'NOTE: ' + note) + + _p(out, 'Shapes this detector does not attempt at all:') + for line in ( + 'a subtree fetched once and then walked by hand -- the keys inside', + ' it are read, but by dict access, not by a path this tool sees;', + 'a path assembled from a variable at a call site listed above;', + 'a wrapper other than _nomen / param_names / _s hiding a literal path;', + 'anything read outside the .py files under %s;' % ', '.join(CODE_ROOTS), + 'whether a reader that EXISTS actually changes an outcome -- the', + ' tier is a proxy for that, not a proof.', + ): + _p(out, (' ' + line) if line.startswith(' ') else (' - ' + line)) + _p(out) + _p(out, 'And three kinds of drift that LOOK like this one but are NOT ' + 'judged here') + _p(out, 'at all, because what they are about is not a knowledge-layer key:') + for line in ( + 'a Revit shared parameter the model carries that no code reads', + ' (TICKET_IS_REINFORCED was one);', + 'a field in the panellift JSON job file, or in the reinforcing index', + ' record, that nothing interprets -- those are dataclass and dict', + ' schemas, not YAML layers;', + 'a check that is registered and can never run. A dead check still', + ' reads its keys, so every key it touches looks alive from here.', + ): + _p(out, (' ' + line) if line.startswith(' ') else (' - ' + line)) + _p(out) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--all', action='store_true', + help='also list every allowlist-suppressed finding') + ap.add_argument('--blind', action='store_true', + help='print only the blind-spot report') + ap.add_argument('--json', action='store_true', help='machine readable') + args = ap.parse_args(argv) + + res = analyse() + if args.json: + payload = { + 'counts': res['counts'], + 'layers': res['layers'], + 'findings': { + cat: [row[0] for row in res['findings'][cat]] + for cat in CATEGORIES}, + 'suppressed': {cat: [p for p, _ in res['suppressed'][cat]] + for cat in CATEGORIES}, + 'stale_allow': ['%s %s' % k for k in res['stale_allow']], + 'allow_errors': res['allow_errors'], + 'blind_dynamic_prefixes': res['dyn_prefixes'], + 'blind_call_sites': ['%s:%d %s' % (b[0], b[1], b[2]) + for b in res['blind']], + 'unparseable_files': [f for f, _ in res['parse_fail']], + } + sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + '\n') + total = sum(len(v) for v in payload['findings'].values()) + else: + total = report(res, sys.stdout, show_all=args.all, + blind_only=args.blind) + if args.blind: + return 0 + if res['allow_errors']: + return 2 + return 1 if total else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/knowledge_drift_allow.txt b/tools/knowledge_drift_allow.txt new file mode 100644 index 0000000..1dcfdce --- /dev/null +++ b/tools/knowledge_drift_allow.txt @@ -0,0 +1,329 @@ +# Knowledge keys deliberately exempted from tools/knowledge_drift.py. +# +# FORMAT -- one per line: +# +# -- why this one is fine +# +# categories: unread undeclared defaults surface-only test-only +# +# THE REASON IS MANDATORY. A line with no ' -- why' is refused and the run +# exits 2, because an unexplained suppression is how a checker quietly stops +# checking. The point of an exception is that the next person can read why it +# was made and decide whether it still holds; a bare path cannot be argued +# with, only inherited. +# +# WHY A FILE AND NOT A YAML COMMENT. Two of the five categories are about keys +# that exist only in Python (a hidden default) or only in a gitignored plant +# or job file that a colleague's clone does not have. A comment convention +# could not express those, so half the exceptions would have nowhere to live +# and would end up as a second, undocumented mechanism. One file, in the repo, +# beside the tool that reads it, greppable, and reviewed like code. +# +# HOW IT STAYS HONEST. Every run prints how many findings this file suppressed, +# so a shrinking report is never mistaken for a shrinking problem. Every run +# also lists entries here that no longer match anything, so a key that was +# fixed or deleted does not leave a permanent blindfold behind. +# +# DO NOT put a real drift here to make the report green. An exception says +# "this key is not meant to be read"; it does not say "we know and have not +# got to it yet". For the second, leave the finding and fix the code. +# +# Example of the shape (commented out -- this is not an active exemption): +# +# unread nomenclature.view_design_token_re -- derived from design_number_re +# when left None, so nothing reads the key itself by design. + +# ---- active exemptions below this line ---- + +# =========================================================================== +# GROUP 1 -- the key IS read, by a call shape this detector cannot resolve. +# =========================================================================== +# Every line below was verified by hand on 2026-08-30 by opening the reader +# and reading it. None of them is a deferral: the code acts on the key today. +# Two shapes recur, and the tool's own blind-spot report already names both: +# a knowledge getter passed in as a FUNCTION ARGUMENT (so the call reads +# get(...) / getter(...), not K.get(...)), and a subtree fetched whole and +# then walked by dict access. Where the fix belongs in the detector rather +# than in the code the line says so; the day it lands, the run will call +# these lines stale and they should be deleted. + +# takeoffcore.settings(know, get) loops its own module-owned DEFAULTS table +# and calls get(know, 'takeoff.' + key) once per entry. Both halves are +# invisible: `get` is an argument -- injected so the module needs no Revit and +# stays testable off a model -- and the path is concatenated. The takeoff acts +# on all seven; they are what classifies a family as bar / part / shape / +# not-ordered. See takeoffcore.DEFAULTS and takeoffcore.classify(). +unread takeoff.bar_prefixes -- read by takeoffcore.settings() through an injected getter and a concatenated path; classifies bar families in takeoffcore.classify() +unread takeoff.part_prefixes -- read by takeoffcore.settings() through an injected getter and a concatenated path; classifies part families in takeoffcore.classify() +unread takeoff.shape_markers -- read by takeoffcore.settings() through an injected getter and a concatenated path; marks a bend-shape row in takeoffcore.classify() +unread takeoff.not_ordered -- read by takeoffcore.settings() through an injected getter and a concatenated path; drops families the plant does not order +unread takeoff.family_class -- read by takeoffcore.settings() through an injected getter and a concatenated path; a family named here beats every pattern +unread takeoff.nested_in -- read by takeoffcore.settings() through an injected getter and a concatenated path; rolls a nested family up into its parent +unread takeoff.order_attached_bars_separately -- read by takeoffcore.settings() through an injected getter and a concatenated path; decides whether an attached bar is ordered on its own line + +# jobreconscan._roles() resolves nine roles in one loop over a literal tuple +# and calls K.param_names(know, role) with the loop variable. The tool sees +# the call and reports it as a blind spot -- 'non-literal key into +# param_names' -- but cannot bind the roles. Job Recon reads all four of +# these off the model on every run. +unread parameters.member.ticket_created -- resolved in jobreconscan._roles()'s role loop and read off every member by Job Recon; the loop variable hides it from the scan +unread parameters.member.ticket_detailed -- resolved in jobreconscan._roles()'s role loop and read off every member by Job Recon; the loop variable hides it from the scan +unread parameters.sheet.total_required -- resolved in jobreconscan._roles()'s role loop; Job Recon compares it against the pieces the model carries +unread parameters.sheet.sheet_count -- resolved in jobreconscan._roles()'s role loop; Job Recon compares it against the sheets actually placed + +# ticketchecks._sheet_param_value(t, know, 'sheet.weight') -> ticketqc.rstr() +# -> K.param_names(). The literal role is right there, but behind two +# wrappers, and only _nomen / param_names / _s are unwrapped by the detector. +# It is deliberately NOT in knowledge.PARAMETER_ROLES: the printed-weight +# check stands down with its own sentence when the plant maps no name for it, +# so Self Check must not nag a plant that never prints a weight. +unread parameters.sheet.weight -- read by ticketchecks through _sheet_param_value() -> ticketqc.rstr() -> param_names; kept out of PARAMETER_ROLES on purpose, the weight check stands down and says so itself + +# ticketpage.muted_checks(know, getter=None) reads it through an injected +# getter, the same shape as takeoffcore. Ticket QC calls it on every run and +# filters checklist rows with the result, so this is behaviour, not display. +surface-only checks.muted -- read by ticketpage.muted_checks() through an injected getter; Ticket QC mutes checklist rows on it every run + +# liftexport fetches handling.dunnage WHOLE at behaviour tier and +# dunnage_supports() walks the dict by hand for mode and fraction -- together +# they decide where the transportation supports go. The detector only skips a +# surface-read path when a behaviour reader sits BELOW it; a covering +# behaviour read of an ANCESTOR is not considered, which is the only reason +# these two fire. Delete both lines when that is fixed in the tool. +surface-only handling.dunnage.mode -- liftexport reads the whole handling.dunnage subtree at behaviour tier and dunnage_supports() walks it; the tool does not credit a covering behaviour read of an ancestor +surface-only handling.dunnage.fraction -- liftexport reads the whole handling.dunnage subtree at behaviour tier and dunnage_supports() walks it; the tool does not credit a covering behaviour read of an ancestor + +# liftexport._fast_box picks the key by run mode and passes `not batch` as the +# fallback: for handling.fast_lifter_box that expression IS True, and for +# handling.fast_lifter_box_batch it IS False -- exactly what configdump +# passes. The two call sites agree on the value and differ only in source +# text, which is all this category can compare. +defaults handling.fast_lifter_box -- liftexport._fast_box passes `not batch`, which for this key evaluates to True, the same fallback configdump prints; the two differ in source text only +defaults handling.fast_lifter_box_batch -- liftexport._fast_box passes `not batch`, which for this key evaluates to False, the same fallback configdump prints; the two differ in source text only + +# The two call sites ask different questions and both answers are written +# down. designspec.design_key MUST always return a key, so it falls back to a +# built-in generic pattern. ticketchecks._view_design_token_re would have to +# strip the leading anchor off that pattern and hunt for it UNANCHORED inside +# a view name; guessing there is how a view called 'Elevation Top 2' becomes +# a design. It returns None instead and checklist 1.4 reads NOT RUN, which +# its docstring states outright. Not a disagreement about what unset means. +defaults nomenclature.design_number_re -- deliberate: designspec.design_key falls back to a built-in pattern because it must always return a key, while ticketchecks._view_design_token_re refuses to guess an unanchored view token and lets checklist 1.4 read NOT RUN + +# =========================================================================== +# GROUP 2 -- not knowledge keys at all: a design spec read as a layer +# =========================================================================== +# knowledge_drift._job_files() walks the job folder recursively and takes +# every .yaml in it. A design spec -- designspec.spec_path writes them to +# /specs/.yaml, first line `schema: 1` -- is a scraped record of +# one design, not a knowledge layer, and merging one plants its whole shape +# as declared paths: schema, source, confidence, unresolved, and the measured +# values under them. Nothing reads them through knowledge.get because nothing +# should; designspec loads a spec by path. +# +# THE FIX IS IN THE TOOL and it is one line: _job_files() must not descend +# into specs/. These thirteen lines live only until it does, and the run will +# name them stale the moment it lands. While they are here they are also a +# small hazard worth knowing about: `source` and `schema` are generic enough +# that a future real knowledge key of either name would be suppressed by them. +unread schema -- design-spec field, not a knowledge key; _job_files() descends into /specs/ and reads a spec document as a layer +unread source -- design-spec field (which model, sheet and view it was scraped from), not a knowledge key; _job_files() reads /specs/ as a layer +unread confidence -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread unresolved -- design-spec field (what the scrape could not settle), not a knowledge key; _job_files() reads /specs/ as a layer +unread design_number -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread piece_description -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread thickness_in -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread envelope -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread reinforcing -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread concrete.fc_psi -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer (the knowledge-layer concrete branch carries min_fci_ratio, which is read) +unread concrete.fci_psi -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread concrete.unit_weight_pcf -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer +unread concrete.weight_class -- design-spec field, not a knowledge key; _job_files() reads /specs/ as a layer + +# ---- declared inert on purpose ---- +# A key that is deliberately not wired, ruled on rather than overlooked. Not +# the same thing as muting a CHECKLIST ROW (that is `checks.muted`, a job +# fact, deep-merged, and set from the Project Config window): this changes no +# run on any job, is the same everywhere, and its audience is whoever reads +# this report next. +unread tolerances.psi -- MUTED, NOT DELETED. Owner's ruling on triage D1, 2026-08-30: neither wire nor delete. Nothing compares a psi against it (3.1 and 3.2 compare the ticket and the design summary exactly), so it decides nothing on any job today; it is kept declared and settable so that the day a tolerance is wanted the number and its name are already there. The engineer-facing half is configform's `inert` row, which shows it with this ruling instead of as a number box that decides nothing. + +# ---- the master checklist moved into the public layer, 2026-08-31 -------- +unread checklist -- the master checklist is read by ticketpage.load_master, which opens knowledge/catalogue.yaml DIRECTLY at import and parses it; it never goes through knowledge.get on the merged tree, so this detector cannot see the reader. It is the most-read data in the tool -- CHECKLIST_ITEMS is derived from it -- and two tests in test_ticketchecks_host.py pair every entry against the check functions. + +# ---- connector knowledge, 2026-08-31 ------------------------------------ +unread connectors.what_it_is -- the definition of a connector, in prose, for a person reading the catalogue. Deliberately not a predicate: what a connector IS belongs in the file that publishes the products, and what RECOGNISES one is connectors.marks, which is read. +unread connectors.insulation_note -- the model's own description of these pins as NON-COMPOSITE, quoted and dated. Recorded because it states a design assumption with engineering weight; deliberately read by nothing, and it must not become a check without the owner's word. +unread connectors.recognition_verified_on -- provenance for connectors.recognition_verified, which IS read. A date beside a flag is for the person deciding whether the flag still holds. +unread connectors.recognition_verified_by -- as above: what was measured, so the flag can be argued with rather than inherited. + + +# =========================================================================== +# GROUP 3 -- LANDED FROM THE PDF LIBRARY, READER PENDING. 2026-08-31. +# =========================================================================== +# Stage 3c of knowledge/library/RUNBOOK.md, executed against library row D1 +# (the plant standards manual, 929 pages) whose 25-fact audit had been +# EXTRACTED AND NEVER LANDED. The owner's ruling that commissioned this pass +# is quoted in that runbook and in knowledge/library/LEDGER.md. +# +# EVERY LINE BELOW IS A FACT IN A KNOWLEDGE LAYER THAT NO CODE READS YET, and +# that is the intended state, not a deferral of a fix. Knowledge leads and +# readers follow: the alternative is a drift gate that PUNISHES LANDING, and a +# gate that punishes the right behaviour is a gate people route around -- +# which is exactly how roughly 150 A-class records ended up in notes instead +# of in YAML. With these lines the drift list becomes the to-do list: every +# unread key is a reader somebody owes, named, dated, with its cite attached. +# +# ---- AND THIS FILE'S OWN HEADER SAYS THE OPPOSITE, IN AS MANY WORDS ------- +# "An exception says 'this key is not meant to be read'; it does not say 'we +# know and have not got to it yet'. For the second, leave the finding and fix +# the code." THE COMMISSION AND THAT SENTENCE DISAGREE. The honest +# resolution is either a new category (`reader-pending`, dated) or an +# amendment to the header -- NOT a quiet re-use of `unread` that makes the +# header a lie. Per RUNBOOK.md stage 3c, until the owner rules: `unread` is +# used, every reason says "reader pending", and THE CONFLICT IS ON HIS LIST +# (QNA/open-questions-2026-08-30.md). The header was NOT edited here -- that +# is not this pass's call to make. +# +# Cites are PDF page / printed section, the convention the extraction notes +# use. Both halves always: the PDF page is how the next agent finds it, the +# printed section is how the owner does, and they do not match. Full +# argument, page cites and confidence marks for every one of these: +# QNA/knowledge-audit-plant-2026-08-30.md, and knowledge/library/LEDGER.md D1. +# +# Three of these are also PARKED CONTRADICTIONS -- a live value the manual +# disagrees with, landed BESIDE it rather than over it, awaiting a ruling. +# They are marked "RULING PENDING" and carry their open-questions number. + +unread beds -- landed 2026-08-31 from the plant standards manual (library D1) p584-600 / 6110-6126, reader pending: the per-bed strand-hole grid, panel width and abutment capacity. 4.6 BED-WIDTH/BED-OFFSET reads the rails.max_bed_width_in scalar today and the manual contradicts it (open-questions A6.1); 4.6 STRAND-GRID does not exist yet +unread rails.narrow_panel -- landed 2026-08-31 from the plant standards manual p819 / 10315.1 and p804 / 10301.1 notes 27-29, reader pending: a narrow panel is HELD TO THE FIXED RAIL, so 4.6 BED-OFFSET's permitted band is an outer bound and not an expectation, and 4.4's asymmetric pattern is expected rather than a finding +unread strand.standard_patterns -- landed 2026-08-31 from the plant standards manual p174-178 / 3201-3205 and p873 / 12204, reader pending: the plant's three standard strand chains, so 4.4 STRAND-POS gets a second opinion beside the design summary +unread strand.edge_strand_in -- landed 2026-08-31 from the plant standards manual p876 / 12210, reader pending: EDGE STRAND @ 3 5/8 in, the first and last segment of every standard chain +unread strand.both_wythes_same_stations -- landed 2026-08-31 from the plant standards manual p174-178 / 3201-3205, reader pending: STRAND E.L. means both wythes share x-positions, so total strand is 2x the per-wythe count +unread strand.debond_by_blockout -- landed 2026-08-31 from the plant standards manual p817 / 10306.1, reader pending: 4.7 has the opening geometry and strand.debond_below_continuous_ft, but the L-vs-W ratio rows have no counterpart in the tool at all +unread strand.deflection -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 note 20C and p817 / 10306.1, reader pending: deflection is the alternative to debonding and is ranked LAST, max 2 in, min cover 1 1/2 in +unread strand.detensioning -- landed 2026-08-31 from the plant standards manual p628 / 6320, reader pending: nothing in the tool reads a cutting sequence. Landed because knowledge leads readers, audit 3.25 +unread reinforce.panel_standard -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: ReinforceSuggest works only from donor pieces today, and this is its first non-donor source. The middle band is PER DESIGN and must never be proposed +unread reinforce.design_sheet_selector -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: which of the nine standard reinforcing sheets applies, from panel geometry alone +unread reinforce.weight_plf -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: a per-linear-foot sanity band for Takeoff and for bar-schedule plausibility +unread reinforce.master_ticket -- landed 2026-08-31 from the plant standards manual p716 / 8430.1, reader pending: it defines what reinforce.master_token marks. The durations and the submission list are process with no check behind them +unread lifters.stripping_rows_by_height_ft -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: panellift.layout decides where inserts go and does not know the plant's own row count by panel height +unread lifters.stripping_rows_gap_at_ft -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: the sheet's own bands leave H = 39 ft exactly unassigned; a reader must pick a side and say which +unread lifters.stripping_rows_gap_because -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: the sentence a reader must print when it resolves the 39 ft boundary itself +unread lifters.default_layout -- landed 2026-08-31 from the plant standards manual p713 / 8320.2, reader pending: fifth points is a DEFAULT and the sheet says to verify the insert type with the engineer +unread lifters.insert_type_verified_with_engineer -- landed 2026-08-31 from the plant standards manual p713 / 8320.2, reader pending: the half of that sentence that stops the default being read as governing +unread lifters.common_elevation_panel_to_panel -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 note 14, reader pending: lifting inserts at a common elevation panel to panel +unread lifters.dimension_centre_to_centre_and_both_edges -- landed 2026-08-31 from the plant standards manual p812 / 10303.1, reader pending: a dimensioning rule for lift inserts, checkable once something reads printed insert dimensions +unread lifters.edge_lifter_centre_matches_cg -- landed 2026-08-31 from the plant standards manual p812 / 10303.1, reader pending: 6.2 LIFT-EDGE has the geometry but not this condition +unread lifters.side_lifters -- landed 2026-08-31 from the plant standards manual p813 / 10303.2 and p804 / 10301.1 note 10, reader pending: an ADD'L FINISH = YES title block with no side lifters is a new check needing only two title-block reads +unread lifters.top_pick_count -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: 4 top-pick inserts. The PART is deliberately not landed -- the sheet names M42/M43 and the model uses P76 (audit Q5) +unread part_numbers.prefix_class -- landed 2026-08-31 from the plant standards manual p406 / 5001, reader pending: the material-type prefix, which gives families.* a fallback that cannot go stale the way a list does +unread part_numbers.number_bands -- landed 2026-08-31 from the plant standards manual p406 / 5001 and p713 / 8320.2, reader pending: the number range tells a standard part from an erection item from a repair item with no list at all +unread part_numbers.finish_suffix -- landed 2026-08-31 from the plant standards manual p406 / 5001, reader pending: g/p/z/s/e finish suffixes on a material designation +unread part_numbers.job_specific_prefixes -- landed 2026-08-31 from the plant standards manual p406 / 5001, reader pending: the printed rule behind the SP-series move to the job layer on 2026-08-30, which this file acted on without holding the rule +unread part_numbers.rebar_mark -- landed 2026-08-31 from the plant standards manual p536 / 5302, reader pending: 5.6 BARMARK-UNIQUE could catch a mark whose first digit disagrees with the modelled bar size with no geometry at all +unread panel_types.make_ups -- landed 2026-08-31 from the plant standards manual p174-178 / 3201-3205 and p879 / 12221, reader pending: 2.1 SIZE-* reads panel_type.thickness and has no list of the five thicknesses this plant builds +unread girders -- landed 2026-08-31 from the plant standards manual p805 / 10301.2 note 3 and p879 / 12221, reader pending: girder depth, the insulation-plus-3in size rule and the opening-proximity rules. The COUNT is deliberately null -- three sources give three answers (audit Q3) +unread nomenclature.control_numbers -- landed 2026-08-31 from the plant standards manual p693-695 / 8102.3-8102.5, reader pending: JobRecon does the 163-vs-61 reconciliation and does not hold the arithmetic rule that makes it a defect +unread nomenclature.marks -- landed 2026-08-31 from the plant standards manual p693-695 / 8102.3-8102.5, reader pending: a gap in the mark sequence is EXPECTED, and the renumber trigger reframes 2.1 SIZE-* and 2.2 WGT-* as a piece that should have been renumbered +unread nomenclature.ticket_set -- landed 2026-08-31 from the plant standards manual p827 / 10340.2 and p807 / 10301.4, reader pending: 1.3 TICKET-SET gathers sheets today but does not know that sheets after the first carry no product data, nor that third-party sheets are legitimately appended +unread nomenclature.sheet_families_manual_stated -- landed 2026-08-31 from the plant standards manual p819 / 10315.1, p822 / 10330.1, p821 / 10320, p716 / 8430.1, p851 / 10950, reader pending AND RULING PENDING: nomenclature.detail_sheet_prefix is live and unchanged; this is the manual's fuller set, parked beside it as open-questions A6.2 +unread drawings.rebar_placement_manual_stated -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 notes 25, 26, 40, 47 and p805 / 10301.2 note 15C, reader pending AND RULING PENDING: drawings.rebar_must_appear_on is a residual and unchanged; this is the manual's per-bar-class enumeration, parked beside it as open-questions A6.5 +unread drawings.dimension_exceptions -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 notes 6, 33, 46, 48, p816 / 10305, p726 / 8520.1, reader pending: every entry is a place where a MISSING dimension is correct by standard, and a completeness check that does not know them produces a steady drip of false findings +unread drawings.bom_rebar_has_no_description -- landed 2026-08-31 from the plant standards manual p713 / 8320.2, reader pending: an empty BOM description on a rebar row is CORRECT, and without this 5.1 BOM-MEMBERS would fire on every rebar row of every ticket +unread drawings.not_printed_on_ticket -- landed 2026-08-31 from the plant standards manual p701 / 8203, reader pending: the manual's own statement of what is deliberately absent from a ticket -- the panel ID and the behind-panel sections +unread drawings.alternate_datum -- landed 2026-08-31 from the plant standards manual p811 / 10302.2 and p814 / 10304, reader pending: on an angled-top panel embeds are dimensioned to a WORK POINT, a legitimate alternative datum that will confuse a naive datum check +unread drawings.dimensions -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 note 5, p810 / 10302.1, p814 / 10304, reader pending: centre-of-embed is this plant's universal datum and is a precondition for any check comparing a printed dimension to a modelled position +unread drawings.orientation_on_sheet -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 notes 2, 3, 12, 13 and p819 / 10315.1, reader pending: drawings.section_a and section_b were measured off crop boxes on three pieces; this is the convention that binding should agree with +unread drawings.attached_bar_rule -- landed 2026-08-31 from the plant standards manual p413-418 / 5100.1-5100.6 and p701 / 8203, reader pending: the rule behind drawings.annotation_covered_by_parent, which is a two-entry list today and goes stale in silence the way drawings.reinforcing_rule's predecessor did +unread drawings.cad_layers -- landed 2026-08-31 from the plant standards manual p701 / 8203, reader pending: the layer a thing is drawn on IS its classification, and REBAR-HEAVY / REBAR-MED / REBAR-LIGHT is a reinforcing size split +unread handling.weight_band_lb -- landed 2026-08-31 from the plant standards manual p800 / 10230.1, reader pending AND RULING PENDING: knowledge.DEFAULTS' weight_tolerance 0.02 is unchanged and 2.2 WGT-* still runs on it; this is the manual's -0/+100 lb band, parked beside it as open-questions A6.4 +unread geometry -- landed 2026-08-31 from the plant standards manual p168-170 / 3040-3042, p814 / 10304, p712 / 8320.1, p278 / 4294.0, p784 / 10150, reader pending: the recessed-plate rule ties a geometric property to a required X-sheet callout, which is checkable against DETAIL-CALLOUT +unread finishes -- landed 2026-08-31 from the plant standards manual p165-166 / 3010, 3011, reader pending: a closed vocabulary with two checkable obligations -- a raked panel needs a direction on the ticket, and an end finish is checkable against the product type +unread concrete.wall_panel_default -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: f'ci 3500 gives 3.1/3.2/3.3 a plausibility anchor. f'c is null on purpose -- printed 5000, hand-revised 6000 in red, and neither state is settled (audit Q2) +unread concrete.release_range_psi -- landed 2026-08-31 from the plant standards manual p627 / 6200, reader pending: the release-strength range this plant offers +unread concrete.release_standard_psi -- landed 2026-08-31 from the plant standards manual p627 / 6200, reader pending: 3500 psi is the standard release, the rest is specialty +unread concrete.day28_range_psi -- landed 2026-08-31 from the plant standards manual p627 / 6200, reader pending: 5000-7000 psi at 28 days available or more +unread concrete.mix_role -- landed 2026-08-31 from the plant standards manual p800 / 10230.1, reader pending: FACE = BOTTOM wythe and BASE = TOP, because the panel is cast face-down. A reader that takes FACE MIX for the exterior mix is right by accident; one that takes it for the top wythe is wrong +unread concrete.single_wythe_uses -- landed 2026-08-31 from the plant standards manual p800 / 10230.1, reader pending: a single-layer piece uses the BASE mix number, not the face one +unread concrete.named_mixes -- landed 2026-08-31 from the plant standards manual p164 / 3003, reader pending: the plant's named mixes quoted directly. The positional digit decoding an extraction inferred is filed C there and deliberately not landed +unread material_grades -- landed 2026-08-31 from the plant standards manual p863 / 11301.1 and p412 / 5015, reader pending: a plant CHOICE among published ASTM options, confidence B because it is read off a filled example form. HSS is null -- A513 struck to A500-C in red, neither state settled +unread openings -- landed 2026-08-31 from the plant standards manual p760 / 9301 and p887 / 12261, reader pending: a void below 100 sq in or 8 in is one the plant does not cast, which filters the population 6.4 DUN-OPENING, 4.7 and liftexport._openings all count +unread revisions -- landed 2026-08-31 from the plant standards manual p723 / 8511, reader pending: a ticket at revision 3 whose two predecessors are missing is NORMAL, because the sequence advances project-wide. The old-cloud layer name is null -- two spellings in one manual (audit Q10) +unread connections -- landed 2026-08-31 from the plant standards manual p216-220 / 4201-4205, p231 / 4214, p243 / 4232, p375-403 / 4900-4974, reader pending: base-connection count by seismic category and butt-joint count by panel height are countable findings for 5.1 BOM-MEMBERS. The alignment count is null -- its table is red-marked (audit Q2) + +# FOUR FACTS THIS PASS LANDED HAVE NO LINE HERE, AND THE REASON IS A BLIND +# SPOT RATHER THAN A READER: notes.handling_views_on_sheet, +# notes.handling_view_order, face_designations.carried_by_hatch and +# face_designations.insulation_hatch all sit under a subtree that some caller +# fetches WHOLE. The detector credits a covering read of an ancestor, so it +# never reports them and an allowlist line for them is refused as stale. They +# are as unread as everything below -- nothing acts on any of the four -- and +# they are recorded in knowledge/library/LEDGER.md D1 instead. If the covering +# read is ever narrowed, four findings appear at once and they are these. + + +# ---- two owner rulings landed the same day, 2026-08-31 ------------------- +# Not from the library; from the owner directly, landed in the same pass +# because it holds the plant file. See knowledge/library/LEDGER.md D1's row +# for the pass and QNA/will-owes-2026-08-31.md for the open consequence. +unread lifters.min_edge_distance_in_by_panel_type -- landed 2026-08-31 from the owner directly ("insulated panel minimum edge distance is 2ft, plant knowledge"), reader pending: knowledge.families_for() resolves a *_by_panel_type map for a LIST role only and there is no scalar resolver, so 6.2 still runs on the published-first order in ticketchecks._edge_limit(). The composition a reader must implement is LARGER-OF with the provenance named -- the plant's 24 in exceeds every published part minimum, so published-first would silently discard it +unread panel_types.detect_verified_on -- provenance for panel_types.detect_verified, which IS read and was armed 2026-08-31. A date beside a flag is for the person deciding whether the flag still holds; same shape as connectors.recognition_verified_on above +unread panel_types.detect_verified_by -- as above: what the arming rested on (three signals at 100% separation over 282 panels, a 1,951-mark connector census, and the owner's ruling), and the one thing it did NOT rest on -- a live press -- so the flag can be argued with rather than inherited + + +# ---- the live-model guard, 2026-09-01 ------------------------------------ +# Both ARE read, on every single bridge request, by +# `vibe/bridgeguard.config_from()`. The detector cannot see it: that function +# is deliberately given `knowledge.get` as an ARGUMENT rather than importing +# it, so the module stays free of anything that reads a file and can be unit +# tested off-Revit (tests/test_bridgeguard.py). The literal paths are there, +# one line apart -- they just arrive through a parameter named `getter`. +unread bridge.refuse_titles -- read every request via bridgeguard.config_from(know, knowledge.get); the path literal is passed to an injected getter, not to knowledge.get by name, which is the wrapper shape this detector says it does not follow +unread bridge.allow_roots -- as above, same call, same line pair + + +# =========================================================================== +# GROUP 4 -- LANDED FROM THE PDF LIBRARY, READER PENDING. 2026-09-07. +# =========================================================================== +# Stage 3c of knowledge/library/RUNBOOK.md, run against library row P1 -- the +# PCI Design Handbook, 9th ed. (MNL-120-25), read live in the owner's Chrome +# via the Apryse WebViewer (licensed, viewed online, not downloaded). This is +# the PUBLIC layer's own pass, unlike GROUP 3's plant-layer D1 pass: every +# value below identifies no producer, no job and no client, so it landed in +# knowledge/catalogue.yaml rather than a plant file. Same rule as GROUP 3 +# applies: `unread` is used, every reason says "reader pending", and the +# tension with this file's own header is the same one GROUP 3 already +# raised and not re-argued here. +# +# Cites are PDF VIEWER page / printed page (footer), same convention as +# GROUP 3. Full extraction, confidence marks and screen-verification notes: +# knowledge/library/notes/PCI9-5.8-losses.md, PCI9-5.12-insulated.md, +# PCI9-5.17-design-aids.md, PCI9-ch8-handling.md, PCI9-ch15-reference.md. +# Ledger row: knowledge/library/LEDGER.md P1. +# +# The reason this is FIVE lines and not fifty: the detector reports the +# HIGHEST unread ancestor, not every leaf -- same shape as the pre-existing +# `catalogue.shear_grid` / `catalogue.slotted_inserts` findings, which this +# pass did not touch and which are still unread themselves (see the plain +# findings list, not this file). + +unread catalogue.prestress_losses -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 5.8 "Loss of Prestress", viewer p293-296 / printed 5-42 to 5-45, reader pending: this is what will/REVIT #24 needs -- the panellift/liftexport engine today assumes a lump-sum prestress-loss percentage, and the owner ruled the tool must compute losses per the PCI/Zia method instead of assuming one. The ES/CR/SH/RE equations and the Kre/J/C coefficient tables are landed; nothing calls them yet. +unread catalogue.strand -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 15.2, Design Aids 15-9/15-10/15-11/15-12, viewer p862-863 / printed 15-31/15-32, reader pending: nominal diameter -> area/weight/fpu for ASTM A416 strand (incl. 1/2" SPECIAL at 0.167 in^2), A421 wire and A722 bars, plus the design stress-strain curve. No product-family module currently reads a strand area from this table; ticket QC's own strand count/spacing checks read the model's own strand matrix (strand-matrix-geometry-read), not a published property table. +unread catalogue.camber_and_bowing -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 5.9 and Design Aids 5-1/5-2, viewer p293-300 / printed 5-45 to 5-49 and p448-449 / printed 5-197/5-198, reader pending: the PCI multiplier-method table (1.85/1.80/2.70/2.45/3.00/2.30), the three camber-vs-strand-pattern formulas, and the bowing-restraint-force design aid (including the 0.75*Ec short-term effective-modulus fact). Nothing in this tool currently computes camber or bowing. +unread catalogue.insulated_panel -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 5.12, viewer p304-309 / printed 5-53 to 5-58, reader pending: insulation max-use-temperatures, the four composite/noncomposite/partially-composite/semicomposite structural design assumptions, and wythe/strand/connector-spacing limits (24 in o.c., 12-14 gauge). Distinct from `insulation.*` (a plant/job-layer namespace already read for the current job's recess/hatch geometry) -- this is the published PCI design-method layer, not this job's drafted geometry. +unread catalogue.handling -- landed 2026-09-07 from PCI Design Handbook 9th ed. Chapter 8 "Component Handling and Erection Bracing", viewer p620-642 / printed 8-3 to 8-23, reader pending: Table 8.3.1's stripping/handling/transport load multipliers, the 4x/5x/2x lifting/hardware/bracing safety factors (OSHA 1926.704), Table 8.3.3's aircraft-cable/wire-rope safe loads, the strand-loop capacity defaults (10 kip base, 1.7x/2.2x for double/triple loops), and Figure 8.6.1's rotation-pick moment/reaction coefficients. NAMING NOTE for whoever picks this up: there is an UNRELATED, already-read top-level `handling.*` namespace (`handling.connectors.declared_action`, `handling.ignore_lateral_prestress` in liftexport.py) that is panellift's OWN per-job handling-analysis config -- a different dotted path (`catalogue.handling.*` vs `handling.*`) so no collision today, but close enough in name to confuse a reader; consider renaming one of the two before wiring a check to either. + + +# =========================================================================== +# GROUP 5 -- LANDED FROM THE PDF LIBRARY, READER PENDING. 2026-09-07. +# =========================================================================== +# Stage 3 of knowledge/library/RUNBOOK.md, run against ACI 318-19 Chapter 25 +# "Reinforcement Details" (§25.4 Development of reinforcement, §25.5 +# Splices), read from the code's own text layer, not a producer's manual -- +# PUBLIC, same as GROUP 4: it identifies no producer, no job, no client. +# Full extraction, confidence marks and the raw-text-fragment cross-checks: +# knowledge/library/notes/ACI318-19-ch25-development.md. Ledger row: C1 in +# knowledge/library/LEDGER.md. + +unread catalogue.development_length -- landed 2026-09-07 from ACI 318-19 Chapter 25 §25.4/§25.5, printed p.471-491, reader pending: development-length and lap-splice formulas, minimums and modification factors for deformed bars/wires in tension and compression, standard hooks, headed bars, pretensioned strand, welded wire reinforcement, and the excess-reinforcement reduction with its six exclusions. No product-family module currently checks a bar's or a strand's development length against a formula -- ticket QC reads dimension strings and bar counts off the model today, not an embedment length. The reader this is for: a future rebar/strand embedment check (would sit beside ticketchecks.py's existing bar checks) and the strand transfer-length assumption panellift/liftexport currently treats as a plant-layer default rather than computing from Eq. 25.4.8.1. diff --git a/tools/knowledgelint.py b/tools/knowledgelint.py new file mode 100644 index 0000000..13a5669 --- /dev/null +++ b/tools/knowledgelint.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Strict-parse every knowledge layer on this machine. Exit 1 if one will not. + + python tools/knowledgelint.py # every standard layer here + python tools/knowledgelint.py a.yaml b.yaml # just these + python tools/knowledgelint.py knowledge/ # everything under a folder + python tools/knowledgelint.py --list # what it would check + python tools/knowledgelint.py --json # machine readable + +**AFTER EDITING ANY KNOWLEDGE LAYER, RUN THIS.** A landing agent that writes +a fact into `knowledge/plant//knowledge.yaml`, a job file, the +catalogue or a project config is not done until this passes. It takes under a +second and it is the only thing between a mistyped file and a whole layer +disappearing inside Revit. + +WHY IT IS A GATE, AND WHY `scrub.py --private` IS NOT +----------------------------------------------------- +`--private` is an inventory: what it prints is raw material, and a check that +fails on every run gets wrapped in `|| true`. This one is the opposite. An +unparseable knowledge layer is ALWAYS wrong -- there is no state of the world +in which a file the tool cannot read is acceptable -- so a failure here blocks, +and exit 1 means exactly that. + +WHAT IT IS FOR (measured 2026-09-01) +------------------------------------ +pyRevit's reader is YamlDotNet, and it is STRICT: a mapping key stated twice +raises `Duplicate key` and the read returns nothing. `knowledge._merge_file` +wrapped that read in a bare `except Exception: return merged`, so one nested +duplicate in one plant file silently discarded the ENTIRE 255KB plant layer. +Every parameter role mapped to None, the sweep enumerated 0 assemblies, and +every report rendered normally -- `sources()` and `config_problems()` both +said healthy. A layer that fails to load looks exactly like a layer that +agrees with the defaults. + +`_merge_file` now records `__unreadable` and `config_problems()` refuses +on it, so Revit will say so. But that is the late warning, one button press +before a wrong report; this is the early one, on the machine where the edit was +made, before Revit is even open. + +Host CPython 3.x, PyYAML. It reads with a unique-key SafeLoader -- the same +strictness YamlDotNet has and the same one `tests/pyrevit_yaml_fake.py` +doubles -- and NOT with `vibe.yamlkit`, which is a hand-rolled subset that +takes the last of two duplicate keys and says nothing. + +A NOTE ON WHAT IT PRINTS +------------------------ +It names files, including the gitignored plant and job layers, because a lint +tool that will not tell you which file failed is not a lint tool. Those paths +carry job and client names. The output is for your terminal: do not paste it +into a tracked file, a commit message or a doc. +""" +from __future__ import annotations + +import argparse +import io +import json +import os +import sys + +try: + import yaml +except ImportError: # pragma: no cover + sys.stderr.write('PyYAML is required: pip install pyyaml\n') + raise SystemExit(2) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CATALOGUE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml') +CATALOGUE_TEMPLATE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml.template') + +# Mirrors `vibe.plantpath` / `vibe.designspec`, which cannot be imported here: +# `vibe/__init__.py` pulls in the Revit API. +PLANT_ENV = 'WILLTOOLS_KNOWLEDGE' +PLANT_FOLDER = 'WillTools' +JOBS_ENV = 'WILLTOOLS_JOBS' +LAYER_FILE = 'knowledge.yaml' +CONFIG_POINTER = 'active-config.txt' + +SKIP_DIRS = {'__pycache__', '.git', 'dist', '_archive', '_scratch', + '.pytest_cache', 'node_modules', 'library'} +YAML_SUFFIXES = ('.yaml', '.yml') + + +class DuplicateKeyError(yaml.YAMLError): + """A mapping key stated twice -- what YamlDotNet refuses and PyYAML allows. + + Named as its own class so a caller can tell the failure this tool exists + for from an ordinary syntax error, and worded to lead with the same two + words pyRevit's reader uses (`Duplicate key`) so the string a person sees + here is the string they will see in Self Check. + """ + + +def _unique_key_loader(): + """SafeLoader that raises on a duplicate mapping key at ANY depth. + + The standard recipe: `construct_mapping` runs once per mapping node, so + nesting comes free, and `key_node.start_mark` points at the SECOND + statement of the key -- the line to delete. + """ + class UniqueKeyLoader(yaml.SafeLoader): + pass + + def construct_mapping(loader, node, deep=False): + seen = set() + for key_node, _value in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in seen + except TypeError: # unhashable key: not this question + continue + if duplicate: + mark = key_node.start_mark + raise DuplicateKeyError( + 'Duplicate key: {0!r}, stated again at line {1}, column ' + '{2}. A mapping may state a key only once; pyRevit\'s ' + 'reader refuses the WHOLE FILE over it, so the layer is ' + 'not merged at all.'.format( + key, mark.line + 1, mark.column + 1)) + seen.add(key) + return yaml.SafeLoader.construct_mapping(loader, node, deep=deep) + + UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_mapping) + return UniqueKeyLoader + + +def check_file(path): + """(ok, message) for one file. `ok` False means the tool cannot read it. + + The message carries line and column wherever the parser gives them -- + a duplicate key in a 4,000-line plant file is otherwise a search. + """ + try: + handle = io.open(path, 'r', encoding='utf-8') + except IOError as ex: + return False, 'cannot open: {0}'.format(ex) + try: + text = handle.read() + except (IOError, UnicodeDecodeError) as ex: + return False, 'cannot read: {0}'.format(ex) + finally: + handle.close() + + try: + yaml.load(text, Loader=_unique_key_loader()) + except DuplicateKeyError as ex: + return False, str(ex) + except yaml.YAMLError as ex: + return False, _yaml_message(ex) + except Exception as ex: # pragma: no cover + return False, '{0}: {1}'.format(type(ex).__name__, ex) + return True, 'ok' + + +def _yaml_message(ex): + """A PyYAML error as one line, with the line/column it knows about.""" + problem = (getattr(ex, 'problem', None) or str(ex)).strip() + mark = getattr(ex, 'problem_mark', None) + if mark is None: + return problem + context = (getattr(ex, 'context', None) or '').strip() + where = 'line {0}, column {1}'.format(mark.line + 1, mark.column + 1) + if context: + return '{0} ({1}) at {2}'.format(problem, context, where) + return '{0} at {1}'.format(problem, where) + + +# ------------------------------------------------------------------ targets + +def plant_root(): + """The folder the plant layers live in. Mirrors `plantpath.plant_root`. + + Set to a folder, that is the root; set to a file, its directory is (the + machine-wide file lives IN the root); unset, %APPDATA%/WillTools. + """ + override = os.environ.get(PLANT_ENV) + if not override: + base = os.environ.get('APPDATA') or os.path.expanduser('~') + return os.path.join(base, PLANT_FOLDER) + if os.path.isdir(override): + return override + return os.path.dirname(override) or override + + +def default_targets(): + """Every standard layer location that EXISTS on this machine. + + The four layers `knowledge.load()` merges, resolved the way it resolves + them minus the document it does not have: + + * `knowledge/catalogue.yaml`, in this repo; + * the plant root -- `/knowledge.yaml` and every + `//knowledge.yaml`; + * every `//knowledge.yaml`, plus the project config each + job's `active-config.txt` points at; + * and, whatever the environment says, everything under this checkout's + own `knowledge/plant/` and `knowledge/job/`, because that is where + these files are edited here and an agent whose environment is not set + up must still be checking the files it just wrote. + + Deduplicated by real path and returned in that order. A `.bak-*` snapshot + is skipped: nothing reads one, and yesterday's file failing would make + this a check people learn to ignore. + """ + out = [] + _add(out, CATALOGUE) + + root = plant_root() + _add(out, os.path.join(root, LAYER_FILE)) + for name in _entries(root): + _add(out, os.path.join(root, name, LAYER_FILE)) + + jobs = os.environ.get(JOBS_ENV) + if jobs: + for name in _entries(jobs): + _add(out, os.path.join(jobs, name, LAYER_FILE)) + _add(out, _config_pointed_at(os.path.join(jobs, name))) + + for folder in ('plant', 'job'): + for path in walk_yaml(os.path.join(ROOT, 'knowledge', folder)): + _add(out, path) + return out + + +def _add(out, path): + """Append an existing file, once, by real path.""" + if not path or not os.path.isfile(path): + return out + real = os.path.realpath(path) + if real not in [os.path.realpath(p) for p in out]: + out.append(path) + return out + + +def _entries(folder): + try: + return sorted(os.listdir(folder)) + except OSError: + return [] + + +def _config_pointed_at(job_dir): + """The project config a job's one-line pointer names, if it is there. + + A pointer to a file that is GONE is `knowledge._config_missing`, which the + buttons already refuse on, and it is not a parse failure -- the file may + simply live on a share this machine is not on today. So it is not this + tool's verdict: it returns None and the run stays about parseability. + """ + pointer = os.path.join(job_dir, CONFIG_POINTER) + if not os.path.isfile(pointer): + return None + try: + handle = io.open(pointer, 'r', encoding='utf-8') + except IOError: + return None + try: + target = handle.read().strip() + finally: + handle.close() + return target or None + + +def walk_yaml(folder): + """Every YAML file under a folder, sorted, without the `.bak-` snapshots.""" + out = [] + if not os.path.isdir(folder): + return out + for dirpath, dirnames, filenames in os.walk(folder): + dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) + for name in sorted(filenames): + if name.endswith(YAML_SUFFIXES) and '.bak-' not in name: + out.append(os.path.join(dirpath, name)) + return out + + +def resolve(paths): + """Command-line arguments as a list of files. A folder means everything in it.""" + out = [] + for path in paths: + if os.path.isdir(path): + for found in walk_yaml(path): + _add(out, found) + elif os.path.isfile(path): + _add(out, path) + else: + out.append(path) # kept, and reported as unreadable + return out + + +# --------------------------------------------------------------------- main + +def run(paths): + """[(path, ok, message)] for everything asked for, in order.""" + return [(path,) + check_file(path) for path in paths] + + +def main(argv=None): + parser = argparse.ArgumentParser( + description='Strict-parse the knowledge layers. Exit 1 if one fails.') + parser.add_argument('paths', nargs='*', + help='files or folders; default: every standard ' + 'layer location that exists on this machine') + parser.add_argument('--list', action='store_true', + help='print what would be checked and stop') + parser.add_argument('--json', action='store_true', dest='as_json', + help='machine-readable results') + args = parser.parse_args(argv) + + targets = resolve(args.paths) if args.paths else default_targets() + + if args.list: + for path in targets: + print(path) + return 0 + + if not targets: + # Not a pass, ordinarily: nothing was checked, and saying "ok" to + # that is the exact collapse this repository keeps paying for on the + # owner's own machines, where knowledge/catalogue.yaml is always + # tracked and this branch should never be reachable. + # + # ONE EXCEPTION, and it is checked by absence of the file this + # checkout SHOULD have plus presence of the file that stands in for + # it on a stripped install (see tools/make_dist.py): a colleague + # trial distribution genuinely ships no knowledge layer at all, on + # the owner's own instruction, and "nothing to check" is the honest, + # expected answer there -- SKIP-shaped, not FAIL-shaped, same + # discipline tools/setup_machine.py --check applies to the same + # state. This is the only branch where exit 0 with nothing checked + # is not a lie. + if (not args.paths and os.path.isfile(CATALOGUE_TEMPLATE) + and not os.path.isfile(CATALOGUE)): + print('no knowledge layers configured -- checks that need them ' + 'stand down. This looks like a knowledge-less trial ' + 'install ({0} is present in place of the real catalogue): ' + 'expected here, not a failure. Ask the owner for ' + 'knowledge/catalogue.yaml plus a plant/job layer to check ' + 'them for real.'.format(CATALOGUE_TEMPLATE)) + return 0 + print('no knowledge layer found to check -- neither {0} nor {1} is ' + 'set and this checkout has no plant or job files. Name the ' + 'files to check, or set the variables.'.format(PLANT_ENV, + JOBS_ENV)) + return 1 + + results = run(targets) + failed = [r for r in results if not r[1]] + + if args.as_json: + print(json.dumps( + {'checked': len(results), 'failed': len(failed), + 'results': [{'path': p, 'ok': ok, 'message': msg} + for p, ok, msg in results]}, + indent=2)) + return 1 if failed else 0 + + for path, ok, message in results: + print('{0} {1}'.format('ok ' if ok else 'FAILED', path)) + if not ok: + print(' {0}'.format(message)) + + print('') + if failed: + print('{0} of {1} layer file(s) WILL NOT LOAD.'.format(len(failed), + len(results))) + print('pyRevit refuses the whole file, so the whole layer is dropped ' + 'and every value in it silently falls back -- which looks ' + 'exactly like a layer that agrees with the defaults. Fix the ' + 'file; nothing will guess at what it said.') + return 1 + print('{0} layer file(s) parse.'.format(len(results))) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/layersplit.py b/tools/layersplit.py new file mode 100644 index 0000000..9ed7e02 --- /dev/null +++ b/tools/layersplit.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +"""Move long comment blocks out of a knowledge layer into a rationale file. + +Values are never touched. Two gates prove it: + 1. the parsed data must be byte-identical before and after; + 2. every comment line that leaves the YAML must appear verbatim in the + rationale file. +Gate 1 is the caller's; gate 2 is here. +""" +import re, sys, json, hashlib + +MIN_RUN = 10 +KEY_RE = re.compile(r'^(\s*)([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$') +BLOCK_RE = re.compile(r':\s*[|>][-+]?\d*\s*$') +SOURCE_RE = re.compile(r'#.*\bSOURCE\b') +# page / date / confidence tokens: these stay beside the value, so a number is +# never left in the file with its provenance a file away. +CITE_RE = re.compile(r'\bp\.?\s?(\d{2,4})\b|\b(\d{4}-\d\d-\d\d)\b' + r'|\b[Cc]onfidence ([A-D])\b') + + +def classify(lines): + """'comment' | 'data' | 'blank' | 'scalar' for each line, block-aware. + + A '#' inside a |/> block scalar is CONTENT, not a comment; treating it as + one would delete data. + """ + kinds = [None] * len(lines) + i = 0 + while i < len(lines): + l = lines[i] + s = l.strip() + if BLOCK_RE.search(l) and not s.startswith('#'): + kinds[i] = 'data' + m = KEY_RE.match(l) + base = len(m.group(1)) if m else len(l) - len(l.lstrip()) + i += 1 + while i < len(lines): + nxt = lines[i] + if not nxt.strip(): + kinds[i] = 'scalar'; i += 1; continue + if len(nxt) - len(nxt.lstrip()) <= base: + break + kinds[i] = 'scalar'; i += 1 + continue + kinds[i] = 'blank' if not s else ('comment' if s.startswith('#') else 'data') + i += 1 + return kinds + + +def path_of(lines, kinds, idx): + m = KEY_RE.match(lines[idx]) + if not m: + return None + ind = len(m.group(1)); parts = [m.group(2)] + for j in range(idx - 1, -1, -1): + if kinds[j] != 'data': + continue + mj = KEY_RE.match(lines[j]) + if not mj: + continue + ij = len(mj.group(1)) + if ij < ind: + parts.append(mj.group(2)); ind = ij + if ind == 0: + break + return '.'.join(reversed(parts)) + + +def run(src, out_yaml, out_md, title): + text = open(src, encoding='utf-8').read() + lines = text.split('\n') + kinds = classify(lines) + + runs, cur = [], [] + for i, k in enumerate(kinds): + if k == 'comment': + cur.append(i) + elif k == 'blank' and cur: + cur.append(i) # a blank line inside a block is a paragraph + else: + if cur: runs.append([j for j in cur if kinds[j] == 'comment']) + cur = [] + if cur: runs.append([j for j in cur if kinds[j] == 'comment']) + runs = [r for r in runs if r] + + moved = {} + for r in runs: + if len(r) < MIN_RUN or r[0] == 0: # file preamble stays put + continue + nxt = next((j for j in range(r[-1] + 1, len(lines)) if kinds[j] == 'data'), None) + if nxt is None: + continue + p = path_of(lines, kinds, nxt) + if p: + moved[r[0]] = [p, r] + + seen = {} + for k in sorted(moved): + p, r = moved[k] + n = seen.get(p, 0); seen[p] = n + 1 + if n: + moved[k][0] = '%s (%d)' % (p, n + 1) + + md = ['# %s -- rationale' % title, '', + 'Why the values in `knowledge.yaml` are what they are. Moved out of', + 'that file so the keys the tool actually reads stay legible. NOTHING', + 'HERE IS PARSED by anything -- it is for the reader, human or model.', + '', + 'Each section is named for the key it explains. The key itself keeps a', + '`# sources:` stub and a pointer back here, so no number sits in the', + 'layer with its provenance out of sight.', + '', + 'Generated once by tools/layersplit.py; edit freely from here on.', + '', '---', ''] + + drop, inject, gate2 = set(), {}, [] + for k in sorted(moved): + p, r = moved[k] + body, keep = [], [] + pages, dates, conf = [], [], [] + for j in r: + t = lines[j].strip()[1:] + if t.startswith(' '): + t = t[1:] + body.append(t) + gate2.append(t) + if SOURCE_RE.search(lines[j]): + keep.append(lines[j]) + for pg, dt, cf in CITE_RE.findall(lines[j]): + if pg and 'p' + pg not in pages: pages.append('p' + pg) + if dt and dt not in dates: dates.append(dt) + if cf and cf not in conf: conf.append(cf) + md += ['## %s' % p, ''] + body + [''] + + indent = re.match(r'^(\s*)', lines[r[0]]).group(1) + stub = [] + if pages: stub.append(' '.join(pages[:6]) + (' +' if len(pages) > 6 else '')) + if dates: stub.append(' '.join(sorted(dates)[:3]) + (' +' if len(dates) > 3 else '')) + if conf: stub.append('confidence ' + '/'.join(sorted(conf))) + ptr = list(keep) + if stub: + ptr.append('%s# sources: %s' % (indent, '; '.join(stub))) + ptr.append('%s# -> rationale.md \u00a7 %s' % (indent, p)) + inject[r[0]] = ptr + drop.update(r) + + out = [] + for i, l in enumerate(lines): + if i in inject: + out.extend(inject[i]) + if i not in drop: + out.append(l) + + yaml_txt = '\n'.join(out) + md_txt = '\n'.join(md) + open(out_yaml, 'w', encoding='utf-8').write(yaml_txt) + open(out_md, 'w', encoding='utf-8').write(md_txt) + + # ---- gate 2: nothing was lost or mangled on the way out -------------- + missing = [t for t in gate2 if t and t not in md_txt] + print('runs %d, moved %d' % (len(runs), len(moved))) + print('yaml %d -> %d lines (%.0f%% smaller)' + % (len(lines), len(out), 100.0 * (1 - len(out) / float(len(lines))))) + print('rationale %d lines' % len(md)) + print('GATE 2 (every moved comment line present verbatim): %s' + % ('PASS' if not missing else 'FAIL -- %d missing' % len(missing))) + for t in missing[:5]: + print(' missing: %r' % t) + return 0 if not missing else 1 + + +sys.exit(run(*sys.argv[1:])) diff --git a/tools/libindex.py b/tools/libindex.py new file mode 100644 index 0000000..b716b04 --- /dev/null +++ b/tools/libindex.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Full-text index over the knowledge library, one row per PDF page. + + python tools/libindex.py build + python tools/libindex.py search "lifting anchor" --limit 5 + python tools/libindex.py status + +The library is hundreds of pages of source PDFs plus the extraction notes +written beside them. Answering "which document, which page, says X" by +re-reading PDFs costs minutes every time and is the single most repeated act +in `knowledge/library/RUNBOOK.md`. This makes it one query. + +SQLite + FTS5, lexical only. There is no embedding model here and no +semantic matching -- a search finds the WORDS you typed, so a miss means +"those words are not in the index", never "the library does not say that". +The schema leaves room for a vector layer later (`pages.method` already +distinguishes how a page's text was obtained) but nothing here pretends to be +one. + +WHAT IS IN THE DATABASE, AND WHERE IT LIVES +-------------------------------------------- +`knowledge/library/index.sqlite`, inside a folder that is gitignored as a +folder. That is deliberate twice over: the index holds the verbatim text of +documents that name producers and jobs, so it may never be committed -- and it +is a REBUILDABLE ARTIFACT, so it never needs to be. Delete it and run `build`. + +Identity is the CONTENT HASH, the same convention the ledger keys on: a file +that moved is the same document and is not re-read; a file whose bytes changed +is a new document and replaces the old rows. Two paths holding identical bytes +are one document, and the second is reported rather than duplicated. + +This tool PRINTS the contents of gitignored source material. Its output can +carry a producer's name even though this file never does. Do not paste a line +of its output into a tracked file; `CLAUDE.md` section 3 has the placeholders. + +Host CPython 3.11+ (modern syntax is fine here, as in `tools/rvt.py`); it is +never imported from Revit. +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import hashlib +import os +import re +import sqlite3 +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_DB = os.path.join(ROOT, 'knowledge', 'library', 'index.sqlite') +DEFAULT_ROOTS = ( + os.path.join(ROOT, 'knowledge', 'library'), + os.path.join(ROOT, 'knowledge', 'MC'), +) + +SCHEMA_VERSION = 1 + +#: How a page's text was obtained. Only one value exists today; a per-page +#: vision transcription would land as a second one ('vision'), which is why +#: this is a column and not an assumption. A page with no text layer is +#: still 'text-layer' -- we looked, with that method, and there was nothing. +METHOD_TEXT_LAYER = 'text-layer' + +# --------------------------------------------------------------------------- +# Tokenizer +# --------------------------------------------------------------------------- +# unicode61, diacritics folded, plus exactly three extra token characters: +# '#', "'" and '"'. Each one is a character that carries MEANING in this +# corpus and that the default tokenizer would throw away: +# +# #4 a bar size. Without '#', "#4" and a bare "4" are the same +# token and every page with a 4 on it answers a bar-size query. +# f'c concrete strength. Without "'", it is "f" AND "c". +# 8" an inch dimension. Without '"', it is a bare 8. +# +# Deliberately NOT token characters: +# +# '.' "7.5" would become one token -- and so would "7.5." at the end +# of a sentence, which then no longer matches a search for 7.5. +# Decimals are handled on the QUERY side instead: `_match_expr` +# turns 7.5 into the phrase "7" "5", which requires the two to be +# adjacent, so "value 7 and 5" does not match. Verified both ways +# in the tests. +# '-' hyphens join words far more often than they join marks, and a +# token character would stop "wall" finding "wall-panel". +# '_' same reasoning: leaving it a separator is what lets a search +# for "anchor" find ANCHOR_FACE_4T. +# +# Plate marks (P316) and part numbers need nothing special -- unicode61 does +# not split a letter/digit boundary. +# +# The quoting below is load-bearing and easy to get wrong: the value is an SQL +# double-quoted string (so the '"' inside it is doubled), and inside THAT the +# tokenchars list is an FTS5 single-quoted string (so the "'" inside it is +# doubled). FTS5 rejects '[...]' quoting here, whatever the SQL docs suggest. +TOKENIZE = '"unicode61 remove_diacritics 2 tokenchars \'#\'\'""\'"' + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +-- One row per DOCUMENT, keyed by content hash exactly as the ledger is. +-- `relpath` is where it was last seen; it is not the identity. +CREATE TABLE IF NOT EXISTS docs ( + hash TEXT PRIMARY KEY, + relpath TEXT NOT NULL, + pages INTEGER NOT NULL, + bytes INTEGER NOT NULL, + mtime REAL NOT NULL, + ingested_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS docs_relpath ON docs(relpath); + +-- One row per PAGE. An empty `text` with method 'text-layer' means "this +-- page was opened and its text layer was empty" -- a scanned image, whose +-- words live in the notes until a vision method exists to put them here. +CREATE TABLE IF NOT EXISTS pages ( + doc_hash TEXT NOT NULL, + page_no INTEGER NOT NULL, + text TEXT NOT NULL, + method TEXT NOT NULL, + PRIMARY KEY (doc_hash, page_no) +); + +-- One row per extraction note. `doc_ref` is the M#/D#/H# id the filename +-- starts with, when it has one -- the join back to the ledger's own numbering. +CREATE TABLE IF NOT EXISTS notes ( + relpath TEXT PRIMARY KEY, + doc_ref TEXT, + text TEXT NOT NULL, + mtime REAL NOT NULL, + bytes INTEGER NOT NULL, + hash TEXT NOT NULL, + ingested_at TEXT NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5( + text, + content='pages', + content_rowid='rowid', + tokenize = {tok} +); +CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( + text, + content='notes', + content_rowid='rowid', + tokenize = {tok} +); + +CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN + INSERT INTO pages_fts(rowid, text) VALUES (new.rowid, new.text); +END; +CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN + INSERT INTO pages_fts(pages_fts, rowid, text) + VALUES ('delete', old.rowid, old.text); +END; +CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN + INSERT INTO pages_fts(pages_fts, rowid, text) + VALUES ('delete', old.rowid, old.text); + INSERT INTO pages_fts(rowid, text) VALUES (new.rowid, new.text); +END; + +CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts(rowid, text) VALUES (new.rowid, new.text); +END; +CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, text) + VALUES ('delete', old.rowid, old.text); +END; +CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, text) + VALUES ('delete', old.rowid, old.text); + INSERT INTO notes_fts(rowid, text) VALUES (new.rowid, new.text); +END; +""".replace('{tok}', TOKENIZE) + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- +def use_utf8_stdout() -> None: + """Windows consoles are cp1252 and this corpus is not. + + A page of CJK, or a single typographic dash out of a PDF, raises + UnicodeEncodeError on print and the whole run looks like a crash rather + than like a result. `errors='replace'` is chosen over `'strict'` + deliberately: a character we cannot render is not a reason to lose the + hit that contains it. + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding='utf-8', errors='replace') + except (AttributeError, ValueError): # pragma: no cover - odd host + pass + + +def fts5_available() -> bool: + """True if this interpreter's sqlite3 can build an FTS5 table.""" + try: + con = sqlite3.connect(':memory:') + except sqlite3.Error: # pragma: no cover + return False + try: + con.execute('CREATE VIRTUAL TABLE probe USING fts5(x)') + return True + except sqlite3.Error: + return False + finally: + con.close() + + +NO_FTS5 = ('this python has no sqlite3 FTS5 -- the index cannot be built or ' + 'searched on this machine') + + +def sha256_file(path: str) -> str: + """Streamed: the biggest document in the library is 122 MB.""" + h = hashlib.sha256() + with open(path, 'rb') as f: + for block in iter(lambda: f.read(1 << 20), b''): + h.update(block) + return h.hexdigest() + + +def relpath(path: str) -> str: + """Repo-relative with forward slashes when it can be; absolute otherwise. + + An extra `--root` may sit anywhere on disk, and forcing a `..\\..\\` + relative path onto it would make the stored path unreadable. + """ + path = os.path.abspath(path) + try: + rel = os.path.relpath(path, ROOT) + except ValueError: # different drive on Windows + return path.replace('\\', '/') + if rel.startswith('..'): + return path.replace('\\', '/') + return rel.replace('\\', '/') + + +def now_iso() -> str: + return _dt.datetime.now(_dt.timezone.utc).replace( + microsecond=0).isoformat() + + +#: `M11a-standards-details-p001-085.md` -> `M11a`. A note whose name does not +#: start with a ledger id simply has no `doc_ref`; that is not an error, and +#: the column is NULL rather than '' so the difference stays visible. +_DOC_REF = re.compile(r'^([A-Za-z]{1,2}\d+[A-Za-z]?)[-_]') + + +def note_doc_ref(name: str) -> str | None: + m = _DOC_REF.match(os.path.basename(name)) + if not m: + return None + ref = m.group(1) + return ref[0].upper() + ref[1:] + + +# --------------------------------------------------------------------------- +# Connection / schema +# --------------------------------------------------------------------------- +def connect(db_path: str, create: bool = False) -> sqlite3.Connection: + if not create and not os.path.exists(db_path): + raise FileNotFoundError(db_path) + parent = os.path.dirname(os.path.abspath(db_path)) + if create and parent and not os.path.isdir(parent): + os.makedirs(parent, exist_ok=True) + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + con.execute('PRAGMA journal_mode = WAL') + con.execute('PRAGMA synchronous = NORMAL') + return con + + +def _schema_version(con: sqlite3.Connection) -> int | None: + try: + row = con.execute( + "SELECT value FROM meta WHERE key = 'schema_version'").fetchone() + except sqlite3.Error: + return None + return int(row['value']) if row else None + + +def ensure_schema(con: sqlite3.Connection, out=print) -> bool: + """Create the schema, wiping first if an older one is in the file. + + Returns True if it wiped. The index is a rebuildable artifact, so a + schema change costs a rebuild and never a migration -- but it says so out + loud, because a silent wipe and a silent no-op look identical from here. + """ + wiped = False + existing = _schema_version(con) + if existing is not None and existing != SCHEMA_VERSION: + out('index was built by schema v{0}, this is v{1} -- rebuilding from ' + 'scratch'.format(existing, SCHEMA_VERSION)) + drop_all(con) + wiped = True + con.executescript(_SCHEMA) + con.execute( + "INSERT INTO meta(key, value) VALUES ('schema_version', ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (str(SCHEMA_VERSION),)) + con.commit() + return wiped + + +def drop_all(con: sqlite3.Connection) -> None: + for name in ('pages_ai', 'pages_ad', 'pages_au', + 'notes_ai', 'notes_ad', 'notes_au'): + con.execute('DROP TRIGGER IF EXISTS {0}'.format(name)) + for name in ('pages_fts', 'notes_fts', 'pages', 'notes', 'docs', 'meta'): + con.execute('DROP TABLE IF EXISTS {0}'.format(name)) + con.commit() + + +def set_meta(con: sqlite3.Connection, key: str, value: str) -> None: + con.execute( + 'INSERT INTO meta(key, value) VALUES (?, ?) ' + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value', + (key, str(value))) + + +def get_meta(con: sqlite3.Connection, key: str) -> str | None: + row = con.execute('SELECT value FROM meta WHERE key = ?', + (key,)).fetchone() + return row['value'] if row else None + + +# --------------------------------------------------------------------------- +# Walking +# --------------------------------------------------------------------------- +def find_pdfs(root: str) -> list[str]: + found = [] + for base, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if not d.startswith('.')] + for name in sorted(files): + if name.lower().endswith('.pdf'): + found.append(os.path.join(base, name)) + return sorted(found) + + +def find_notes(root: str) -> list[str]: + """Every `*.md` under a folder called `notes`. + + Not every `*.md` under the root: `LEDGER.md` and `RUNBOOK.md` are + bookkeeping about the library rather than content out of it, and mixing + them into the same ranked list would answer "which document says X" with + a row from the index of documents. + """ + found = [] + for base, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if not d.startswith('.')] + parts = {p.lower() for p in + os.path.relpath(base, root).replace('\\', '/').split('/')} + if 'notes' not in parts: + continue + for name in sorted(files): + if name.lower().endswith('.md'): + found.append(os.path.join(base, name)) + return sorted(found) + + +# --------------------------------------------------------------------------- +# Ingest +# --------------------------------------------------------------------------- +def read_pdf_pages(path: str) -> list[str]: + """Text layer, one string per page. Raises on a PDF that cannot be read.""" + import pymupdf # imported here so that + # `search` and `status` work on a machine with no pymupdf installed. + doc = pymupdf.open(path) + try: + if doc.needs_pass: + raise ValueError('encrypted -- no password') + return [page.get_text().replace('\r\n', '\n') for page in doc] + finally: + doc.close() + + +class BuildReport: + """What one build run did. Printed by `cmd_build`, asserted by the tests.""" + + def __init__(self): + self.docs_new = 0 + self.docs_known = 0 + self.docs_moved = 0 + self.docs_removed = 0 + self.pages_added = 0 + self.pages_removed = 0 + self.duplicates: list[tuple[str, str]] = [] + self.unreadable: list[tuple[str, str]] = [] + self.notes_new = 0 + self.notes_changed = 0 + self.notes_known = 0 + self.notes_removed = 0 + self.seconds = 0.0 + + +def _under_any(rel: str, root_rels: list[str]) -> bool: + for r in root_rels: + if rel == r or rel.startswith(r + '/'): + return True + return False + + +def build(con: sqlite3.Connection, roots: list[str], out=print) -> BuildReport: + """Bring the index in step with what is on disk, under `roots` only. + + Confined to `roots` on purpose. A run with `--root` naming one folder + must not delete the rows of a folder it was not asked to look at, or + every narrowed re-run would silently gut the index. + """ + import time + started = time.time() + rep = BuildReport() + stamp = now_iso() + + root_rels = [relpath(r) for r in roots] + + # ---- PDFs ------------------------------------------------------------- + seen_hashes: dict[str, str] = {} + for root in roots: + if not os.path.isdir(root): + out('root not found, skipped: {0}'.format(relpath(root))) + continue + for path in find_pdfs(root): + rel = relpath(path) + try: + digest = sha256_file(path) + except OSError as exc: + rep.unreadable.append((rel, str(exc))) + continue + if digest in seen_hashes: + rep.duplicates.append((rel, seen_hashes[digest])) + continue + seen_hashes[digest] = rel + + row = con.execute('SELECT relpath, mtime FROM docs WHERE hash = ?', + (digest,)).fetchone() + if row is not None: + rep.docs_known += 1 + if row['relpath'] != rel: + con.execute('UPDATE docs SET relpath = ?, mtime = ? ' + 'WHERE hash = ?', + (rel, os.path.getmtime(path), digest)) + rep.docs_moved += 1 + continue + + try: + texts = read_pdf_pages(path) + except Exception as exc: # noqa: BLE001 - any reader + rep.unreadable.append((rel, '{0}: {1}'.format( + type(exc).__name__, exc))) + del seen_hashes[digest] + continue + + st = os.stat(path) + con.execute( + 'INSERT INTO docs(hash, relpath, pages, bytes, mtime, ' + 'ingested_at) VALUES (?, ?, ?, ?, ?, ?)', + (digest, rel, len(texts), st.st_size, st.st_mtime, stamp)) + con.executemany( + 'INSERT INTO pages(doc_hash, page_no, text, method) ' + 'VALUES (?, ?, ?, ?)', + [(digest, i + 1, t, METHOD_TEXT_LAYER) + for i, t in enumerate(texts)]) + rep.docs_new += 1 + rep.pages_added += len(texts) + con.commit() + + # Documents that were under a scanned root and are no longer there. + gone = [r['hash'] for r in con.execute('SELECT hash, relpath FROM docs') + if r['hash'] not in seen_hashes + and _under_any(r['relpath'], root_rels)] + for digest in gone: + n = con.execute('SELECT COUNT(*) c FROM pages WHERE doc_hash = ?', + (digest,)).fetchone()['c'] + con.execute('DELETE FROM pages WHERE doc_hash = ?', (digest,)) + con.execute('DELETE FROM docs WHERE hash = ?', (digest,)) + rep.docs_removed += 1 + rep.pages_removed += n + + # ---- notes ------------------------------------------------------------ + seen_notes: set[str] = set() + for root in roots: + if not os.path.isdir(root): + continue + for path in find_notes(root): + rel = relpath(path) + seen_notes.add(rel) + try: + with open(path, 'rb') as f: + raw = f.read() + except OSError as exc: + rep.unreadable.append((rel, str(exc))) + seen_notes.discard(rel) + continue + digest = hashlib.sha256(raw).hexdigest() + row = con.execute('SELECT hash FROM notes WHERE relpath = ?', + (rel,)).fetchone() + if row is not None and row['hash'] == digest: + rep.notes_known += 1 + continue + text = raw.decode('utf-8', errors='replace') + st = os.stat(path) + if row is None: + con.execute( + 'INSERT INTO notes(relpath, doc_ref, text, mtime, bytes, ' + 'hash, ingested_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + (rel, note_doc_ref(rel), text, st.st_mtime, st.st_size, + digest, stamp)) + rep.notes_new += 1 + else: + con.execute( + 'UPDATE notes SET doc_ref = ?, text = ?, mtime = ?, ' + 'bytes = ?, hash = ?, ingested_at = ? WHERE relpath = ?', + (note_doc_ref(rel), text, st.st_mtime, st.st_size, + digest, stamp, rel)) + rep.notes_changed += 1 + con.commit() + + gone_notes = [r['relpath'] for r in con.execute('SELECT relpath FROM notes') + if r['relpath'] not in seen_notes + and _under_any(r['relpath'], root_rels)] + for rel in gone_notes: + con.execute('DELETE FROM notes WHERE relpath = ?', (rel,)) + rep.notes_removed += 1 + + rep.seconds = time.time() - started + set_meta(con, 'last_build', stamp) + set_meta(con, 'last_build_seconds', '{0:.2f}'.format(rep.seconds)) + set_meta(con, 'last_build_roots', ' | '.join(root_rels)) + con.commit() + return rep + + +# --------------------------------------------------------------------------- +# Query building +# --------------------------------------------------------------------------- +class QueryError(ValueError): + pass + + +def _chunks(query: str) -> list[str]: + """Split a user query into phrase chunks. + + A `"` only opens a quoted phrase at the START of a chunk. That is what + lets an inch mark stay attached to its number -- `8"` is one bareword, + while `"shear wall"` is one phrase. + """ + out: list[str] = [] + i, n = 0, len(query) + while i < n: + if query[i].isspace(): + i += 1 + continue + if query[i] == '"': + j = query.find('"', i + 1) + if j < 0: + j = n + out.append(query[i + 1:j]) + i = j + 1 + else: + j = i + while j < n and not query[j].isspace(): + j += 1 + out.append(query[i:j]) + i = j + return [c for c in out if c.strip()] + + +# --------------------------------------------------------------------------- +# Thousands-separator equivalence (query side) +# --------------------------------------------------------------------------- +# The defect this exists for: `7,690` is a published capacity that appears in +# the source PDFs and the notes ALWAYS with the comma (that is how the +# catalogue prints it), so searching the bare digits `7690` -- which is how +# anyone asking "where does 7690 come from" actually types it -- returned 0 +# hits although the corpus holds the fact on four pages and in two notes. A +# search that cannot find a number it holds, and reports that as 0 rather +# than an error, is indistinguishable from "not in the corpus" and is exactly +# the failure this index exists to prevent (CLAUDE.md section on "could not +# judge" vs "judged, found nothing"). +# +# Why query-side rather than a stored, normalised copy: the comma is not a +# tokenchar (see the TOKENIZE comment above), so `unicode61` already splits +# `7,690` into the two adjacent tokens "7" and "690" at INDEX time -- that is +# how `"7,690"` (quoted, comma and all) already found those hits before this +# fix. The only missing half is the reverse: a bare `7690` tokenizes to the +# single token "7690", which never occurs in a corpus that always writes the +# comma. So nothing about how text is stored needs to change; the query just +# has to offer FTS5 both spellings and let it match either. That costs no +# rebuild and touches every existing row immediately, at the price of only +# helping searches that go through `match_expr` (not `--raw`, which hands the +# string to FTS5 untouched on purpose). +# +# Deliberately narrow triggers, so this never reaches past the number: +# * A chunk with a '.' is left alone -- decimals already work today via the +# adjacent-phrase trick above, and `3.5` / `4.0` / `0.6` must keep +# behaving exactly as they do now. This function never sees them: the +# caller checks for '.' first. +# * A chunk containing anything but digits and commas (`QL589G`, a bar mark +# `SR5602`, a control number `25008-40085`) is left untouched -- the +# letters or the hyphen already fail `_BARE_DIGITS` / `_GROUPED_DIGITS` +# below, so a part number is never mistaken for a grouped integer. +# * Grouping is only offered from 4 digits up (`1,000` is the shortest real +# grouped number); `4`, `#4`, `123` do not get a comma variant nobody +# would ever type. +_BARE_DIGITS = re.compile(r'^\d{4,}$') +_GROUPED_DIGITS = re.compile(r'^\d{1,3}(,\d{3})+$') + + +def _group_thousands(digits: str) -> str: + """`'7690'` -> `'7,690'`. Standard right-to-left grouping of 3.""" + rev = digits[::-1] + groups = [rev[i:i + 3] for i in range(0, len(rev), 3)] + return ','.join(groups)[::-1] + + +def _numeric_variants(chunk: str) -> list[str] | None: + """Both spellings of a thousands-grouped integer, or `None`. + + Returns `[bare, grouped]` when `chunk` is unambiguously an integer typed + either way (`'7690'` or `'7,690'`); `None` for anything else, which tells + the caller to fall back to quoting `chunk` exactly as it always has. + """ + if _BARE_DIGITS.match(chunk): + grouped = _group_thousands(chunk) + return [chunk, grouped] if grouped != chunk else None + if _GROUPED_DIGITS.match(chunk): + return [chunk.replace(',', ''), chunk] + return None + + +def match_expr(query: str, raw: bool = False) -> str: + """User text -> an FTS5 MATCH expression. + + Every chunk becomes a quoted phrase, so nothing the user types is read as + FTS5 operator syntax by accident and `7.5` becomes the adjacent pair + "7" "5" rather than a parse error. A trailing `*` on a bareword survives + as an FTS5 prefix. `--raw` hands the string over untouched, for the rare + query that really wants OR / NEAR / column filters. + + A chunk that is a bare or comma-grouped integer of 4+ digits (`7690` or + `7,690`) expands into an OR of both spellings, so a search finds a number + regardless of which way the source printed it. See `_numeric_variants`. + """ + if raw: + if not query.strip(): + raise QueryError('empty query') + return query + parts = [] + for chunk in _chunks(query): + prefix = chunk.endswith('*') and len(chunk) > 1 + if prefix: + chunk = chunk[:-1] + if not chunk.strip(): + continue + variants = None if prefix else _numeric_variants(chunk) + if variants: + parts.append('({0})'.format(' OR '.join( + '"{0}"'.format(v.replace('"', '""')) for v in variants))) + else: + parts.append('"{0}"{1}'.format(chunk.replace('"', '""'), + '*' if prefix else '')) + if not parts: + raise QueryError('empty query') + return ' '.join(parts) + + +_WS = re.compile(r'\s+') + + +def flatten(text: str, limit: int = 0) -> str: + """PDF text is full of newlines; a terminal hit should be one line.""" + flat = _WS.sub(' ', text).strip() + if limit and len(flat) > limit: + flat = flat[:limit - 3] + '...' + return flat + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- +def search_pages(con, expr, limit, doc=None): + sql = ["SELECT d.relpath, d.hash, p.page_no, bm25(pages_fts) AS score,", + " snippet(pages_fts, 0, '[', ']', ' ... ', 14) AS snip", + "FROM pages_fts", + "JOIN pages p ON p.rowid = pages_fts.rowid", + "JOIN docs d ON d.hash = p.doc_hash", + "WHERE pages_fts MATCH ?"] + args: list = [expr] + if doc: + sql.append('AND (d.hash LIKE ? OR d.relpath LIKE ?)') + args += [doc.lower() + '%', '%' + doc + '%'] + sql.append('ORDER BY score LIMIT ?') + args.append(limit) + return con.execute('\n'.join(sql), args).fetchall() + + +def search_notes(con, expr, limit, doc=None): + sql = ["SELECT n.relpath, n.doc_ref, bm25(notes_fts) AS score,", + " snippet(notes_fts, 0, '[', ']', ' ... ', 14) AS snip", + "FROM notes_fts", + "JOIN notes n ON n.rowid = notes_fts.rowid", + "WHERE notes_fts MATCH ?"] + args: list = [expr] + if doc: + sql.append('AND (IFNULL(n.doc_ref, "") LIKE ? OR n.relpath LIKE ?)') + args += [doc + '%', '%' + doc + '%'] + sql.append('ORDER BY score LIMIT ?') + args.append(limit) + return con.execute('\n'.join(sql), args).fetchall() + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- +def cmd_build(args, out=print) -> int: + roots = [os.path.abspath(r) for r in (args.root or DEFAULT_ROOTS)] + con = connect(args.db, create=True) + try: + if args.rebuild: + drop_all(con) + out('--rebuild: dropped every table, starting empty') + ensure_schema(con, out=out) + rep = build(con, roots, out=out) + finally: + con.close() + + out('roots: ' + ', '.join(relpath(r) for r in roots)) + out('docs: {0} new, {1} unchanged, {2} moved, {3} removed'.format( + rep.docs_new, rep.docs_known, rep.docs_moved, rep.docs_removed)) + out('pages: +{0}, -{1}'.format(rep.pages_added, rep.pages_removed)) + out('notes: {0} new, {1} changed, {2} unchanged, {3} removed'.format( + rep.notes_new, rep.notes_changed, rep.notes_known, rep.notes_removed)) + for rel, first in rep.duplicates: + out('duplicate content, not indexed twice: {0} == {1}'.format( + rel, first)) + for rel, why in rep.unreadable: + out('COULD NOT READ (not indexed, will be retried next build): ' + '{0} -- {1}'.format(rel, why)) + out('built in {0:.2f}s'.format(rep.seconds)) + return 0 + + +def _open_for_reading(db: str, out=print): + if not os.path.exists(db): + out('no index built yet -- run: python tools/libindex.py build') + return None + con = connect(db) + if _schema_version(con) != SCHEMA_VERSION: + con.close() + out('index is from another schema version -- run: ' + 'python tools/libindex.py build') + return None + return con + + +def cmd_search(args, out=print) -> int: + con = _open_for_reading(args.db, out=out) + if con is None: + return 1 + try: + try: + expr = match_expr(args.query, raw=args.raw) + except QueryError as exc: + out('bad query -- {0}'.format(exc)) + return 1 + try: + hits = [] + if args.kind in ('all', 'pages'): + hits += [('page', r) + for r in search_pages(con, expr, args.limit, args.doc)] + if args.kind in ('all', 'notes'): + hits += [('note', r) + for r in search_notes(con, expr, args.limit, args.doc)] + except sqlite3.OperationalError as exc: + # A malformed FTS5 expression is "could not judge", not "nothing + # matched", so it must never print 0 hits. + out('could not run that query -- {0}'.format(exc)) + out('(the query was handed to FTS5 as: {0})'.format(expr)) + return 1 + + hits.sort(key=lambda h: h[1]['score']) + hits = hits[:args.limit] + + out('query: {0}'.format(expr)) + if not hits: + out('0 hits') + return 0 + out('{0} hit(s), best first (bm25: lower is better):'.format(len(hits))) + for kind, row in hits: + if kind == 'page': + where = '{0} p.{1}'.format(row['relpath'], row['page_no']) + tag = row['hash'][:8] + else: + where = row['relpath'] + tag = row['doc_ref'] or 'note' + out('') + out(' [{0}] {1} bm25 {2:.2f} {3}'.format( + kind, where, row['score'], tag)) + out(' {0}'.format(flatten(row['snip'], 400))) + return 0 + finally: + con.close() + + +def cmd_status(args, out=print) -> int: + con = _open_for_reading(args.db, out=out) + if con is None: + return 1 + def one(sql): + return con.execute(sql).fetchone()[0] + + docs = one('SELECT COUNT(*) FROM docs') + pages = one('SELECT COUNT(*) FROM pages') + empty = one("SELECT COUNT(*) FROM pages WHERE TRIM(text) = ''") + notes = one('SELECT COUNT(*) FROM notes') + blank_docs = one( + "SELECT COUNT(*) FROM (SELECT doc_hash FROM pages GROUP BY doc_hash " + "HAVING SUM(CASE WHEN TRIM(text) = '' THEN 0 ELSE 1 END) = 0)") + out('index: {0}'.format(relpath(args.db))) + out('docs: {0}'.format(docs)) + out('pages: {0} ({1} with an empty text layer'.format(pages, empty) + + (', {0} document(s) empty throughout -- scanned, ' + 'their words are in the notes only)'.format(blank_docs) + if blank_docs else ')')) + out('notes: {0}'.format(notes)) + roots_meta = get_meta(con, 'last_build_roots') or '' + root_rels = [r for r in roots_meta.split(' | ') if r] + out('roots: {0}'.format(roots_meta or '(none)')) + + # `last_build_roots` is only the ROOTS NAMED ON THE MOST RECENT BUILD -- + # not a history of everywhere the index has ever been told to look. A + # doc or note ingested by an earlier build with a different --root is + # never removed by a later build that does not name that root (`build`'s + # own docstring: "must not delete the rows of a folder it was not asked + # to look at"), so it stays in the database forever while this line goes + # on describing only the last build's roots. That is `status` UNDER- + # REPORTING what the index actually holds -- the same silence-as-success + # shape as a search returning 0 hits for a reason other than "not + # found" -- so state it explicitly rather than let the line above imply + # the database contains nothing else. + all_rels = [r['relpath'] for r in con.execute( + 'SELECT relpath FROM docs UNION SELECT relpath FROM notes')] + outside = sorted(r for r in all_rels if not _under_any(r, root_rels)) + if outside: + out(' ** {0} of those item(s) live OUTSIDE the roots above --' + .format(len(outside))) + out(' ** from an earlier build with a different --root; still ' + 'resident because no later') + out(' ** build has scanned (and could therefore remove) them. ' + 'The database holds more') + out(' ** than the "roots" line says. Examples:') + for r in outside[:8]: + out(' {0}'.format(r)) + if len(outside) > 8: + out(' ... and {0} more'.format(len(outside) - 8)) + out('built: {0} in {1}s'.format(get_meta(con, 'last_build') or '(never)', + get_meta(con, 'last_build_seconds') + or '?')) + con.close() + return 0 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def make_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog='libindex', + description='Page-granular full-text index over the knowledge ' + 'library (SQLite + FTS5, lexical only).') + sub = p.add_subparsers(dest='cmd') + + b = sub.add_parser('build', help='ingest / re-ingest; safe to re-run') + b.add_argument('--root', action='append', + help='folder to scan; repeatable. Default: ' + 'knowledge/library and knowledge/MC') + b.add_argument('--db', default=DEFAULT_DB) + b.add_argument('--rebuild', action='store_true', + help='throw the index away and start from empty') + + s = sub.add_parser('search', help='find pages and notes') + s.add_argument('query') + s.add_argument('--limit', type=int, default=10) + s.add_argument('--doc', help='hash prefix, ledger id, or path substring') + s.add_argument('--kind', choices=('all', 'pages', 'notes'), default='all') + s.add_argument('--raw', action='store_true', + help='pass the query to FTS5 verbatim (OR / NEAR / *)') + s.add_argument('--db', default=DEFAULT_DB) + + t = sub.add_parser('status', help='what the index currently holds') + t.add_argument('--db', default=DEFAULT_DB) + return p + + +def main(argv=None, out=print) -> int: + use_utf8_stdout() + parser = make_parser() + args = parser.parse_args(argv) + if not args.cmd: + parser.print_help() + return 1 + if not fts5_available(): + out(NO_FTS5) + return 1 + return {'build': cmd_build, + 'search': cmd_search, + 'status': cmd_status}[args.cmd](args, out=out) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/library.py b/tools/library.py new file mode 100644 index 0000000..079ee04 --- /dev/null +++ b/tools/library.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""List the PDF library against its ledger. + + python tools/library.py + +Stage 1 of `knowledge/library/RUNBOOK.md`, and nothing more. It answers three +questions and refuses to grow a fourth: what is in the drop folder that the +ledger has never seen, what has changed under a name the ledger already knows, +and how many documents are sitting in each status. + +The identity is the CONTENT HASH, not the filename. A file that moved is the +same document; a second copy of a hash already listed is a no-op, which is what +makes a re-run cheap -- and it has already saved one 929-page manual from being +extracted twice. + +This prints filenames from a gitignored folder, so its OUTPUT can carry a +producer's name even though this file never does. Do not paste a line of it +into a tracked file; `CLAUDE.md` section 3 has the placeholders. + +Host CPython. The pipeline is discipline, not machinery: if this ever needs a +second screen of code, delete the addition and follow the runbook by hand. +""" +from __future__ import annotations + +import hashlib +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LIBRARY = os.path.join(ROOT, 'knowledge', 'library') +LEDGER = os.path.join(LIBRARY, 'LEDGER.md') + +# A ledger index row: | id | `sha256` | `file` | kind | pages | status | +_ROW = re.compile( + r'^\|[^|]*\|\s*`([0-9a-f]{64})`\s*\|\s*`([^`]+)`\s*\|' + r'([^|]*)\|([^|]*)\|([^|]*)\|') + + +def sha256(path: str) -> str: + """Streamed, because the manual is 122 MB and a read() of it is not free.""" + h = hashlib.sha256() + with open(path, 'rb') as f: + for block in iter(lambda: f.read(1 << 20), b''): + h.update(block) + return h.hexdigest() + + +def read_ledger(): + """Index rows as (hash, filename, status). Missing file is not an error.""" + if not os.path.exists(LEDGER): + return [] + rows = [] + with open(LEDGER, encoding='utf-8') as f: + for line in f: + m = _ROW.match(line.strip()) + if m: + rows.append((m.group(1), m.group(2).strip(), + m.group(5).strip().strip('`'))) + return rows + + +def dropped_pdfs(): + """Every PDF actually sitting in the drop folder, repo-relative.""" + found = [] + for base, dirs, files in os.walk(LIBRARY): + dirs[:] = [d for d in dirs if not d.startswith('.')] + for name in sorted(files): + if name.lower().endswith('.pdf'): + found.append(os.path.join(base, name)) + return found + + +def main() -> int: + if not os.path.isdir(LIBRARY): + print('no library folder at knowledge/library/ -- nothing to list') + return 0 + + rows = read_ledger() + by_hash = {h: (name, status) for h, name, status in rows} + by_name = {} + for h, name, _ in rows: + by_name.setdefault(os.path.basename(name), set()).add(h) + + print('ledger: {0} row(s), {1} distinct document(s)' + .format(len(rows), len(by_hash))) + + new, changed, known = [], [], 0 + for path in dropped_pdfs(): + rel = os.path.relpath(path, ROOT).replace('\\', '/') + digest = sha256(path) + if digest in by_hash: + known += 1 + elif os.path.basename(path) in by_name: + changed.append((rel, digest)) + else: + new.append((rel, digest)) + + print('drop folder: {0} PDF(s) -- {1} already in the ledger' + .format(known + len(new) + len(changed), known)) + + if new: + print('\nNEW -- inventory these (runbook stage 1):') + for rel, digest in new: + print(' {0} {1}'.format(digest[:16], rel)) + if changed: + print('\nHASH CHANGED -- a known filename holding new content. This is ' + 'a NEW document: reset its row to `inventoried` and re-extract.') + for rel, digest in changed: + print(' {0} {1}'.format(digest[:16], rel)) + + elsewhere = [n for h, n, _ in rows + if not os.path.exists(os.path.join(LIBRARY, + os.path.basename(n)))] + if elsewhere: + print('\nrecorded at another location (not in the drop folder), {0}: ' + '{1}'.format(len(elsewhere), ', '.join(sorted(set(elsewhere))))) + + counts = {} + for _, _, status in rows: + counts[status] = counts.get(status, 0) + 1 + if counts: + print('\nstatus: ' + ' | '.join( + '{0} {1}'.format(k, v) for k, v in sorted(counts.items()))) + debt = counts.get('extracted', 0) + if debt: + print('\n{0} document(s) EXTRACTED AND NOT LANDED. That is the debt the ' + 'library exists to drain, not a stage on the way to done -- ' + 'runbook stage 3.'.format(debt)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/make_dist.py b/tools/make_dist.py new file mode 100644 index 0000000..30d0a8c --- /dev/null +++ b/tools/make_dist.py @@ -0,0 +1,1091 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Build a clean, knowledge-free distribution tree for GitHub. + + python tools/make_dist.py OUT_DIR + python tools/make_dist.py OUT_DIR --force # overwrite OUT_DIR + python tools/make_dist.py --scan-only OUT_DIR # re-run the scrub gate + # over an existing tree + +THE COMMISSION, owner, 2026-09-02 (verbatim): "我们继续更新github,但是得是干净 +的版本,不包含任何知识包括公共知识,尽量修复可能出现的安装问题,等等。我要准备 +分发给我同事试用了。我也可以跟他们那里收集反馈。" -- a clean distribution, ZERO +knowledge (including the public catalogue), for colleagues to trial, with +installation problems fixed where found. NEVER PUSHES; see the bottom of this +file for the exact commands the owner runs after reviewing what this builds. + +WHAT "CLEAN" MEANS HERE, PRECISELY +----------------------------------- +1. Zero PROPRIETARY knowledge. Not `knowledge/catalogue-rationale.md` + (which has shipped in this repository's own history), and not the two + real capacity tables inside `knowledge/catalogue.yaml` -- + `catalogue.inserts` (ALP lifting-insert ratings) and + `connectors.products` (sandwich-wall tie ratings). Those never leave the + private checkout. + + `knowledge/catalogue.yaml` ITSELF now ships, but not verbatim: see + "THE CATALOGUE CARVE-OUT" below for exactly what of it does and does + not, and 2026-09 commission notes (verbatim, owner): "你先做B,把B确认 + 做通了以后,再抽掉这些知识... 缺了知识没法跑是对的,但是还是得确认有了 + 知识就能跑这件事儿" -- prove the tool runs WITH knowledge before + shipping a build that has none, so every red in a knowledge-less + distribution is attributable to knowledge alone, never tangled with a + real bug. `knowledge/catalogue.yaml.template` still ships too, as a + blank-value authoring template for a colleague building their OWN + knowledge layers from scratch -- it is not what this carve-out is built + from. + +THE CATALOGUE CARVE-OUT +------------------------ +`build_catalogue_carveout()` reads THIS repo's real `knowledge/catalogue.yaml` +and re-emits four branches VERBATIM (parsed and re-dumped, not hand-typed): +`face_designations` (trade abbreviations printed on every shop ticket +everywhere -- the real file's own header already calls this branch public), +`orientation` (a fact about precast assemblies in general, the owner's own +ruling for why it lives here and not in a plant file), `catalogue.rebar` +(ASTM A706/A615 grade tables -- a published national standard, not a +manufacturer's rating) and `catalogue.safety_factors` (two nulls and a +reason). `checklist.master` -- the shop-ticket QC checklist's row ids, +names, grades and which check function answers each one -- ships in full: +it is this TOOL's own built-in configuration, not knowledge about any job. + +`catalogue.inserts` and `connectors.products` ship EMPTY. A handful of +tests read those two tables directly and assert the owner's real published +numbers (a specific insert's factor, a specific tie's edge distance) -- +knowledge-content tests, not tests of this tool's logic. See +`append_test_exclusions()` below for how the dist's own test trees skip +them, by name, with the reason, rather than being faked with invented +numbers that would just be the same real knowledge through a back door. +2. Fresh history. This script does not touch this repo's git history at all + -- it assembles a plain directory tree and commits ONE orphan commit + inside a brand new repo at OUT_DIR. The 19 versions of the catalogue this + repo's own history carries never reach GitHub this way. +3. An INCLUDE list, not an exclude list. Every file that reaches OUT_DIR is + named below, one at a time, read through `git ls-files` scoped to its own + directory -- never a raw directory walk. That means a stray untracked + file (a scratch script with a real path pasted into it, the exact thing + .gitignore's own `_scratch/` comment describes) cannot reach the dist by + accident the way it could reach a plain `git add -A`: if `git ls-files` + does not name it, this script never opens it. + +WHY EACH TOOLS/ AND DOCS/ FILE IS IN OR OUT: TOOLS_MANIFEST / DOCS_MANIFEST +below, one reason per file, not just "curated". Read them before assuming a +tool's absence is an oversight. + +THE SCRUB GATE +--------------- +After assembly, `scrub_tree()` runs the SAME regex machinery +`tools/scrub.py` uses -- imported from it, not reimplemented -- over EVERY +file in OUT_DIR, using the REAL `.scrub-terms` list read from THIS repo ( +never copied into the dist) plus two more structural patterns this script +adds: the private repo's own host, and the owner's machine paths. A hit +anywhere aborts the build before the git commit is made, no exceptions -- +not even in a doc, not even in a comment. See `EXTRA_PATTERNS` below. + +WHAT THIS SCRIPT DOES NOT DO +------------------------------ +Push, or touch this repository's own git state at all. It also does not +decide whether the GitHub repo should be public or stay private-with- +collaborators -- that is the owner's call, not encoded here. +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import re +import shutil +import subprocess +import sys + +import yaml # PyYAML -- already a requirements.txt dependency of this suite + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TOOLS_DIR = os.path.join(ROOT, 'tools') +sys.path.insert(0, TOOLS_DIR) +import scrub as scrubmod # noqa: E402 -- the real term list stays in ROOT/.scrub-terms + + +# ============================================================== tools/ manifest +# +# (filename, include, reason). `include=False` entries are still listed, so +# the decision and its reason are read together rather than left to be +# reconstructed from a diff. +TOOLS_MANIFEST = [ + ('aiextract_run.py', True, + 'host half of Design Extract\'s vision escalation pass; reads its own ' + 'model key from the environment and fails with a clear message with ' + 'none set -- no owner-specific path or credential baked in'), + ('deckcheck.py', True, + 'generic frozen-deck-vs-current-knowledge checker; useful once a ' + 'colleague has real knowledge layers of their own'), + ('dsh-here.cmd', False, + "the script's own header says it: \"Nothing references this file any " + 'more; it is kept for the owner to bin.\" Dead, and named after ' + 'owner-only tooling (DeepSeek Harness) besides'), + ('knowledge_drift.py', True, + 'generic declared-vs-read knowledge-key checker; no owner-specific ' + 'paths, degrades cleanly with no plant/job layers'), + ('knowledge_drift_allow.txt', True, + 'suppression list paired with knowledge_drift.py; every entry is a ' + 'dotted key name and a reason, no client data'), + ('knowledgelint.py', True, + 'REQUIRED reading after any knowledge-layer edit; fixed in this pass ' + 'to report a knowledge-less checkout as SKIP rather than FAIL (see ' + 'tools/knowledgelint.py\'s own diff)'), + ('layersplit.py', False, + 'splits long rationale comments out of a knowledge layer the OWNER ' + 'authors, into catalogue-rationale.md-shaped files; a trial install ' + 'with no real knowledge layers has nothing to run this on'), + ('libindex.py', False, + "indexes knowledge/library/, the owner's private PDF drop folder, " + 'which never ships -- named explicitly in the commission as an ' + 'example exclusion'), + ('library.py', False, + 'lists knowledge/library/ against its ledger -- same folder as ' + 'libindex.py above, same reason, also named explicitly'), + ('make_icons.py', True, + 'generic ribbon-icon generator; optional Pillow dependency is its own ' + 'business, nothing else needs it'), + ('package.py', False, + 'superseded by tools/make_dist.py for anything leaving this machine, ' + 'and unconditionally zips the WHOLE knowledge/ tree (its own ' + '`INCLUDE` list names it) -- exactly what must never leave the ' + "owner's machine as real data. Stays in the owner's own working repo, " + 'not in a trial dist'), + ('panelanalysis.py', True, + 'generic Eriksson-Wall-shaped demo report over a job.json deck, no ' + 'owner-specific data; a good second thing to try after the example ' + 'deck'), + ('qna_watch.py', False, + 'watches QNA/, which is excluded from this distribution wholesale ' + '(see .gitignore\'s own comment on it) -- nothing for this to watch'), + ('reinforce_offline.py', True, + 'generic reinforcing-suggestion CLI reading a --index the colleague ' + 'builds themselves; no owner data baked in'), + ('rvt.py', True, "the live-Revit bridge client -- generic, and it's the " + 'whole point of the GOD_MOD button'), + ('scrub.py', True, + 'ships despite being infrastructure-flavored: CI ' + '(.github/workflows/ci.yml), the pre-commit hook, and CONTRIBUTING.md ' + 'all call it, and every code path already degrades cleanly with no ' + 'real .scrub-terms or CLAUDE.md present (empty term list -> ' + 'structural-patterns-only scan, missing CLAUDE.md -> no placeholder ' + 'exemption, both by design, see the module docstring and ' + 'load_placeholders()). What does NOT ship is the real .scrub-terms ' + 'list itself -- see EXCLUDE_ALWAYS below.'), + ('setup_machine.py', True, + 'updated in this pass to make a knowledge-less install a first-class, ' + 'PASS-shaped state (--no-knowledge / auto-detect) -- the doctor a ' + 'colleague runs first'), + ('snapshot_cycle.py', True, 'generic Revit session cycling, no owner paths'), + ('snapshot_defer.py', True, 'host half of snapshot_cycle.py, same reason'), + ('sweep.py', True, 'stays -- named explicitly in the commission'), + ('sweep_in_revit.py', True, 'stays -- paired with sweep.py above'), + ('ticket_delta.py', True, + 'generic PDF ticket-revision diff tool (pymupdf, already a dependency)'), + ('vision_read.py', True, + "Ticket QC's vision-escalation CPython half; reads ANTHROPIC_API_KEY " + 'from the environment and fails cleanly with none set'), + ('make_dist.py', False, 'this script -- builds a dist, is not one'), +] + +# ================================================================ docs/ manifest +DOCS_MANIFEST = [ + ('architecture-migration.md', True, + 'clean architecture/design rationale, no client or job data'), + ('checklist-code-map.md', True, + 'mechanical id-to-code reference for the ticket checklist, no client data'), + ('handoff.md', False, + 'written "for an agent picking this repo up cold" -- points at ' + 'CLAUDE.md (excluded) and carries open engineering questions tied to ' + 'real sweep findings, not colleague documentation'), + ('interference.md', True, 'clean technical explanation of the clash check'), + ('knowledge-override.md', True, + 'generic layering-precedence rule, useful once a colleague writes ' + 'their own plant/job layers'), + ('library-index.md', False, + 'documents tools/libindex.py, which is excluded from this distribution'), + ('lift-check.md', True, 'user-facing explanation of Panel Lift Check'), + ('panellift-ewf-alignment.md', True, + 'clean mapping doc; references a private sample-data folder a ' + 'colleague will not have, but names no client or job'), + ('plan-panellift-three-stage.md', True, + 'design doc, explicitly named in the commission as clean and worth ' + 'shipping'), + ('plan-strands-auto-layout.md', True, 'same as above'), + ('plan-ticketqc-refactor.md', True, 'same as above'), + ('revit-api-cheatsheet.md', True, 'generic Revit 2025 API patterns'), + ('revit-bridge-context.md', True, 'generic explanation of the rvt.py bridge'), + ('snapshot-cycle.md', True, 'generic explanation of the snapshot tool'), + ('std-clash-all.md', False, + "a raw findings table from a real job's clash run, not reference " + 'material -- job data, not documentation'), + ('std-clash-judgement.md', False, 'same reason as std-clash-all.md'), + ('std-nonclash.md', False, 'same reason as std-clash-all.md'), + ('takeoff.md', True, 'user-facing explanation of the Takeoff button'), + ('ticket-audit-wp1-12.md', False, + 'an internal audit trail over twelve real tickets, mid-revision; the ' + 'durable lessons from it already live in ticket-checklist.md and ' + 'ticket-knowledge-deltas.md below'), + ('ticket-check.skill.md', True, 'user-facing skill doc for Ticket QC'), + ('ticket-checklist.md', True, + 'durable, generic checklist reference; piece marks it cites (WP194 ' + 'etc.) are design-number-shaped naming, not client information, per ' + "CLAUDE.md's own placeholder table"), + ('ticket-knowledge-deltas.md', True, + 'generic lessons on what belongs at which knowledge layer'), + ('ticketqc-vision.skill.md', True, 'user-facing skill doc for the vision escalation'), + ('todo-units.md', True, 'clean roadmap note, no client or job data'), +] + +# ============================================================ top-level files +# Copied verbatim from the working tree. README.md and INSTALL.md are the +# versions already rewritten in this repo's own working tree for this +# audience -- see the commission's part C. +TOP_LEVEL_FILES = [ + 'requirements.txt', + 'INSTALL.md', + 'README.md', + 'CONTRIBUTING.md', + '.setup-opts.example', +] + +# Whole directories, tracked files only (via `git ls-files`), no curation. +WHOLE_DIRS = ['WillTools.extension', 'panellift'] + +# Files inside a whole-copied directory that still do not ship, because the +# TOOL they test does not. Paired 1:1 with a False row in TOOLS_MANIFEST. +TEST_EXCLUDES = { + os.path.join('WillTools.extension', 'tests', 'test_libindex.py'): + 'tests tools/libindex.py, which is excluded (see TOOLS_MANIFEST)', +} + +# Never copied, whatever else changes -- named here once so a reviewer can +# see the whole "never" list in one place rather than infer it from what is +# absent. +EXCLUDE_ALWAYS_NOTE = """\ +Deliberately never touched by this script (no rule below CAN reach them, +because copying is include-list-driven off `git ls-files`, never a directory +walk): + knowledge/ except README.md, catalogue.yaml.template, and a + catalogue.yaml written FRESH below by + build_catalogue_carveout() -- never copied from the + real file. catalogue-rationale.md, and everything + gitignored under it (plant/, job/, library/, MC/, + _archive/, building code/) all stay out. The carve-out + omits catalogue.inserts and connectors.products (real + manufacturer capacities) even though it reads every + other branch of the real file -- see "THE CATALOGUE + CARVE-OUT" above. + QNA/ agent task inbox, gitignored except its own README.md. + CLAUDE.md the owner's own agent operating manual -- Chinese + commission quotes, real measured incidents, not + colleague documentation. + PROJECT.md owner's own project log. + AGENTS.md owner's own agent roster notes. + .scrub-terms the real client/job denylist -- .scrub-terms.example + ships in its place. + .setup-opts this machine's own declared install choices. + sample data/, *.pdf, *.log, _scratch/, .git/, .pytest_cache/ +""" + + +# ================================================================ scrub gate +# +# The private repo's own host and the owner's machine paths, added on top of +# tools/scrub.py's own STRUCTURAL list (which already flags any +# `C:\\Users\\` path generically -- this adds the two more specific +# strings named in the commission). +EXTRA_PATTERNS = [ + (re.compile(re.escape('git.will123song.xyz'), re.I), 'private repo host'), + (re.compile(r'\bV:\\', re.I), "owner's project drive path"), +] + + +def scrub_tree(dist_dir): + """Every hit tools/scrub.py's own machinery would flag, plus + EXTRA_PATTERNS, scanned over EVERY file under `dist_dir`. Returns a list + of hit strings; empty means clean. + + Reuses `scrub.load_terms`, `scrub.load_placeholders`, `scrub.patterns` + and `scrub.FILENAME_CATEGORIES` / `scrub._is_placeholder_filename` + directly rather than reimplementing the regex or the placeholder + exemption -- one definition of "a hit", used by the pre-commit hook, CI, + and this build gate alike. + """ + terms = scrubmod.load_terms(os.path.join(ROOT, '.scrub-terms')) + placeholders = scrubmod.load_placeholders(os.path.join(ROOT, 'CLAUDE.md')) + pats = scrubmod.patterns(terms) + EXTRA_PATTERNS + + hits = [] + for dirpath, dirnames, filenames in os.walk(dist_dir): + dirnames[:] = [d for d in dirnames + if d not in ('.git', '__pycache__', '.pytest_cache')] + for fn in sorted(filenames): + path = os.path.join(dirpath, fn) + rel = os.path.relpath(path, dist_dir) + try: + with open(path, encoding='utf-8', errors='replace') as fh: + lines = fh.readlines() + except OSError: + continue + for n, line in enumerate(lines, 1): + for pat, why in pats: + m = pat.search(line) + if not m: + continue + if (why in scrubmod.FILENAME_CATEGORIES and + scrubmod._is_placeholder_filename(m.group(0), placeholders)): + continue + hits.append('{0}:{1}: {2} -- {3}'.format( + rel, n, why, m.group(0)[:80])) + return hits + + +# ============================================================= assembly helpers + +def git(*args): + return subprocess.run(['git'] + list(args), cwd=ROOT, check=False, + capture_output=True, text=True, errors='replace') + + +def tracked_under(rel_dir): + """Tracked files under `rel_dir`, forward-slash relative paths, via + `git ls-files` -- never a raw walk. See the module docstring for why.""" + out = git('ls-files', '--', rel_dir).stdout + return [line for line in out.splitlines() if line] + + +def _copy(rel_src, dest_root): + src = os.path.join(ROOT, rel_src) + dst = os.path.join(dest_root, rel_src) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + + +def copy_top_level(dest_root): + for name in TOP_LEVEL_FILES: + src = os.path.join(ROOT, name) + if not os.path.isfile(src): + raise SystemExit('make_dist: expected top-level file missing: {0} ' + '(part C of the commission writes this)'.format(name)) + _copy(name, dest_root) + + +def copy_whole_dirs(dest_root): + for rel_dir in WHOLE_DIRS: + for rel in tracked_under(rel_dir): + if rel.replace('/', os.sep) in TEST_EXCLUDES: + continue + _copy(rel.replace('/', os.sep), dest_root) + + +def copy_curated(dest_root, subdir, manifest): + tracked = set(tracked_under(subdir)) + named = set(name for name, _, _ in manifest) + on_disk = set(os.path.basename(p) for p in tracked) + missing_from_manifest = on_disk - named + if missing_from_manifest: + raise SystemExit( + 'make_dist: {0}/ has file(s) with no manifest entry, refusing ' + 'to guess whether they ship: {1}'.format( + subdir, ', '.join(sorted(missing_from_manifest)))) + for name, include, _reason in manifest: + if not include: + continue + rel = '{0}/{1}'.format(subdir, name) + if rel not in tracked: + raise SystemExit( + 'make_dist: manifest names {0} but it is not a tracked file ' + 'under {1}/ -- manifest is stale'.format(name, subdir)) + _copy(rel.replace('/', os.sep), dest_root) + + +def write(dest_root, rel_path, content): + dst = os.path.join(dest_root, rel_path) + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(dst, 'w', encoding='utf-8', newline='\n') as fh: + fh.write(content) + + +def copy_knowledge_stub(dest_root): + for name in ('README.md', 'catalogue.yaml.template'): + src = os.path.join(ROOT, 'knowledge', name) + if not os.path.isfile(src): + raise SystemExit('make_dist: knowledge/{0} missing from source ' + 'repo -- write it before building'.format(name)) + _copy(os.path.join('knowledge', name), dest_root) + write(dest_root, os.path.join('knowledge', 'catalogue.yaml'), + build_catalogue_carveout()) + + +# ======================================================= the catalogue carve-out +# +# THE COMMISSION, owner, 2026-09-02 (verbatim): "你先做B,把B确认做通了以后, +# 再抽掉这些知识... 缺了知识没法跑是对的,但是还是得确认有了知识就能跑这件事 +# 儿" -- prove the tool runs WITH knowledge, in full, before shipping a build +# that has none. Measured that day on this machine: stripping +# `knowledge/catalogue.yaml` entirely (the previous behaviour of this +# script) turned 171 tests red across both suites, all of them tests +# asserting SOME shape of that one file's content -- grouped, on inspection, +# into two very different kinds: +# +# (i) TOOL LOGIC tests that merely need *a* catalogue.yaml to exist with +# the right STRUCTURE -- 165 of the 171. Most fail on a bare +# FileNotFoundError, or on a checklist-row KeyError once the file +# opens but `checklist.master` is empty. None of these 165 assert a +# specific manufacturer number; they assert that the MECHANISM (face +# designation resolution, the up-end rule, a checklist row existing +# and reaching the rendered table) works given SOME catalogue. +# (ii) KNOWLEDGE CONTENT tests -- 6 of the 171 -- that read +# `catalogue.inserts` / `connectors.products` directly and assert +# the OWNER'S real published numbers: a specific insert's 4:1 +# safety factor cited to "ALP catalog p53, 02/2024", a specific +# tie's 3" edge distance. These are tests of the KNOWLEDGE, not of +# this tool, and satisfying them here would mean shipping that real +# data through a back door -- exactly what the commission says not +# to do. See `append_test_exclusions()` below for how they are +# excluded, honestly and by name, rather than faked. +# +# This function builds the fix for (i): a `knowledge/catalogue.yaml` that +# carries every (i)-relevant branch of the REAL file -- re-read and +# re-dumped from it, never hand-typed, so it cannot drift out of step with +# what the real file actually publishes -- while `catalogue.inserts` and +# `connectors.products`, the two branches (ii) depends on, ship EMPTY. +CARVEOUT_HEADER = """\ +# ============================================================================ +# knowledge/catalogue.yaml -- DISTRIBUTION CARVE-OUT, not the real file +# ============================================================================ +# Written by tools/make_dist.py's build_catalogue_carveout(), which reads +# the owner's real knowledge/catalogue.yaml and re-emits ONLY the branches +# below -- parsed and re-dumped from the real file, never hand-typed, so +# this cannot drift out of step with what the real file actually publishes. +# The real file itself never leaves the private checkout. See +# knowledge/README.md and CONTRIBUTING.md for the full accounting. +# +# SHIPPED, AND WHY EACH PIECE IS SAFE TO: +# +# face_designations trade abbreviations (N.F., F.F., T.I.F., ...) +# printed on shop tickets everywhere. The real file's +# own header already calls this branch public -- +# identifies no client, job, or plant. +# orientation.up_end a fact about precast assemblies IN GENERAL (which +# Revit axis is "up" once an assembly's origin is +# correctly configured), not one plant's naming +# convention -- the owner's own ruling for keeping it +# in the public layer rather than a plant file. +# catalogue.rebar ASTM A706 / A615 grade tables -- a published +# national standard, not a manufacturer's rating. +# catalogue.safety_factors both null, by design -- see the note inline. +# checklist.master the shop-ticket QC checklist's SKELETON: row ids, +# names, grades, and which check function answers +# each one. This tool's own built-in configuration, +# not knowledge about any job -- identifies nobody. +# +# NOT SHIPPED: +# +# catalogue.inserts real lifting-insert capacities (ALP catalog, +# 02/2024), cited to a page and a date. EMPTY here. +# catalogue.slotted_inserts real JVI slotted-insert capacities. Omitted +# entirely (nothing in this distribution's +# tests needs its structure present at all). +# connectors.products real sandwich-wall tie capacities. EMPTY here. +# +# A handful of tests read those omitted tables directly and assert the +# owner's real published numbers. They are excluded from this +# distribution's test run, by name, with that reason -- see +# tools/make_dist.py's append_test_exclusions() and CONTRIBUTING.md. +# ============================================================================ + +""" + +# The knowledge-content tests in WillTools.extension/tests/test_knowledge.py +# -- every one reads `catalogue.inserts` (via a real-file helper, several +# layers of indirection deep in a couple of cases) and pins a real ALP or HK +# Composites number. See the module docstring's "(ii)" paragraph above. This +# list is a SNAPSHOT, not a closed set -- test_knowledge.py is under active +# development on other branches of this same repo, and a new test asserting +# a real published number lands here the same way +# test_qc589_plant_override_beats_the_public_catalogue did on 2026-09-02 +# (its own docstring: "this is not a stand-in catalogue, it is the one the +# tool actually ships, and it really does publish QL589SPG at 16,000 lb"). +# If a dist build's test run comes back with an UNEXPECTED failure in +# test_knowledge.py rather than a clean skip, check whether it is a new +# knowledge-content test before assuming the carve-out itself regressed. +EXT_KNOWLEDGE_CONTENT_TESTS = [ + 'test_the_shipped_catalogue_file_agrees_with_the_defaults', + 'test_the_factor_resolves_per_part_and_is_never_borrowed', + 'test_the_spacing_minimum_is_derived_from_the_part_s_own_edge_distance', + 'test_a_published_spacing_overrides_the_multiple', + 'test_a_part_with_no_edge_distance_to_double_stands_down_naming_it', + 'test_st100_carries_the_owners_number_and_st50_still_does_not', + 'test_qc589_plant_override_beats_the_public_catalogue', +] + +# panellift/tests/test_safety_factors.py's own module docstring already +# calls this "a golden-value check against a page, not a recomputation" and +# already self-skips when knowledge/catalogue.yaml is absent -- but its +# skip condition is file EXISTENCE (`CATALOGUE.is_file()`), and this carve- +# out makes the file exist with `catalogue.inserts` empty. Only the one +# parametrized function that reads real ALP rows needs an added exclusion; +# the other 6 tests in that file pass fine against an empty `inserts`. +PANELLIFT_KNOWLEDGE_CONTENT_TESTS = [ + 'test_the_catalogue_publishes_four_to_one_in_shear_for_every_qlpd_row', +] + + +def _load_real_catalogue(): + src = os.path.join(ROOT, 'knowledge', 'catalogue.yaml') + if not os.path.isfile(src): + raise SystemExit('make_dist: knowledge/catalogue.yaml missing from ' + 'the source repo -- the carve-out has nothing to ' + 'read from') + with open(src, encoding='utf-8') as fh: + data = yaml.safe_load(fh.read()) + return data + + +def build_catalogue_carveout(): + data = _load_real_catalogue() + carveout = { + 'face_designations': data['face_designations'], + 'orientation': data['orientation'], + 'catalogue': { + 'safety_factors': data['catalogue']['safety_factors'], + 'inserts': {}, + 'rebar': data['catalogue']['rebar'], + }, + 'connectors': { + 'what_it_is': data['connectors']['what_it_is'], + 'products': {}, + }, + 'checklist': data['checklist'], + } + # Fail loudly rather than ship a carve-out that quietly stopped covering + # what the (i) tests need -- these three are cheap, structural + # invariants the real file has held since this script was written. + if 'stock_by_size' in carveout['catalogue']['rebar']: + raise SystemExit('make_dist: the real catalogue.rebar now carries ' + 'stock_by_size -- that is plant knowledge and must ' + 'not reach the carve-out; fix build_catalogue_carveout()') + if not set(carveout['face_designations'].get('vocabularies', {})) >= { + 'near_far', 'in_form'}: + raise SystemExit('make_dist: the real face_designations no longer ' + 'publishes near_far/in_form -- carve-out is stale') + if len(carveout['checklist'].get('master') or []) < 30: + raise SystemExit('make_dist: the real checklist.master looks ' + 'truncated ({0} rows) -- refusing to ship a partial ' + 'checklist'.format(len(carveout['checklist'].get('master') or []))) + dumped = yaml.safe_dump(carveout, default_flow_style=False, + allow_unicode=True, sort_keys=False, width=100) + return CARVEOUT_HEADER + dumped + + +# ========================================================= dist-only test exclusions +# +# Appended to the ALREADY-COPIED conftest.py inside dest_root -- never to +# this repo's own tracked WillTools.extension/tests/conftest.py or +# panellift/tests/conftest.py, which other agents may be editing at the +# same time (see this landing's own commission). The block below is plain +# text written straight into the copy in OUT_DIR; the source files are +# never opened for writing. +_EXT_CONFTEST_ADDITION = ''' + +# ============================================================================ +# Appended by tools/make_dist.py -- knowledge-content test exclusion +# ============================================================================ +# See knowledge/catalogue.yaml's own header (build_catalogue_carveout() in +# tools/make_dist.py) for the full accounting. Short version: these {n} +# tests read `catalogue.inserts` and assert the owner's real published +# manufacturer numbers, which this distribution does not ship. Skipped by +# name, not faked with invented numbers standing in for real knowledge. +# +# AUTO-DETECTING: if `catalogue.inserts` is ever non-empty in the +# knowledge/catalogue.yaml this test run actually finds -- e.g. a colleague +# who has since been handed the owner's real knowledge layers -- these run +# normally, unskipped, no edit needed here. +_KNOWLEDGE_CONTENT_TESTS = {test_list!r} + + +def _dist_real_inserts_present(): + import io as _io + import os as _os + here = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))) + path = _os.path.join(_os.path.dirname(here), 'knowledge', 'catalogue.yaml') + try: + import yaml as _yaml + with _io.open(path, encoding='utf-8') as fh: + data = _yaml.safe_load(fh.read()) or {{}} + except Exception: + return False + return bool(((data.get('catalogue') or {{}}).get('inserts')) or None) + + +def pytest_collection_modifyitems(config, items): + import pytest as _pytest + if _dist_real_inserts_present(): + return + skip = _pytest.mark.skip( + reason='knowledge-content test: asserts the owner\\'s real product ' + 'capacity data, which this distribution does not ship -- ' + 'see knowledge/catalogue.yaml\\'s header and CONTRIBUTING.md') + for item in items: + if item.originalname in _KNOWLEDGE_CONTENT_TESTS or item.name in _KNOWLEDGE_CONTENT_TESTS: + item.add_marker(skip) +''' + +_PANELLIFT_CONFTEST_ADDITION = ''' + +# ============================================================================ +# Appended by tools/make_dist.py -- knowledge-content test exclusion +# ============================================================================ +# See knowledge/catalogue.yaml's own header. test_safety_factors.py already +# self-skips its whole module when knowledge/catalogue.yaml is ABSENT (see +# its own pytestmark) -- but this distribution's carve-out ships that file +# with `catalogue.inserts` EMPTY, so the file-existence check alone no +# longer catches the {n} test(s) below that need real ALP rows in it. +# AUTO-DETECTING the same way: real inserts present, and these run normally. +_KNOWLEDGE_CONTENT_TESTS = {test_list!r} + + +def _dist_real_inserts_present(): + import io as _io + import os as _os + here = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))) + path = _os.path.join(_os.path.dirname(here), 'knowledge', 'catalogue.yaml') + try: + import yaml as _yaml + with _io.open(path, encoding='utf-8') as fh: + data = _yaml.safe_load(fh.read()) or {{}} + except Exception: + return False + return bool(((data.get('catalogue') or {{}}).get('inserts')) or None) + + +def pytest_collection_modifyitems(config, items): + import pytest as _pytest + if _dist_real_inserts_present(): + return + skip = _pytest.mark.skip( + reason='knowledge-content test: asserts the owner\\'s real product ' + 'capacity data, which this distribution does not ship -- ' + 'see knowledge/catalogue.yaml\\'s header and CONTRIBUTING.md') + for item in items: + if item.originalname in _KNOWLEDGE_CONTENT_TESTS or item.name in _KNOWLEDGE_CONTENT_TESTS: + item.add_marker(skip) +''' + + +def append_test_exclusions(dest_root): + """Appends the two blocks above to the COPIES of conftest.py already + sitting in dest_root (written by copy_whole_dirs -- this runs after + it). Refuses if either copy is missing, rather than silently shipping + a dist where the (ii) tests fail loudly instead of skipping cleanly.""" + targets = [ + (os.path.join('WillTools.extension', 'tests', 'conftest.py'), + _EXT_CONFTEST_ADDITION, EXT_KNOWLEDGE_CONTENT_TESTS), + (os.path.join('panellift', 'tests', 'conftest.py'), + _PANELLIFT_CONFTEST_ADDITION, PANELLIFT_KNOWLEDGE_CONTENT_TESTS), + ] + for rel, template, test_list in targets: + path = os.path.join(dest_root, rel) + if not os.path.isfile(path): + raise SystemExit('make_dist: {0} not found in the assembled tree ' + '-- append_test_exclusions() must run after ' + 'copy_whole_dirs()'.format(rel)) + addition = template.format(n=len(test_list), test_list=test_list) + with open(path, 'a', encoding='utf-8', newline='\n') as fh: + fh.write(addition) + + +# ---- examples/ -- the ASPIRE fixture, not a real job ------------------------ +# +# The published ASPIRE Winter 2019 worked example, the SAME dict +# tools/setup_machine.py's own verify_panellift() uses to prove the +# installed CLI reaches real arithmetic -- built from a public journal +# article, not from any real job. A colleague's first success should be +# `python -m panellift examples/example-panel.job.json -o report.html` +# with no Revit, no model, no knowledge layer. +EXAMPLE_JOB = { + 'schema': 'panellift/1', + 'panel': { + 'mark': 'ASPIRE-EXAMPLE-1', 'length_ft': 32.0, 'width_ft': 10.0, + 'thickness_in': 8.75, 'unit_weight_pcf': 150.0, + 'fci_psi': 4000.0, 'fc_psi': 5000.0, + 'lifters': [ + {'mark': 'L1', 'family': 'EXAMPLE_LIFTER', 'x': 6.627416997969522, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L2', 'family': 'EXAMPLE_LIFTER', 'x': 6.627416997969522, + 'y': 7.5, 'capacity_lb': 12000.0}, + {'mark': 'L3', 'family': 'EXAMPLE_LIFTER', 'x': 25.372583002030478, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L4', 'family': 'EXAMPLE_LIFTER', 'x': 25.372583002030478, + 'y': 7.5, 'capacity_lb': 12000.0}, + ], + }, + 'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0}, + 'stages': [{'name': 'stripping', 'multiplier': 1.3, 'fc_psi': 4000.0}], +} + +EXAMPLES_README = """\ +# examples/ + +`example-panel.job.json` is a `panellift` job file for a flat panel with four +lifting inserts -- the ASPIRE Winter 2019 worked example (Brown, +"Calculations for Handling and Shipping Precast Concrete Deck Panels"), the +same fixture panellift's own test suite and `tools/setup_machine.py --check` +verify the installed CLI against. It is a published example, not a real job: +nothing about it identifies anyone. + +Try it with no Revit, no model, and no knowledge layer: + + python -m panellift examples/example-panel.job.json -o report.html + +Expect a peak transfer-stage tension near 244 psi, matching the article. If +you see a different number, `panellift`'s own suite (`python -m pytest +panellift`) is the next place to look. +""" + + +def write_examples(dest_root): + write(dest_root, os.path.join('examples', 'example-panel.job.json'), + json.dumps(EXAMPLE_JOB, indent=2) + '\n') + write(dest_root, os.path.join('examples', 'README.md'), EXAMPLES_README) + + +# ---- WillTools-Intro.pptx -- the colleague intro deck, riding with the dist --- +# +# THE COMMISSION, owner, 2026-09-02 (verbatim): "GitHub推了吧。给我PPT也放上 +# 去。" -- push, and put the intro PPT up too. +# +# Lives beside this repo, not inside it (`os.path.dirname(ROOT)`, i.e. the +# same folder pyrevit-dev-git itself sits in) -- a binary with 8 embedded +# screenshots does not belong in git history the way source does, so it is +# never tracked here and this is the ONE place its path is named. Copied +# into the dist tree's ROOT (not `docs/`, which is a curated, +# one-reason-per-file manifest meant for text) so it rides in the SAME +# orphan commit as everything else, where `scrub_tree()` also walks it -- +# harmlessly, since a .pptx is a zip of compressed XML and the raw bytes +# will not contain readable text for the patterns to match. That is NOT the +# real gate for this file: a .pptx's text (slide bodies, SPEAKER NOTES, +# docProps author/company fields) lives inside compressed per-slide XML +# entries, and only a zip-aware scan finds it. That scan is a manual, +# one-time pass run before each build that ships a new copy of the deck -- +# see the commit that landed this comment for what it found and fixed (two +# slides used an ad hoc placeholder model filename, styled like a real one +# but not this repo's own sanctioned `sample_job` form -- deliberately not +# quoted verbatim here, so this comment does not itself read as a Revit +# filename to the structural scrub pattern) -- not a repeatable tool, +# because the deck itself changes rarely and a colleague is not expected to +# rebuild it. +INTRO_DECK_NAME = 'WillTools-Intro.pptx' +INTRO_DECK_SRC = os.path.join(os.path.dirname(ROOT), INTRO_DECK_NAME) + + +def copy_intro_deck(dest_root): + if not os.path.isfile(INTRO_DECK_SRC): + print('make_dist: no {0} found beside the repo at {1} -- skipping ' + '(not required for a build; only added when the owner asks ' + 'for it to ride along)'.format(INTRO_DECK_NAME, INTRO_DECK_SRC)) + return + shutil.copy2(INTRO_DECK_SRC, os.path.join(dest_root, INTRO_DECK_NAME)) + print('copied {0} ({1} bytes) -- scrubbed by hand before this build; ' + 'see tools/make_dist.py\'s copy_intro_deck() comment'.format( + INTRO_DECK_NAME, os.path.getsize(INTRO_DECK_SRC))) + + +# ---- .github/ -- CI workflow (comment corrected) + issue templates --------- + +CI_YML = """\ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install + # requirements.txt is the single source of truth for host-side deps. + # PyYAML fixes collection (ticketpage._read_yaml falls back to it off + # IronPython; without it eight test files die before running). pymupdf + # is required for tools/ticket_delta.py's tests. + run: pip install -r requirements.txt + + - name: panellift tests + run: python -m pytest panellift -q -p no:cacheprovider + + - name: vibe tests (the Revit-free half of the extension) + run: python -m pytest WillTools.extension -q -p no:cacheprovider + + - name: Syntax smoke (extension + tools + panellift) + run: python -m compileall -q WillTools.extension tools panellift/src + + - name: Scrub worktree with the committed generic sample terms + # Real client names never leave the owner's machine: this + # distribution ships no .scrub-terms at all, so CI runs the generic + # .scrub-terms.example instead -- the same fallback a colleague's own + # clone uses locally with no real term list. + run: python tools/scrub.py --worktree --terms .scrub-terms.example +""" + +ISSUE_CONFIG_YML = """\ +blank_issues_enabled: false +contact_links: + - name: Ask the owner directly + url: https://github.com/will123song/pyrevit-dev/discussions + about: Not sure it's a bug, or want to talk through knowledge layers first? +""" + +BUG_REPORT_MD = """\ +--- +name: Bug report +about: Something ran and gave a wrong or crashing answer +title: '' +labels: bug +assignees: '' +--- + + + +## What happened + + + +``` +paste it here +``` + +## What you expected instead + +## Steps to reproduce + +1. +2. +3. + +## Environment + +Run this and paste its ENTIRE output below, unedited -- it is the single +most diagnostic thing you can attach. It is plain text with no color codes, +so it pastes cleanly into this box: + +``` +python tools/setup_machine.py --check +``` + +
              +setup_machine.py --check output + +``` +paste it here +``` + +
              + +- Windows version: +- `python --version`: +- Revit version (if this involves a ribbon button): +- pyRevit version (if this involves a ribbon button): + +## Which button or command + + + +## No client or job data, please + +Screenshots, pasted output, and job files should carry no real client name, +job name, or job number. If a screenshot or a job file shows one, use a +placeholder (`sample_client`, `sample_job`) or ask the owner how to share it +privately instead of pasting it here. +""" + +QUESTION_MD = """\ +--- +name: Question +about: Something was unclear, or you're not sure what a result means +title: '' +labels: question +assignees: '' +--- + +## What you were trying to do + +## What the report or the ribbon said + + + +## What was unclear about it + + +""" + + +def write_github(dest_root): + write(dest_root, os.path.join('.github', 'workflows', 'ci.yml'), CI_YML) + write(dest_root, os.path.join('.github', 'ISSUE_TEMPLATE', 'config.yml'), + ISSUE_CONFIG_YML) + write(dest_root, os.path.join('.github', 'ISSUE_TEMPLATE', 'bug_report.md'), + BUG_REPORT_MD) + write(dest_root, os.path.join('.github', 'ISSUE_TEMPLATE', 'question.md'), + QUESTION_MD) + + +# =================================================================== orphan git + +def git_init_and_commit(dest_root, source_hash): + stamp = datetime.date.today().isoformat() + message = ( + 'WillTools distribution {0}\n\n' + 'Clean colleague-trial snapshot, built by tools/make_dist.py.\n' + 'Source commit (owner repo, not published): {1}\n' + 'No plant/job layers, no catalogue-rationale.md, no product-capacity ' + 'data (catalogue.inserts / connectors.products ship empty). ' + 'knowledge/catalogue.yaml DOES ship a structural carve-out -- see ' + 'its own header and knowledge/README.md.\n' + ).format(stamp, source_hash) + + def run(*args): + r = subprocess.run(['git'] + list(args), cwd=dest_root, check=False, + capture_output=True, text=True, errors='replace') + if r.returncode != 0: + raise SystemExit('make_dist: git {0} failed:\n{1}'.format( + ' '.join(args), r.stderr)) + return r + + run('init', '-q') + run('add', '-A') + run('commit', '-q', '-m', message) + rev = run('rev-parse', 'HEAD').stdout.strip() + return rev, message + + +# ======================================================================= main + +def _clear_readonly_and_retry(func, path, exc_info): + """`shutil.rmtree`'s onerror/onexc hook. A previous build's `.git` + directory carries read-only loose-object files on Windows -- git sets + that mode deliberately, and plain `shutil.rmtree` does not clear it + before unlinking, so `--force` over a tree that already has an orphan + commit in it raises PermissionError instead of overwriting. Clear the + bit and retry once; if that still fails, let it raise for real.""" + os.chmod(path, 0o666) + func(path) + + +def build(dest_root, force=False): + if os.path.exists(dest_root): + if not force: + raise SystemExit('make_dist: {0} already exists -- pass --force ' + 'to overwrite'.format(dest_root)) + try: + shutil.rmtree(dest_root, onexc=lambda f, p, e: _clear_readonly_and_retry(f, p, e)) + except TypeError: + # Python < 3.12: shutil.rmtree has onerror, not onexc, and the + # callback signature drops the exception-info-vs-exception + # difference that does not matter to _clear_readonly_and_retry. + shutil.rmtree(dest_root, onerror=_clear_readonly_and_retry) + os.makedirs(dest_root) + + copy_top_level(dest_root) + copy_whole_dirs(dest_root) + copy_curated(dest_root, 'tools', TOOLS_MANIFEST) + copy_curated(dest_root, 'docs', DOCS_MANIFEST) + copy_knowledge_stub(dest_root) + append_test_exclusions(dest_root) + write_examples(dest_root) + write_github(dest_root) + copy_intro_deck(dest_root) + + print('assembled {0}'.format(dest_root)) + + hits = scrub_tree(dest_root) + if hits: + print('\nSCRUB GATE FAILED -- {0} hit(s), build aborted, no commit made:' + .format(len(hits))) + for hit in hits: + print(' ' + hit) + raise SystemExit(1) + print('scrub gate: clean -- 0 hits against the real term list + ' + 'structural patterns + private-host/machine-path patterns') + + source_hash = git('rev-parse', 'HEAD').stdout.strip() or '(no commits yet)' + rev, message = git_init_and_commit(dest_root, source_hash) + print('\norphan commit {0} written in {1}'.format(rev[:12], dest_root)) + print('-' * 72) + print(message) + print('-' * 72) + + print(""" +DOES NOT PUSH. Review the tree at {0} first. Once reviewed, from inside it: + + git remote add github https://github.com/will123song/pyrevit-dev.git + git push --force-with-lease github HEAD:dist + +Pushed to a `dist` branch, NOT main -- deliberate, so nothing already on the +GitHub repo's main branch is clobbered by this. Point colleagues at the +`dist` branch (or open a PR from dist -> main once reviewed) rather than +main directly. +""".format(dest_root)) + return dest_root + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + ap.add_argument('out_dir', nargs='?', help='where to build the tree') + ap.add_argument('--force', action='store_true', + help='overwrite out_dir if it exists') + ap.add_argument('--scan-only', metavar='DIR', + help='run only the scrub gate over an existing tree ' + '(e.g. to prove it aborts on a planted term) and exit') + args = ap.parse_args(argv) + + if args.scan_only: + hits = scrub_tree(args.scan_only) + if hits: + print('{0} hit(s):'.format(len(hits))) + for hit in hits: + print(' ' + hit) + return 1 + print('clean: 0 hits') + return 0 + + if not args.out_dir: + print('usage: make_dist.py OUT_DIR [--force]', file=sys.stderr) + return 2 + + build(os.path.abspath(args.out_dir), force=args.force) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..318164a --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +"""Ribbon icons, drawn rather than drawn-in-a-paint-program. + + python tools/make_icons.py + +Needs Pillow. Writes icon.png into the pushbutton folders it knows about and +leaves everything else alone. + +The house style, taken from the icons that already existed: + + 96 x 96 RGBA, a rounded square of ONE flat colour filling the canvas with + a 2px margin and a 20px corner radius, a white glyph on top, nothing else. + No gradients, no outlines, no text. + +Colour is the identifier. Every button on the tab gets its own and they are +chosen to survive being 32px next to each other, which is the only size +anybody sees: + + GodMod terracotta 217, 119, 87 + ExtractDesign blue 45, 108, 178 + TicketQC green 43, 138, 90 + SelfCheck slate 86, 98, 112 + PanelLiftCheck purple 109, 76, 163 + ProjectConfig amber 198, 141, 26 + SnapshotClose indigo 46, 52, 130 + Config moss 55, 118, 45 (the pulldown, not a button) + DesignHub plum 124, 58, 136 (the pulldown, not a button) + PanelAnalysis seagreen 35, 139, 120 (a DesignHub row -- WIP) + Modeling forest 72, 119, 34 (the pulldown, not a button) + BeamAnalysis fern 45, 118, 57 (a DesignHub row -- WIP) + ConnectionAnalysis olive-gold 134, 130, 39 (a DesignHub row -- WIP) + +PanelAnalysis sits at hue 169, the midpoint of the widest arc still open once +every hue above (14, 40, 75, 112, 149, 189, 211/212/214, 235, 262, 291, 321, +353) is plotted on the ring -- between TicketQC's green at 149 and Gravity's +teal at 189, 20 degrees from each. The ring is full enough now that 20 is the +best separation left anywhere on it. + +Three more landed 2026-09-05, for the ribbon reorganisation that added a +`Modeling.pulldown` (ReinforceSuggest / ReinforcingQC / TicketQC) beside +SnapshotClose, and moved BeamAnalysis / ConnectionAnalysis onto DesignHub as +two more (WIP) rows. Same method as PanelAnalysis: plot every hue already in +use (now including 169) and take the widest remaining arcs. +`Modeling.pulldown` sits at 93, the midpoint of LateralExtract's 75 and +Config's 112 (18-19 degrees each side, the same class of separation +PanelAnalysis accepted at 20). `BeamAnalysis` takes 130, the midpoint of +Config's 112 and TicketQC's 149. `ConnectionAnalysis` takes 57, the midpoint +of ExtractDesign/blue-adjacent amber at 40 and LateralExtract's 75 -- the +only other arc left as wide as those two. All three are pulled darker / +less saturated than their nearest neighbour, the same value-before-hue rule +`archive_icon` and the two pulldowns above already use. + +Purple and amber went to those two because they used to sit side by side on +one QC panel and were being pressed by mistake -- opposite hue and opposite +value is as far apart as the palette goes. The 2026-08-30 rebuild put them on +different panels, which fixes the confusion a second way; the colours stay as +they are because the owner has learned them. + +THE TWO PULLDOWNS. A `.pulldown` is a component in its own right and it draws +its own `icon.png` (`genericcomps` resolves one for every component, and +`uimaker._produce_ui_pulldown` hands it to `create_pulldown_button`). Without +one it renders BLANK -- `_create_button_group` catches the icon error and only +debug-logs it, so nothing tells you. They are drawn here for that reason, in +the same canvas, palette and supersample as the seven above. + +Their hues were not picked by eye. The tab already spends 14, 40, 75, 149, +189, 211/212/214, 235, 262, 321 and 353; the two widest unused arcs in that +ring are 75->149 and 262->321, so these two sit at their midpoints -- 112 and +291 -- which is as far from every existing button as the ring still allows. +Both are taken darker than their nearest neighbour (moss L .32 against +TicketQC's .35, plum L .38 against PanelLift's .47) so the separation is a +value difference before it is a hue difference, the same argument as +`archive_icon`. + +Their glyphs are containers rather than tools on purpose: neither pulldown +does anything, it opens. Config is sliders and NOT a gear -- the gear is +ProjectConfig, which is a row inside it, and a gear opening onto a gear reads +as a bug. DesignHub is a hub, which promises no particular tool, which is +right for a button whose two rows are both unbuilt. + +Everything is drawn at 4x and resampled. Pillow has no anti-aliased +primitives, and a 96px icon with hard edges looks broken next to the others. +""" +from __future__ import print_function + +import math +import os + +from PIL import Image, ImageDraw + +S = 4 # supersample factor +N = 96 # final size +MARGIN = 2 +RADIUS = 20 + +PURPLE = (109, 76, 163, 255) +AMBER = (198, 141, 26, 255) +INDIGO = (46, 52, 130, 255) +MOSS = (55, 118, 45, 255) +PLUM = (124, 58, 136, 255) +SEAGREEN = (35, 139, 120, 255) +FOREST = (72, 119, 34, 255) +FERN = (45, 118, 57, 255) +OLIVEGOLD = (134, 130, 39, 255) +WHITE = (255, 255, 255, 255) + +HERE = os.path.dirname(os.path.abspath(__file__)) +TAB = os.path.join(HERE, os.pardir, 'WillTools.extension', 'WillTools.tab') + + +def canvas(colour): + im = Image.new('RGBA', (N * S, N * S), (0, 0, 0, 0)) + d = ImageDraw.Draw(im) + d.rounded_rectangle([MARGIN * S, MARGIN * S, + (N - MARGIN) * S, (N - MARGIN) * S], + radius=RADIUS * S, fill=colour) + return im, d + + +def lift_icon(): + """A panel hanging from two slings. + + Sized for 32px, which is where it actually gets looked at. Three earlier + versions lost there: inserts drawn on the panel turned into a pair of + eyes and the whole thing read as a face -- and at 32px they were grey + mush anyway, complexity paid for and not delivered. A deep panel read as + a box. What survives is a thin slab under a triangle of rigging, which is + the one silhouette nothing else on this ribbon has. + """ + im, d = canvas(PURPLE) + k = S + # shackle: an open ring, so it reads as rigging and not as a bubble + d.ellipse([38 * k, 8 * k, 58 * k, 28 * k], outline=WHITE, width=6 * k) + # slings, landing on the top corners of the panel + for x in (24, 72): + d.line([48 * k, 26 * k, x * k, 56 * k], fill=WHITE, width=6 * k) + # the panel: thin, because a panel is + d.rounded_rectangle([16 * k, 56 * k, 80 * k, 76 * k], + radius=4 * k, fill=WHITE) + return im + + +def gear_icon(): + """A gear: settings, and shaped like nothing else on the tab.""" + im, d = canvas(AMBER) + k = S + cx = cy = 48.0 * k + teeth = 8 + r_out, r_root, r_hole = 35.0 * k, 26.5 * k, 12.0 * k + + pts = [] + pitch = 2 * math.pi / teeth + for i in range(teeth): + base = i * pitch + # flanks at 9% and 41% of the pitch, root for the rest + for frac, r in ((0.00, r_root), (0.09, r_out), + (0.41, r_out), (0.50, r_root)): + a = base + frac * pitch + pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) + d.polygon(pts, fill=WHITE) + d.ellipse([cx - r_hole, cy - r_hole, cx + r_hole, cy + r_hole], fill=AMBER) + return im + + +def archive_icon(): + """An arrow going down into an open tray: put it away, then shut the door. + + Hue was the constraint. The tab already spends 14 (terracotta), 40 + (amber), 75 (olive), 149 (green), 189 (teal), 211/214 (blue and two + slates), 262 (purple), 321 (magenta) and 353 (red), which leaves the + indigo band around 238 genuinely unused. It is nearest to ExtractDesign's + 211 blue, so this one is taken much darker and much less green -- navy + against medium blue, at 32px, is a value difference before it is a hue + difference. + + The glyph carries more of the load than the colour here on purpose: no + other button on the tab has an arrow, and none has a container. The + silhouette is legible before the colour is. + """ + im, d = canvas(INDIGO) + k = S + # the arrow: shaft, then a head wide enough to survive being 32px + d.rectangle([40 * k, 12 * k, 56 * k, 38 * k], fill=WHITE) + d.polygon([(28 * k, 36 * k), (68 * k, 36 * k), (48 * k, 60 * k)], + fill=WHITE) + # the tray: open at the top, so the arrow reads as going INTO it + d.rectangle([16 * k, 70 * k, 80 * k, 80 * k], fill=WHITE) + d.rectangle([16 * k, 52 * k, 26 * k, 80 * k], fill=WHITE) + d.rectangle([70 * k, 52 * k, 80 * k, 80 * k], fill=WHITE) + return im + + +def sliders_icon(): + """Three sliders at three settings: this button holds the set-up commands. + + NOT a gear. The gear is `gear_icon`, which is ProjectConfig -- the first + row inside this very pulldown -- and a gear that opens onto a gear reads + as a misclick rather than a menu. + + Three horizontal bands is the one silhouette nothing else on this tab has; + every other glyph here is centred and roughly square. The knobs sit at + three different positions on purpose: evenly spaced they collapse into a + hamburger menu at 32px, which would promise a menu of anything at all + rather than a menu of settings. + """ + im, d = canvas(MOSS) + k = S + # track, then knob, top to bottom. x is where that knob sits on its track. + for y, x in ((28, 62), (48, 34), (68, 54)): + d.rounded_rectangle([18 * k, (y - 3) * k, 78 * k, (y + 3) * k], + radius=3 * k, fill=WHITE) + # the knob is drawn in the field colour with a white ring, so it stays + # readable where it overlaps its own track + d.ellipse([(x - 10) * k, (y - 10) * k, (x + 10) * k, (y + 10) * k], + fill=MOSS, outline=WHITE, width=5 * k) + return im + + +def hub_icon(): + """A hub: one centre, three spokes, three nodes. + + A container, not a tool. Both rows behind this arrow are buttons-and-docs + only, so a glyph that promised a beam, a load or a moment would be + claiming more than the panel delivers -- what it has to say is "there is + more than one thing under here", and a hub says exactly that. + + Three nodes rather than two: two reads as a dumbbell, and the count is not + meant to be a count of the rows (there are two today and the picture must + not go stale when a third arrives). + """ + im, d = canvas(PLUM) + k = S + cx = cy = 48.0 * k + r_hub, r_node, reach = 13.0 * k, 9.0 * k, 30.0 * k + + # spokes first, so the nodes sit on top of their own ends + for a in (-math.pi / 2, math.pi / 6, 5 * math.pi / 6): + x, y = cx + reach * math.cos(a), cy + reach * math.sin(a) + d.line([cx, cy, x, y], fill=WHITE, width=6 * k) + d.ellipse([x - r_node, y - r_node, x + r_node, y + r_node], fill=WHITE) + d.ellipse([cx - r_hub, cy - r_hub, cx + r_hub, cy + r_hub], fill=WHITE) + return im + + +def form_icon(): + """A page of fields, with a small filled tag on one of them. + + This button pre-fills an Eriksson-shaped input form from the model + rather than checking anything, which is a different promise than every + other glyph on this tab makes -- none of them is a page. Three plain + field lines read as a form; the short solid tag beside the last one -- + smaller than the field it sits beside, so it reads as attached to it + rather than as a fourth line -- is the source chip this button's whole + idea rests on. + """ + im, d = canvas(SEAGREEN) + k = S + d.rounded_rectangle([20 * k, 12 * k, 76 * k, 84 * k], + radius=6 * k, fill=WHITE) + for y in (30, 48, 66): + d.rounded_rectangle([30 * k, (y - 3) * k, 58 * k, (y + 3) * k], + radius=3 * k, fill=SEAGREEN) + # the chip: a small solid tag beside the bottom field, in the page's own + # field colour, standing for the source badge every pre-filled value on + # the real page carries. + d.rounded_rectangle([62 * k, 63 * k, 74 * k, 69 * k], radius=3 * k, + fill=SEAGREEN) + return im + + +def layers_icon(): + """Three stacked plates: this pulldown holds the modelling tools. + + A container, like `hub_icon` and `sliders_icon`, and drawn to look like + neither of them -- three offset rounded bars read as layers of a model + being built up, which is what the three rows behind this arrow actually + do (suggest reinforcing, check it while it is modelled, check it once + it is on a ticket). No spokes, no knobs, no gear: a stack is the one + silhouette this tab does not already use for a pulldown. + """ + im, d = canvas(FOREST) + k = S + for i, y in enumerate((26, 46, 66)): + inset = i * 6 + d.rounded_rectangle([(18 + inset) * k, (y - 5) * k, + (78 - inset) * k, (y + 5) * k], + radius=4 * k, fill=WHITE) + return im + + +def beam_icon(): + """An I-beam cross-section: this row is a structural member check. + + Two flanges and a web -- the one profile on this tab that reads as + "beam" rather than "panel" (the panel glyph in `lift_icon` is a thin + slab; this is deliberately a wide flange instead) or "gear"/"hub" + (nothing here is a container). Half-built like GravityLoadChase and + LateralExtract, so it is a placeholder colour, not a promise. + """ + im, d = canvas(FERN) + k = S + d.rectangle([20 * k, 18 * k, 76 * k, 30 * k], fill=WHITE) + d.rectangle([20 * k, 66 * k, 76 * k, 78 * k], fill=WHITE) + d.rectangle([42 * k, 30 * k, 54 * k, 66 * k], fill=WHITE) + return im + + +def connection_icon(): + """Two plates and a pin: this row checks how members connect. + + Two overlapping rounded plates, offset so the overlap itself is + legible at 32px, with a single bolt circle through the middle -- the + one glyph on this tab that reads as a joint rather than a member or a + container. Half-built like its siblings; the colour says so. + """ + im, d = canvas(OLIVEGOLD) + k = S + d.rounded_rectangle([16 * k, 34 * k, 62 * k, 58 * k], radius=6 * k, + fill=WHITE) + d.rounded_rectangle([34 * k, 22 * k, 80 * k, 46 * k], radius=6 * k, + fill=WHITE) + d.ellipse([40 * k, 34 * k, 56 * k, 50 * k], fill=OLIVEGOLD) + return im + + +# Paths are relative to TAB and they are panel structure, which moves: the +# 2026-08-30 rebuild put two of the first three inside a `*.stack`, and the +# 2026-08-30 one moved all of them again and added the two pulldowns. `main()` +# prints "skipped, no such button" rather than failing, so a stale line here +# is silent -- check the output, not the exit. +# +# The last two are `.pulldown` folders, not buttons. That is not a mistake: +# see the module docstring. Everything else on the tab keeps the icon it +# already has and is deliberately not regenerated from here. +ICONS = ( + ('Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton', lift_icon), + ('Setup.panel/Config.pulldown/ProjectConfig.pushbutton', gear_icon), + ('Tools.panel/SnapshotClose.pushbutton', archive_icon), + ('Setup.panel/Config.pulldown', sliders_icon), + ('Design.panel/DesignHub.pulldown', hub_icon), + ('Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton', form_icon), + ('Tools.panel/Modeling.pulldown', layers_icon), + ('Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton', beam_icon), + ('Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton', + connection_icon), +) + + +def main(): + for where, draw in ICONS: + folder = os.path.join(TAB, *where.split('/')) + if not os.path.isdir(folder): + # Still not an error: the ribbon is allowed to move without this + # script being run. But nobody reads an exit code, so say it. + print('skipped, no such component: %s' % where) + continue + path = os.path.join(folder, 'icon.png') + draw().resize((N, N), Image.LANCZOS).save(path, 'PNG', optimize=True) + print('%s (%d bytes)' % (where, os.path.getsize(path))) + + +if __name__ == '__main__': + main() diff --git a/tools/pa_pagespec.py b/tools/pa_pagespec.py new file mode 100644 index 0000000..d232dc1 --- /dev/null +++ b/tools/pa_pagespec.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Build a Panel Analysis page-spec JSON with NO Revit running. + + python tools/pa_pagespec.py + python tools/pa_pagespec.py --reads reads.json --out spec.json + python tools/pa_pagespec.py --reads reads.json --job-root JOBDIR + +This is the CPython half of the owner's scope note on the HTML-page +rewrite: the front end (前端) must open and run before Revit ever supplies a +value, Revit only supplies PREFILL. `vibe.pa_pagespec.build_pagespec()` is +already Revit-free (see that module's own docstring); this script is what +gets `values` / `know` in front of it the same way `tools/deckcheck.py` gets +knowledge in front of its own checks -- off a live `doc`, on host CPython. + +`--reads` is a JSON file shaped like whatever `PanelAnalysis.pushbutton`'s +`_reads_for()` hands `panelanalysisform.prefill()` -- the exact file a +bridge dry-run should save (see `_scratch/pa_pagespec_dump.py`, which writes +`_scratch/pa_reads_wpa655.json` for this purpose) or a hand-written stand-in +for testing the page with no model at all. Omitted, or pointed at a file +that is not there, this runs with `reads = {}` -- an EMPTY, but still +complete and defaulted, form (see `pa_pagespec.build_pagespec`'s "THE TWO +CALLING SHAPES" note): every field present, every `pa_defaults` rule +applied, every table at its minimum row count, every drawing's geometry +degraded to a placeholder rather than missing. That is the shape the HTML +page needs to be developable and testable in a browser with no Revit and no +model at all. + +`--job-root` names the folder holding a job's OWN `knowledge.yaml` (see +`tools/deckcheck.py`'s `build_knowledge()` docstring for exactly what that +resolves) -- omitted, this runs with just the shipped defaults + catalogue +layers, same as `deckcheck.build_knowledge(None)`. + +`--out` writes the spec there (UTF-8); omitted, it prints to stdout. + +CONSTRAINTS THIS RESPECTS (same as `tools/deckcheck.py`, for the same +reason): host CPython, no Revit, standalone; prints no plant or client name +of its own -- design numbers and marks flowing through a `--reads` file are +data, not this file's business, per CLAUDE.md's "No client or job +information in the repo". +""" +from __future__ import print_function + +import argparse +import io +import json +import os +import sys +import types + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, os.pardir)) +LIB = os.path.join(REPO, 'WillTools.extension', 'lib') +if LIB not in sys.path: + sys.path.insert(0, LIB) + +# Same trick `tools/deckcheck.py` / `tools/ticket_delta.py` / +# `WillTools.extension/tests/conftest.py` all use: `vibe/__init__.py` +# imports the Revit API at module scope, which is fatal off Revit. Register +# a bare `vibe` package pointed at the directory so the Revit-free +# submodules (`panelanalysisform`, `pa_pagespec`, the five page modules, the +# four `pa_sketch_*` modules, `pa_defaults`) import normally and +# `__init__.py` never runs. +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [os.path.join(LIB, 'vibe')] + sys.modules['vibe'] = _stub + +if HERE not in sys.path: + sys.path.insert(0, HERE) + +import deckcheck # noqa: E402 -- reuses build_knowledge(), not re-derived +from vibe import pa_pagespec # noqa: E402 +from vibe import panelanalysisform as F # noqa: E402 + + +def _load_reads(path): + """`(reads_dict, error_or_None)`. A path that is not given, or not + there, is the ordinary "no Revit at all" case -- `{}`, no error. A path + that IS there and will not parse as a JSON object is reported, never + silently treated as empty (the same "a layer that fails to load must + never look like a layer that agrees" discipline `knowledge._merge_file` + uses).""" + if not path: + return {}, None + if not os.path.isfile(path): + return {}, "'{0}' does not exist; running with reads = {{}}.".format( + path) + try: + with io.open(path, 'r', encoding='utf-8') as fh: + data = json.load(fh) + except Exception as exc: + return {}, "'{0}' could not be read as JSON ({1!r}); running with " \ + 'reads = {{}}.'.format(path, exc) + if not isinstance(data, dict): + return {}, "'{0}' is not a JSON object; running with reads = {{}}." \ + .format(path) + return data, None + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--reads', default=None, + help='JSON file of the model reads dict (omit for ' + 'an empty, still-complete form)') + parser.add_argument('--job-root', default=None, + help="folder holding the job's own knowledge.yaml " + '(omit for shipped defaults + catalogue only)') + parser.add_argument('--out', default=None, + help='write the spec here (UTF-8); omit to print ' + 'to stdout') + args = parser.parse_args(argv) + + reads, reads_err = _load_reads(args.reads) + if reads_err: + sys.stderr.write(reads_err + '\n') + + try: + know = deckcheck.build_knowledge(args.job_root) + except Exception as exc: + sys.stderr.write( + 'knowledge could not be resolved off Revit ({0!r}); running ' + 'with the bare shipped defaults.\n'.format(exc)) + know = {} + + values = F.prefill(reads, know=know) + spec = pa_pagespec.build_pagespec(values, know=know, reads=reads) + blob = pa_pagespec.dumps(spec) + + if args.out: + with io.open(args.out, 'w', encoding='utf-8') as fh: + fh.write(blob if isinstance(blob, str) else blob.decode('utf-8')) + sys.stderr.write('wrote {0} ({1} bytes)\n'.format( + args.out, len(blob))) + else: + print(blob) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/pa_web.py b/tools/pa_web.py new file mode 100644 index 0000000..b3023f1 --- /dev/null +++ b/tools/pa_web.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Panel Analysis's local server -- the CPython half of the HTML front/back end. + + python tools/pa_web.py [--reads reads.json] [--job-root DIR] + [--port N|0] [--open] [--title T] + +Owner ruling, 2026-09-05: every calculation tool has an HTML 前端 (input page) +and HTML 后端 (results page) served by a local CPython HTTP server; the engine +is CPython (`panellift` + `tools/panelanalysis.py`). Revit is only an OPTIONAL +prefill plug-in -- its button reads the model, writes a reads JSON, launches +THIS script and shows the page in a WebView2 window. Without Revit, this same +server opens the page from a saved reads JSON, or blank-with-defaults, and Run +works end to end. See `docs/pa-web-spec.md` for the pagespec schema and the +host<->page protocol this implements over plain HTTP instead of postMessage. + +NO REVIT ANYWHERE ON THIS PATH. `vibe.panelanalysisform`, `vibe.pa_pagespec` +and `vibe.pa_defaults` are Revit-free (same `vibe` package stub trick +`tools/pa_pagespec.py` and `tools/deckcheck.py` use); `tools/panelanalysis.py` +(the solve + results-HTML renderer) is plain CPython too. + +Routes: + + GET / tools/pa_web/static/index.html (and every other + static asset under that folder, by relative path) + GET /api/spec the pagespec JSON: `vibe.pa_pagespec. + build_pagespec()`, built off `--reads` (or a + blank/defaulted form when absent) with any + previously SAVED user edits merged over the + prefill. + POST /api/geometry body {"values": {...}} -- `{"geometry": {...}}`, + the SAME `geometry` dict `/api/spec` returns, + rebuilt off the values the client currently + holds (not a save, not the prefill) -- how a + row-table edit (an opening, a restraint, a + support) or a geometry field the client has + not saved yet gets its outline/section/free- + body drawing redrawn without a full page + reload. See `app.js`'s `scheduleGeometryRefresh`. + POST /api/save body {"values": {...}} -- persists that dict as + this mark's saved edits (`/ + panelanalysis/.values.json`), read back by + the next `/api/spec`. + POST /api/run body {"values": {...}} -- `panelanalysisform. + validate()`; on problems, {"ok": false, + "kind": "input", "problems": [{"key", "text"}, + ...]} (best-effort key per problem sentence, see + `_key_for_problem`, '' when it cannot be matched + to one FIELDS key). `kind` says WHOSE fault the + refusal is: "input" is the piece / the form still + missing a value (nothing broke), "error" is the + solve or a write raising (a bug, traceback on + stderr). The page renders the two differently -- + a validation refusal and a crash used to share + one red banner and read as the same failure; + otherwise `build_deck()`, write `.pa.json`, + run the SAME solve `tools/panelanalysis.py` + performs on that deck shape (`build_page()`), + write the results HTML beside it, and answer + {"ok": true, "result_url": "/results/.html"}. + A solve exception is caught and reported the same + shape, never a dead server. + GET /results/ serves a written results HTML (or anything else + landed in the same folder) by filename. + POST /api/close acknowledges, then shuts the server down (used + by a page's own Close button, and by the Revit + button once its WebView2 window closes). + POST /api/log body {"text": ...} (anything JSON) -- appends + one line, `\t`, to + `/panelanalysis/.client.log` and + answers {"ok": true} (the client-side error + sink `app.js`'s `logError` posts to -- the route + `docs/ba-web-spec.md` §5 says PA's server gains + for Beam Analysis to reuse). + +Only binds 127.0.0.1. Threaded (`http.server.ThreadingHTTPServer`) so a slow +solve never blocks a concurrent static request. Every request and every solve +exception is logged to stderr; nothing here ever prints a plant, job or client +name (`--reads` / `--job-root` are data, supplied by the caller, never this +file's own business -- same rule `tools/pa_pagespec.py` follows). The one +place a client-supplied value is written anywhere is `POST /api/log`, into +`.client.log` -- into the FILE, never stdout. + +JOB ROOT resolution (where `.values.json` / `.pa.json` / the +results HTML all live, one folder per mark under `/panelanalysis/`): +the `--reads` file's own `"job_root"` field wins when present (the Revit +button always writes one -- `designspec.root_dir(doc)`, the same folder +`knowledge.yaml` lives beside); else `--job-root`; else +`%LOCALAPPDATA%\\WillTools\\panelanalysis`. The MARK itself (the file-name +stem) is `--reads`' own `"mark"` field, slugged, or the literal string +`'blank'` when there is no mark at all -- so two blank-path runs never +collide with a real panel's saved files, and a re-run of the same mark picks +its saved edits back up. + +`--port 0` (the default) lets the OS pick a free port; the actual URL is +printed as `PA_WEB_URL=http://127.0.0.1:/` -- the FIRST line on stdout, +flushed immediately, so a launcher (the Revit button, a test) can read it off +a pipe before the server does anything else. `--open` additionally opens it +in the default browser. +""" +from __future__ import print_function + +import argparse +import datetime +import io +import json +import mimetypes +import os +import sys +import threading +import traceback +import types +import webbrowser + +try: # Python 2 vs 3 + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.parse import urlparse, unquote +except ImportError: # pragma: no cover + from BaseHTTPServer import BaseHTTPRequestHandler # noqa: F401 + from SocketServer import ThreadingMixIn + from BaseHTTPServer import HTTPServer + from urlparse import urlparse # noqa: F401 + from urllib import unquote # noqa: F401 + + class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, os.pardir)) +LIB = os.path.join(REPO, 'WillTools.extension', 'lib') +if LIB not in sys.path: + sys.path.insert(0, LIB) + +# Same trick `tools/pa_pagespec.py` / `tools/deckcheck.py` use: `vibe/ +# __init__.py` imports the Revit API at module scope, which is fatal off +# Revit. Register a bare `vibe` package pointed at the directory so the +# Revit-free submodules import normally and `__init__.py` never runs. +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [os.path.join(LIB, 'vibe')] + sys.modules['vibe'] = _stub + +if HERE not in sys.path: + sys.path.insert(0, HERE) + +import deckcheck # noqa: E402 -- sets up the `vibe` stub as a side effect +import panelanalysis # noqa: E402 -- the CPython solve + results renderer +from vibe import pa_pagespec # noqa: E402 +from vibe import panelanalysisform as F # noqa: E402 + + +STATIC_DIR = os.path.join(HERE, 'pa_web', 'static') + + +# ------------------------------------------------------------- job / reads + +def _load_reads(path): + """`(reads_dict, error_or_None)` -- mirrors `tools/pa_pagespec.py`'s own + loader exactly: a path not given, or not there, is the ordinary "no + Revit at all" case (`{}`, no error); a path that IS there and will not + parse as a JSON object is reported, never silently treated as empty.""" + if not path: + return {}, None + if not os.path.isfile(path): + return {}, "'{0}' does not exist; running with reads = {{}}.".format( + path) + try: + with io.open(path, 'r', encoding='utf-8') as fh: + data = json.load(fh) + except Exception as exc: + return {}, "'{0}' could not be read as JSON ({1!r}); running with " \ + 'reads = {{}}.'.format(path, exc) + if not isinstance(data, dict): + return {}, "'{0}' is not a JSON object; running with reads = {{}}." \ + .format(path) + return data, None + + +def _default_job_root(): + base = os.environ.get('LOCALAPPDATA') or os.path.expanduser('~') + return os.path.join(base, 'WillTools', 'panelanalysis') + + +def _slug(text): + text = text or '' + out = ''.join(ch if (ch.isalnum() or ch in '-_.') else '_' for ch in text) + return out or 'panel' + + +def _ensure_dir(path): + if not os.path.isdir(path): + os.makedirs(path) + + +def _append_client_log(ctx, body): + """Append one `\t` line to this mark's + `client.log` and return the written path. Never raises -- logging must + never take the request down with it. The body is only ever written into + the file, never printed (this module promises nothing about a plant, job + or client name reaches stdout).""" + _ensure_dir(ctx.pa_dir) + path = os.path.join(ctx.pa_dir, ctx.mark + '.client.log') + line = datetime.datetime.now().isoformat() + '\t' + \ + json.dumps(body, separators=(',', ':'), ensure_ascii=False) + '\n' + with io.open(path, 'a', encoding='utf-8') as fh: + fh.write(line) + return path + + +class Context(object): + """Everything one server process needs, resolved once at startup.""" + + def __init__(self, reads, know, job_root, pa_dir, mark, static_dir): + self.reads = reads + self.know = know + self.job_root = job_root + self.pa_dir = pa_dir + self.mark = mark + self.static_dir = static_dir + + +def build_context(reads_path=None, job_root_override=None, + static_dir=STATIC_DIR): + reads, reads_err = _load_reads(reads_path) + if reads_err: + sys.stderr.write('pa_web: ' + reads_err + '\n') + + job_root = reads.get('job_root') or job_root_override or \ + _default_job_root() + pa_dir = os.path.join(job_root, 'panelanalysis') + _ensure_dir(pa_dir) + + try: + know, _layers = deckcheck.build_knowledge(job_root) + except Exception as exc: + sys.stderr.write( + 'pa_web: knowledge could not be resolved off {0!r} ({1!r}); ' + 'running with the bare shipped defaults.\n'.format( + job_root, exc)) + know = {} + + mark_raw = (reads.get('mark') or '').strip() + mark = _slug(mark_raw) if mark_raw else 'blank' + + return Context(reads=reads, know=know, job_root=job_root, pa_dir=pa_dir, + mark=mark, static_dir=static_dir) + + +# ------------------------------------------------------------- the pagespec + +def _load_saved_values(ctx): + """The `{key: value}` dict a previous `/api/save` for this mark left -- + `{}` for anything else (no file yet, a file that will not parse, a file + that is not shaped `{"values": {...}}`). Never raises.""" + path = os.path.join(ctx.pa_dir, ctx.mark + '.values.json') + if not os.path.isfile(path): + return {} + try: + with io.open(path, 'r', encoding='utf-8') as fh: + data = json.load(fh) + except Exception: + return {} + if isinstance(data, dict): + values = data.get('values') + if isinstance(values, dict): + return values + return {} + + +def _save_values(ctx, values): + _ensure_dir(ctx.pa_dir) + path = os.path.join(ctx.pa_dir, ctx.mark + '.values.json') + blob = json.dumps({'values': values}, indent=1, sort_keys=True, + ensure_ascii=False) + with io.open(path, 'w', encoding='utf-8') as fh: + fh.write(blob) + return path + + +def geometry_for(ctx, values): + """`{'geometry': {...}}` for `POST /api/geometry` -- the page's own + `values` dict, whatever the client currently holds (not re-prefilled, + not merged with a save on disk), run back through `pa_pagespec. + build_pagespec` so its `geometry` dict reflects an edit the client made + seconds ago and has not (or will not) have saved yet -- an opening row + added, a length typed in, a support moved. Rebuilding the WHOLE pagespec + to get one dict back is wasteful (every page's blocks/tables/choices are + recomputed too) but simple and safe: `build_pagespec` is pure and cheap, + and a second, geometry-only code path would be a second place for the + two to disagree. Never raises -- an exception comes back as an empty + geometry dict plus an `error` string, so a client-side redraw degrades + to 'nothing to draw' rather than a dead page. + """ + try: + spec = pa_pagespec.build_pagespec(values=values, know=ctx.know, + reads=ctx.reads) + return {'geometry': spec.get('geometry') or {}} + except Exception as exc: + sys.stderr.write('pa_web: geometry rebuild raised:\n' + + traceback.format_exc()) + return {'geometry': {}, + 'error': '{0}: {1}'.format(type(exc).__name__, exc)} + + +def build_spec_json(ctx): + """The pagespec dict for GET /api/spec: prefill off `ctx.reads`/`ctx. + know`, with any saved edits for this mark folded over it before + `build_pagespec` computes choices/tables/geometry off the RESULT -- so a + saved length/width edit also redraws the geometry, not just the raw + number.""" + try: + values = F.prefill(ctx.reads, know=ctx.know) + except Exception as exc: + sys.stderr.write( + 'pa_web: panelanalysisform.prefill() raised: {0!r}\n'.format(exc)) + values = {} + saved = _load_saved_values(ctx) + if saved: + values = dict(values) + values.update(saved) + return pa_pagespec.build_pagespec(values=values, know=ctx.know, + reads=ctx.reads) + + +# ------------------------------------------------------------- run / solve + +_DISPLAY_LABELS = None + + +def _display_label_pairs(): + """`[(label_text, key), ...]`, longest label first -- see + `_key_for_problem`. Built once; never raises (a `FIELDS` entry whose + `display_label` fails is simply skipped).""" + global _DISPLAY_LABELS + if _DISPLAY_LABELS is None: + pairs = [] + for f in F.FIELDS: + key = f.get('key') + if not key: + continue + try: + label = F.display_label(key) + except Exception: + continue + if label: + pairs.append((label, key)) + pairs.sort(key=lambda p: -len(p[0])) + _DISPLAY_LABELS = pairs + return _DISPLAY_LABELS + + +def _key_for_problem(text): + """The `FIELDS` key a `validate()` error sentence is most likely about, + or `''` when nothing matches -- best-effort, per the deliverable's own + admission that `validate()`'s plain-English errors do not carry a key of + their own. Matches by the error sentence STARTING WITH that field's own + `display_label(key)` (`'{0} is required'` / `'{0} {1}'` / `'{0}: {1}'` + are exactly the three shapes `validate()` builds its per-field errors + from), longest label first so e.g. a wythe-2 field is not shadowed by + wythe-1's shorter, textually-overlapping label.""" + if not text: + return '' + for label, key in _display_label_pairs(): + if text.startswith(label): + return key + return '' + + +def run_analysis(ctx, values): + """`{"ok": True, "result_url": ...}` or `{"ok": False, "kind": ..., + "problems": [...]}`. + + Never raises -- a `build_deck` / solve exception is caught and reported + the same problems shape the deliverable specifies, with an empty `key` + (there is no single field a solve exception can be pinned on). + + `kind` is `'input'` when `validate()` refused (the piece or the form is + still missing a value -- the user's to supply, nothing broke) and + `'error'` when the solve or a write raised (a bug -- the developer's, + traceback on stderr). The two are different answers -- "could not run + for want of data" against "ran and broke" -- and the reply names which, + because with only `problems` to go on the page rendered both through one + red banner and a validation refusal read as a crash (issue #19).""" + errors, parsed = F.validate(values) + if errors: + return {'ok': False, 'kind': 'input', 'problems': [ + {'key': _key_for_problem(t), 'text': t} for t in errors]} + + try: + deck = F.build_deck(parsed) + except Exception as exc: + sys.stderr.write('pa_web: build_deck() raised:\n' + + traceback.format_exc()) + return {'ok': False, 'kind': 'error', 'problems': [ + {'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]} + + _ensure_dir(ctx.pa_dir) + pa_path = os.path.join(ctx.pa_dir, ctx.mark + '.pa.json') + try: + blob = json.dumps(deck, indent=1, sort_keys=True, ensure_ascii=False) + with io.open(pa_path, 'w', encoding='utf-8') as fh: + fh.write(blob) + except Exception as exc: + sys.stderr.write('pa_web: could not write {0}:\n'.format(pa_path) + + traceback.format_exc()) + return {'ok': False, 'kind': 'error', 'problems': [ + {'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]} + + try: + html = panelanalysis.build_page(pa_path) + except Exception as exc: + sys.stderr.write('pa_web: panelanalysis.build_page() raised:\n' + + traceback.format_exc()) + return {'ok': False, 'kind': 'error', 'problems': [ + {'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]} + + result_name = ctx.mark + '.html' + result_path = os.path.join(ctx.pa_dir, result_name) + try: + with io.open(result_path, 'w', encoding='utf-8') as fh: + fh.write(html) + except Exception as exc: + sys.stderr.write('pa_web: could not write {0}:\n'.format( + result_path) + traceback.format_exc()) + return {'ok': False, 'kind': 'error', 'problems': [ + {'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]} + + return {'ok': True, 'result_url': '/results/' + result_name} + + +# ------------------------------------------------------------------ server + +def make_server(ctx, port=0): + server = ThreadingHTTPServer(('127.0.0.1', port), Handler) + server.ctx = ctx + return server + + +class Handler(BaseHTTPRequestHandler): + server_version = 'PAWeb/1' + + def log_message(self, fmt, *args): + try: + sys.stderr.write('pa_web: {0} - {1}\n'.format( + self.address_string(), fmt % args)) + except Exception: + pass + + # -------------------------------------------------------- plumbing + + def _send_json(self, obj, status=200): + body = json.dumps(obj, ensure_ascii=False).encode('utf-8') + self.send_response(status) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except Exception: + pass + + def _send_error_json(self, exc, status=500): + sys.stderr.write('pa_web: {0} {1} failed:\n{2}'.format( + self.command, self.path, traceback.format_exc())) + self._send_json({'ok': False, 'kind': 'error', 'problems': [ + {'key': '', 'text': '{0}: {1}'.format( + type(exc).__name__, exc)}]}, status=status) + + def _read_json_body(self): + try: + length = int(self.headers.get('Content-Length') or 0) + except (TypeError, ValueError): + length = 0 + raw = self.rfile.read(length) if length else b'' + if not raw: + return {} + try: + return json.loads(raw.decode('utf-8')) + except Exception: + return {} + + def _send_file(self, path, default_type): + ctype, _enc = mimetypes.guess_type(path) + ctype = ctype or default_type + # Every text-ish type this server ever serves (html/js/css/json) is + # written as UTF-8 -- say so, or a browser guesses cp1252/latin-1 and + # every non-ASCII glyph (the title's en dash, the row x buttons, the + # section-arrow triangles) renders as mojibake. mimetypes.guess_type + # never appends a charset on its own. + if ctype.startswith('text/') or ctype in ( + 'application/javascript', 'application/json'): + ctype += '; charset=utf-8' + try: + with open(path, 'rb') as fh: + data = fh.read() + except Exception as exc: + self._send_error_json(exc) + return + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + try: + self.wfile.write(data) + except Exception: + pass + + def _serve_from(self, root, rel, default_type): + rel = rel.lstrip('/') or 'index.html' + full = os.path.normpath(os.path.join(root, rel)) + root_norm = os.path.normpath(root) + if not (full == root_norm or + full.startswith(root_norm + os.sep)): + self.send_error(403, 'forbidden') + return + if not os.path.isfile(full): + self.send_error(404, 'not found') + return + self._send_file(full, default_type) + + # -------------------------------------------------------------- GET + + def do_GET(self): + ctx = self.server.ctx + path = unquote(urlparse(self.path).path) + try: + if path == '/api/spec': + self._send_json(build_spec_json(ctx)) + return + if path.startswith('/results/'): + self._serve_from(ctx.pa_dir, path[len('/results/'):], + 'text/html') + return + if path.startswith('/api/'): + self._send_json({'ok': False, 'problems': [ + {'key': '', 'text': 'unknown endpoint'}]}, status=404) + return + self._serve_from(ctx.static_dir, path, 'application/octet-stream') + except Exception as exc: + self._send_error_json(exc) + + # ------------------------------------------------------------- POST + + def do_POST(self): + ctx = self.server.ctx + path = urlparse(self.path).path + try: + if path == '/api/save': + values = self._read_json_body().get('values') or {} + _save_values(ctx, values) + self._send_json({'ok': True}) + return + if path == '/api/run': + values = self._read_json_body().get('values') or {} + self._send_json(run_analysis(ctx, values)) + return + if path == '/api/geometry': + values = self._read_json_body().get('values') or {} + self._send_json(geometry_for(ctx, values)) + return + if path == '/api/log': + body = self._read_json_body() + try: + _append_client_log(ctx, body) + except Exception as e: # the docstring's promise: never raises + sys.stderr.write('client.log append failed: %r\n' % (e,)) + self._send_json({'ok': True}) + return + if path == '/api/close': + self._send_json({'ok': True}) + threading.Thread(target=self.server.shutdown).start() + return + self._send_json({'ok': False, 'problems': [ + {'key': '', 'text': 'unknown endpoint'}]}, status=404) + except Exception as exc: + self._send_error_json(exc) + + +# --------------------------------------------------------------------- CLI + +def main(argv=None): + ap = argparse.ArgumentParser( + description='Serve the Panel Analysis HTML front/back end.') + ap.add_argument('--reads', default=None, + help='JSON file of the model reads dict (the Revit ' + 'button writes one; omit for a blank, still-' + 'complete form)') + ap.add_argument('--job-root', dest='job_root', default=None, + help='folder to store this run under (overridden by ' + "--reads' own job_root field, if present)") + ap.add_argument('--port', type=int, default=0, + help='0 (default) picks a free port') + ap.add_argument('--open', action='store_true', + help='open the page in the default browser') + ap.add_argument('--title', default='Panel Analysis', + help='cosmetic only -- logged at startup') + args = ap.parse_args(argv) + + ctx = build_context(args.reads, args.job_root) + + try: + server = make_server(ctx, args.port or 0) + except OSError as exc: + sys.stderr.write('pa_web: could not bind 127.0.0.1:{0}: {1}\n'.format( + args.port, exc)) + return 1 + + url = 'http://127.0.0.1:{0}/'.format(server.server_address[1]) + # FIRST line on stdout, flushed immediately -- a launcher (the Revit + # button, a test) reads this off a pipe before anything else happens. + print('PA_WEB_URL={0}'.format(url)) + sys.stdout.flush() + sys.stderr.write('pa_web: serving {0} for mark={1} (job_root={2}, ' + 'title={3})\n'.format(url, ctx.mark, ctx.job_root, + args.title)) + + if args.open: + try: + webbrowser.open(url) + except Exception as exc: + sys.stderr.write('pa_web: could not open a browser: {0}\n' + .format(exc)) + + try: + server.serve_forever() + finally: + server.server_close() + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/pa_web/static/app.js b/tools/pa_web/static/app.js new file mode 100644 index 0000000..35e83ce --- /dev/null +++ b/tools/pa_web/static/app.js @@ -0,0 +1,1308 @@ +// Panel Analysis's 前端 -- renders the pagespec JSON (docs/pa-web-spec.md) +// as tabbed pages and talks to the local server over the fixed HTTP +// contract (GET /api/spec, POST /api/save, POST /api/run, POST /api/close). +// Plain ES2017, no modules, no build step, no framework -- this renderer is +// meant to be generic enough that a future tool's own pagespec (Beam +// Analysis, Connection Analysis) renders through the same code. +(function () { + 'use strict'; + + var U = window.Units; + var D = window.Draw; + + var errBox = document.getElementById('errors'); + function logError(msg) { + errBox.hidden = false; + errBox.textContent += String(msg) + '\n'; + try { fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: String(msg) }) }); } catch (e) { /* best effort */ } + } + window.onerror = function (message, source, lineno, colno, error) { + logError((message || 'error') + ' @ ' + source + ':' + lineno + ':' + colno + + (error && error.stack ? '\n' + error.stack : '')); + }; + window.addEventListener('unhandledrejection', function (ev) { + logError('unhandled rejection: ' + (ev.reason && ev.reason.stack || ev.reason)); + }); + + // ------------------------------------------------------------- state + + var state = { + spec: null, + values: {}, + cutline: null, // {key, default} + drawings: [], // [{block, host, probEl, page}] + saveTimer: null, + qs: parseQuery(), + pageContainers: [], // DOM node per page index + tabButtons: [], + activeIndex: 0, + fieldEls: {}, // key -> [input elements] for error targeting + tableEls: {}, // table name -> {wrap, errEl, emptyEl, refresh} + visibility: [], // [{el, when}] blocks with a `visible_when` + overridden: {}, // derived key -> true once the user typed into it + liveKv: {} // table name -> [{td, spec}] for every kv row + // carrying a `live` spec (docs/pa-web-spec.md §2) + }; + + function parseQuery() { + var out = {}; + var s = window.location.search.replace(/^\?/, ''); + s.split('&').forEach(function (pair) { + if (!pair) return; + var kv = pair.split('='); + out[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || ''); + }); + return out; + } + + function truthy(v) { + if (v === null || v === undefined) return false; + return /^(yes|y|true|1)$/i.test(String(v).trim()); + } + + function escapeReg(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + + // --------------------------------------------------------- unit rules + + // Owner's ruling (2026-09-05): every length whose unit is feet displays + // as feet-inches, typed input parses through Units.parseLength, and the + // value stored in `values` stays decimal feet as a string. Inch fields + // use Units.fmtIn / parseLengthIn and store decimal inches. + function detectUnit(key, symbol, colUnit) { + if (colUnit === 'ft') return 'ft'; + if (colUnit === 'in') return 'in'; + if (symbol === 'ft') return 'ft'; + if (symbol === 'in') return 'in'; + if (/_ft$/.test(key)) return 'ft'; + if (/_in$/.test(key)) return 'in'; + return null; + } + + function numToStr(n) { + if (n === null || n === undefined || isNaN(n)) return ''; + var s = n.toFixed(6); + s = s.replace(/0+$/, '').replace(/\.$/, ''); + return s; + } + + function displayForUnit(rawValue, unitKind) { + if (rawValue === undefined || rawValue === null || String(rawValue).trim() === '') return ''; + var n = parseFloat(rawValue); + if (isNaN(n)) return String(rawValue); + return unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n); + } + + // -------------------------------------------------------- values I/O + + function getValue(key) { + var v = state.values[key]; + return v === undefined || v === null ? '' : v; + } + function setValue(key, v) { + state.values[key] = v; + } + + function updateValue(key, value, opts) { + // A block/cell with no `key` at all is in-spec (docs/pa-web-spec.md + // section 2: "some resistance-factor table cells") -- it carries no + // value of its own, so there is nothing to store or react to. + if (!key) return; + setValue(key, value); + scheduleSave(); + redrawDependents(key); + if (!(opts && opts.silent)) clearFieldError(key); + if (!(opts && opts.derived)) noteUserEdit(key); + recomputeDerived(key); + recomputeLiveKv(key); + applyVisibility(key); + } + + // ------------------------------------------------------ derived fields + + // `spec.derived` (docs/pa-web-spec.md, "derived"): key -> {sources, + // formula}. Editing a source recomputes the derived field here, live, + // mirroring panelanalysisform's own formula -- until the user types + // into the derived field itself, after which their value stands and + // the auto-update stops for that field (the owner's rule: editing f'c + // updates Ec; editing Ec never rewrites f'c and freezes Ec). Issue #22, + // item 1. The freeze is page state (`state.overridden`) AND persisted as + // `values['_override.'] = 'yes'` so it survives a reload. + var FORMULAS = { + // ACI 318 19.2.2: Ec = 33 * wc^1.5 * sqrt(f'c), whole psi -- the + // twin of panelanalysisform.concrete_modulus_psi + prefill's round(). + concrete_modulus_psi: function (args) { + var fc = parseFloat(args[0]), wc = parseFloat(args[1]); + if (isNaN(fc) || isNaN(wc) || fc <= 0 || wc <= 0) return null; + return String(Math.round(33 * Math.pow(wc, 1.5) * Math.sqrt(fc))); + } + }; + + function overrideKey(key) { return '_override.' + key; } + + function noteUserEdit(key) { + var derived = state.spec && state.spec.derived; + if (!derived || !derived[key]) return; + state.overridden[key] = true; + setValue(overrideKey(key), 'yes'); + } + + function loadOverrides() { + var derived = state.spec && state.spec.derived; + if (!derived) return; + Object.keys(derived).forEach(function (key) { + if (truthy(state.values[overrideKey(key)])) state.overridden[key] = true; + }); + } + + function recomputeDerived(changedKey) { + var derived = state.spec && state.spec.derived; + if (!derived) return; + Object.keys(derived).forEach(function (target) { + var d = derived[target]; + if (!d || !d.sources || d.sources.indexOf(changedKey) === -1) return; + if (state.overridden[target]) return; + var fn = FORMULAS[d.formula]; + if (!fn) return; + var out = fn(d.sources.map(getValue)); + if (out === null || out === undefined) return; + if (String(getValue(target)) === out) return; + setValue(target, out); + (state.fieldEls[target] || []).forEach(function (el) { + if ('value' in el && el.type !== 'checkbox') el.value = out; + }); + scheduleSave(); + clearFieldError(target); + redrawDependents(target); + }); + } + + // -------------------------------------------------------- live kv rows + // + // A kv row may carry a `live` spec (docs/pa-web-spec.md §2, "kv"; issue + // #23): value = the sum, over the table's PRESENT rows, of the product + // of the row's terms -- so the strand count, Total IP and Aps keep in + // step with the strand_row table as the user edits rows, instead of only + // updating on the next page build / Run. The server-rendered value is + // the initial display (a page with JS off reads the same numbers); the + // spec only takes over once a key of that table changes. + function fmtLive(sum, format) { + // The Python side prints `('%.4f' % v).rstrip('0').rstrip('.')` -- the + // same trimmed look here. `%d` (an integer-only term list -- the strand + // count) prints the integer. + var s; + if (format === '%d') { + s = String(Math.trunc(sum)); + } else { + var m = /^%(\d+)?\.(\d+)f$/.exec(format || ''); + s = m ? sum.toFixed(parseInt(m[2], 10)) : String(sum); + } + return s.replace(/0+$/, '').replace(/\.$/, ''); + } + + function termProduct(tableName, n, term) { + // One term of one present row: a column key whose cell parses as a + // number, or {col, map} with the cell text looked up in the + // page-shipped map. Returns null for "does not resolve" -- the whole + // row is then `unknown`, never a guessed zero. + var key = typeof term === 'string' ? term : term.col; + var text = String(getValue(tableName + '.' + n + '.' + key)); + if (term && term.map) { + return Object.prototype.hasOwnProperty.call(term.map, text) ? + term.map[text] : null; + } + if (text.trim() === '') return null; + var v = parseFloat(text); + return isNaN(v) ? null : v; + } + + function recomputeLiveKv(changedKey) { + // `state.liveKv[table]` holds every kv row that sums THAT table, so an + // edit to `..` or `
              ..present` (a row edit, or + // an add/remove) only rewrites the rows that could have changed. + Object.keys(state.liveKv).forEach(function (tableName) { + if (changedKey.indexOf(tableName + '.') !== 0) return; + (state.liveKv[tableName] || []).forEach(function (item) { + var live = item.spec; + var sum = 0; + var unknown = false; + rowIds(live.table).forEach(function (n) { + if (unknown) return; + var prod = 1; + for (var i = 0; i < live.terms.length; i++) { + var v = termProduct(live.table, n, live.terms[i]); + if (v === null) { unknown = true; return; } + prod *= v; + } + sum += prod; + }); + item.td.textContent = unknown + ? (live.unknown === undefined ? '' : live.unknown) + : fmtLive(sum, live.format); + }); + }); + } + + // ------------------------------------------------ dependent visibility + + // A block carrying `visible_when: {key, in: [...]}` (docs/pa-web-spec.md) + // is shown only while `values[key]` is one of those strings (compared + // trimmed, case-insensitively). Hidden means `hidden` on the rendered + // node -- the value is kept, nothing is unbound. Issue #22, item 3. + function visibleNow(when) { + if (!when || !when.key) return true; + var cur = String(getValue(when.key) || '').trim().toLowerCase(); + var wanted = (when['in'] || []).map(function (v) { return String(v).trim().toLowerCase(); }); + return wanted.indexOf(cur) !== -1; + } + + function registerVisibility(el, when) { + if (!el || !when || !when.key) return; + state.visibility.push({ el: el, when: when }); + el.hidden = !visibleNow(when); + } + + function applyVisibility(changedKey) { + state.visibility.forEach(function (v) { + if (v.when.key !== changedKey) return; + v.el.hidden = !visibleNow(v.when); + }); + } + + // -------------------------------------------------------- autosave + + function scheduleSave() { + if (state.saveTimer) clearTimeout(state.saveTimer); + state.saveTimer = setTimeout(doSave, 1000); + } + function doSave() { + state.saveTimer = null; + return fetch('/api/save', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: state.values }) + }).catch(function (e) { logError('autosave failed: ' + e); }); + } + function flushSave() { + if (state.saveTimer) { clearTimeout(state.saveTimer); state.saveTimer = null; } + return doSave(); + } + + // ----------------------------------------------------- provenance + + function provenanceTitle(key) { + var parts = []; + var spec = state.spec; + if (spec.basis && spec.basis[key]) parts.push('Basis: ' + spec.basis[key]); + if (spec.defaults_why && spec.defaults_why[key]) parts.push('Default rule: ' + spec.defaults_why[key]); + return parts.join('\n'); + } + function provIcon(key) { + if (!key) return null; + var t = provenanceTitle(key); + if (!t) return null; + var span = document.createElement('span'); + span.className = 'prov-icon'; + span.textContent = 'i'; + span.title = t; + return span; + } + + // ------------------------------------------------------- field errors + + function registerFieldEl(key, el) { + if (!key) return; + if (!state.fieldEls[key]) state.fieldEls[key] = []; + state.fieldEls[key].push(el); + } + + function clearFieldError(key) { + (state.fieldEls[key] || []).forEach(function (el) { + el.classList.remove('has-error'); + var msg = el.parentNode && el.parentNode.querySelector('.field-error-msg'); + if (msg) msg.remove(); + }); + } + function clearAllFieldErrors() { + Object.keys(state.fieldEls).forEach(clearFieldError); + Object.keys(state.tableEls).forEach(clearTableError); + } + function clearTableError(name) { + var t = state.tableEls[name]; + if (!t) return; + t.wrap.classList.remove('has-error'); + t.errEl.textContent = ''; + t.errEl.hidden = true; + } + function setTableError(name, text) { + var t = state.tableEls[name]; + if (!t) return null; + t.wrap.classList.add('has-error'); + t.errEl.textContent = text; + t.errEl.hidden = false; + return t.wrap; + } + function setFieldError(el, text) { + el.classList.add('has-error'); + var wrap = el.parentNode; + var msg = wrap.querySelector('.field-error-msg'); + if (!msg) { + msg = document.createElement('div'); + msg.className = 'field-error-msg'; + wrap.appendChild(msg); + } + msg.textContent = text; + } + + // ---------------------------------------------------------- rendering + + function h(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined && text !== null) e.textContent = text; + return e; + } + + function renderBlockList(container, blocks, ctx) { + (blocks || []).forEach(function (b) { + var node = renderBlock(b, ctx); + if (node) container.appendChild(node); + }); + } + + function renderBlock(block, ctx) { + var node = renderBlockInner(block, ctx); + if (node && block.visible_when) registerVisibility(node, block.visible_when); + return node; + } + + function renderBlockInner(block, ctx) { + switch (block.kind) { + case 'heading': return renderHeading(block); + case 'columns': return renderColumns(block, ctx); + case 'field': return renderField(block, ctx); + case 'kv': return renderKv(block); + case 'sentence': return renderSentence(block, ctx); + case 'table': return renderTable(block, ctx); + case 'lc_table': return renderLcTable(block, ctx); + case 'editable_table': return renderEditableTable(block, ctx); + case 'matrix': return renderMatrix(block, ctx); + case 'legend': return renderLegend(block); + case 'drawing': return renderDrawingBlock(block, ctx); + case 'section': return renderSection(block, ctx); + case 'rule': return h('hr', 'pa-rule'); + case 'space': { + var sp = h('div', 'pa-space'); + sp.style.height = (block.h || 8) + 'px'; + return sp; + } + default: { + var u = h('div', 'unknown-block'); + u.textContent = 'Unrecognized block kind: ' + block.kind; + return u; + } + } + } + + function renderHeading(block) { + var level = block.level === 1 ? 'h1' : (block.level === 3 ? 'h3' : 'h2'); + return h(level, 'pa-h', block.text || ''); + } + + function renderColumns(block, ctx) { + var row = h('div', 'pa-columns'); + (block.columns || []).forEach(function (sub) { + var col = h('div', 'pa-col'); + renderBlockList(col, sub, ctx); + row.appendChild(col); + }); + return row; + } + + function renderSentence(block, ctx) { + if (block.quiet) { + ctx.notes.hints.push({ label: null, text: block.text }); + return null; + } + return h('p', 'sentence', block.text || ''); + } + + function renderKv(block) { + var table = h('table', 'kv-table'); + var tbody = h('tbody'); + (block.rows || []).forEach(function (row) { + var tr = h('tr'); + tr.appendChild(h('td', null, row[0])); + var valueTd = h('td', null, row[1]); + tr.appendChild(valueTd); + tbody.appendChild(tr); + // A row may carry a third element -- a `live` spec (docs/pa-web-spec + // .md §2, "kv", issue #23): its value cell is a sum over a row table + // that the browser must keep in step with the rows as they are + // edited. Remember the
              + spec per table, so `recomputeLiveKv` + // rewrites exactly these cells. Rows without the element are + // unchanged two-column readouts. + var live = row[2]; + if (live && live.table && live.terms) { + if (!state.liveKv[live.table]) state.liveKv[live.table] = []; + state.liveKv[live.table].push({ td: valueTd, spec: live }); + } + }); + table.appendChild(tbody); + return table; + } + + function renderLegend(block) { + var wrap = h('div', 'legend'); + (block.groups || []).forEach(function (grp) { + var name = grp[0], items = grp[1]; + var gwrap = h('span'); + gwrap.appendChild(h('span', 'legend-group-title', name + ':')); + items.forEach(function (it) { + var span = h('span', 'legend-item'); + span.appendChild(h('span', 'legend-glyph', it[0] + ' ')); + span.appendChild(document.createTextNode(it[1])); + gwrap.appendChild(span); + gwrap.appendChild(document.createTextNode(' ')); + }); + wrap.appendChild(gwrap); + }); + return wrap; + } + + function renderSection(block, ctx) { + var det = document.createElement('details'); + det.className = 'pa-section'; + det.open = !block.collapsed; + var summary = h('summary', null, block.title || ''); + det.appendChild(summary); + var body = h('div', 'pa-section-body'); + renderBlockList(body, block.blocks, ctx); + det.appendChild(body); + return det; + } + + // ------------------------------------------------------------- field + + function fieldChoices(block) { + if (block.choices) return block.choices; + if (block.key && state.spec.choices && state.spec.choices[block.key]) return state.spec.choices[block.key]; + return null; + } + + function renderField(block, ctx) { + var key = block.key; + var wrap = h('div', 'field' + (block.required ? ' required' : '')); + if (block.multi_value || (block.editor === 'text' && /\n/.test(String(getValue(key) || '')))) { + wrap.classList.add('full-width'); + } + + var labelRow = h('div', 'field-label-row'); + labelRow.appendChild(h('span', 'field-label', block.label || key || '')); + if (block.symbol) labelRow.appendChild(h('span', 'field-symbol', block.symbol)); + var icon = provIcon(key); + if (icon) labelRow.appendChild(icon); + wrap.appendChild(labelRow); + + if (block.hint) ctx.notes.hints.push({ label: block.label, text: block.hint }); + + var input = buildEditor(block.editor, key, { + choices: fieldChoices(block), + multiValue: block.multi_value, + symbol: block.symbol + }); + wrap.appendChild(input.el); + registerFieldEl(key, input.input); + if (block.unused_by_solve) markUnused(wrap, input.input, labelRow); + return wrap; + } + + // `unused_by_solve: true` (docs/pa-web-spec.md): the control is + // collected only -- nothing in the solve reads it yet. It stays + // editable (a saved value survives) but is muted and says so, so it can + // never pass for a live input (issue #22, item 2). + var UNUSED_TEXT = 'Not used by the solve yet \u2014 collected only.'; + function markUnused(wrap, input, labelRow) { + wrap.classList.add('unused-by-solve'); + if (input) input.title = UNUSED_TEXT; + var tag = h('span', 'unused-tag', 'not used by the solve'); + tag.title = UNUSED_TEXT; + (labelRow || wrap).appendChild(tag); + } + + // Builds one editor (input/select/textarea/checkbox) bound to + // state.values[key]. Returns {el, input} where `el` is what to insert + // (may wrap a checkbox in a row) and `input` is the focusable control + // itself (what error state / provenance attach to). + function buildEditor(editor, key, opts) { + opts = opts || {}; + var unitKind = detectUnit(key || '', opts.symbol || null, opts.colUnit || null); + + if (editor === 'choice') { + var sel = document.createElement('select'); + sel.className = 'field-input'; + var blank = document.createElement('option'); + blank.value = ''; blank.textContent = '—'; + sel.appendChild(blank); + (opts.choices || []).forEach(function (c) { + var o = document.createElement('option'); + o.value = c; o.textContent = c; + sel.appendChild(o); + }); + sel.value = getValue(key); + sel.addEventListener('change', function () { updateValue(key, sel.value); }); + return { el: sel, input: sel }; + } + + if (editor === 'check') { + var row = h('div', 'field-check-row'); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'field-input'; + cb.style.width = 'auto'; + cb.checked = truthy(getValue(key)); + cb.addEventListener('change', function () { updateValue(key, cb.checked ? 'yes' : ''); }); + row.appendChild(cb); + return { el: row, input: cb }; + } + + // A plain 'text' editor whose CURRENT value already carries a newline + // (face_lifters / edge_lifters: "one lifter per line" per their own + // hint, even though the spec marks them multi_value:false) is rendered + // as a textarea regardless of that flag -- a single-line mashes + // every line into one unreadable run, which is worse than a generic + // renderer bending its own rule for a value shape it can plainly see. + var forceMulti = opts.multiValue || (editor === 'text' && /\n/.test(String(getValue(key) || ''))); + + if (forceMulti) { + var ta = document.createElement('textarea'); + ta.className = 'field-input'; + if (unitKind) { + bindMultiLength(ta, unitKind, key); + } else { + ta.value = getValue(key); + ta.addEventListener('input', function () { updateValue(key, ta.value); }); + } + return { el: ta, input: ta }; + } + + var inp = document.createElement('input'); + inp.type = 'text'; + inp.className = 'field-input'; + if (unitKind) { + bindLength(inp, unitKind, key); + } else { + inp.value = getValue(key); + inp.addEventListener('input', function () { updateValue(key, inp.value); }); + } + return { el: inp, input: inp }; + } + + function bindLength(input, unitKind, key) { + input.value = displayForUnit(getValue(key), unitKind); + input.addEventListener('input', function () { + var raw = input.value.trim(); + if (raw === '') { updateValue(key, ''); return; } + try { + var n = unitKind === 'ft' ? U.parseLength(raw) : U.parseLengthIn(raw); + updateValue(key, numToStr(n)); + } catch (e) { /* keep typing -- don't clobber the last valid value */ } + }); + input.addEventListener('blur', function () { + var raw = input.value.trim(); + if (raw === '') { input.value = ''; return; } + try { + var n = unitKind === 'ft' ? U.parseLength(raw) : U.parseLengthIn(raw); + updateValue(key, numToStr(n)); + input.value = unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n); + } catch (e) { + setFieldError(input, 'Not a valid length -- try 8\'-4 1/2", 100", or a plain number.'); + } + }); + } + + function bindMultiLength(textarea, unitKind, key) { + function fmtStored(raw) { + var parts = String(raw || '').split(/[,\n]/).map(function (s) { return s.trim(); }).filter(Boolean); + return parts.map(function (p) { + var n = parseFloat(p); + return isNaN(n) ? p : (unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n)); + }).join(', '); + } + textarea.value = fmtStored(getValue(key)); + textarea.addEventListener('blur', function () { + var raw = textarea.value.trim(); + if (raw === '') { updateValue(key, ''); return; } + var parts = raw.split(/[,\n]/).map(function (s) { return s.trim(); }).filter(Boolean); + try { + var nums = parts.map(function (p) { + return unitKind === 'ft' ? U.parseLength(p) : U.parseLengthIn(p); + }); + updateValue(key, nums.map(numToStr).join(',')); + textarea.value = nums.map(function (n) { return unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n); }).join(', '); + } catch (e) { + setFieldError(textarea, 'One or more entries are not valid lengths.'); + } + }); + } + + // ------------------------------------------------------------- table + + function tableCellNode(cell, ctx) { + if (cell === null || cell === undefined) return h('td', null, ''); + if (typeof cell === 'string') return h('td', null, cell); + if (typeof cell === 'object' && cell.na) { + return h('td', 'na-cell', '—'); + } + // A plain read-only cell with a hover title (a rating's basis sentence + // -- e.g. the Lifting Hardware table's Tension/Shear columns) -- named + // by having `text` but no `editor`, so it never collides with an + // editable cell's own shape (which always carries `editor`). + if (typeof cell === 'object' && cell.editor === undefined && + cell.text !== undefined) { + var textTd = h('td', null, cell.text); + if (cell.title) textTd.title = cell.title; + return textTd; + } + var td = document.createElement('td'); + var editor = buildEditor(cell.editor, cell.key, { choices: cell.choices, colUnit: cell.unit }); + if (cell.width) editor.input.style.width = cell.width + 'px'; + // `default` fills the cell when values[key] is blank (lc_table code rows). + if (cell.default !== undefined && cell.default !== null && cell.key && + (getValue(cell.key) === '' || getValue(cell.key) === undefined)) { + if (editor.input.tagName === 'SELECT') editor.input.value = cell.default; + else if (editor.input.type === 'checkbox') editor.input.checked = truthy(cell.default); + else editor.input.value = cell.default; + } + td.appendChild(editor.el); + var icon = provIcon(cell.key); + if (icon) td.appendChild(icon); + registerFieldEl(cell.key, editor.input); + return td; + } + + function renderTable(block, ctx) { + var table = h('table', 'pa-table'); + if (block.header_groups) { + var groupRow = h('tr'); + block.header_groups.forEach(function (g) { + var th = document.createElement('th'); + th.textContent = g[0]; + th.colSpan = g[1]; + groupRow.appendChild(th); + }); + var thead0 = h('thead'); + thead0.appendChild(groupRow); + table.appendChild(thead0); + } + var thead = table.querySelector('thead') || h('thead'); + var headRow = h('tr'); + (block.headers || []).forEach(function (hd) { headRow.appendChild(h('th', null, hd)); }); + thead.appendChild(headRow); + if (!table.contains(thead)) table.appendChild(thead); + var tbody = h('tbody'); + (block.rows || []).forEach(function (row) { + var tr = h('tr'); + row.forEach(function (cell) { tr.appendChild(tableCellNode(cell, ctx)); }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + return table; + } + + function renderMatrix(block, ctx) { + var table = h('table', 'pa-matrix'); + var thead = h('thead'); + var headRow = h('tr'); + headRow.appendChild(h('th', null, '')); + (block.columns || []).forEach(function (c) { headRow.appendChild(h('th', null, c)); }); + thead.appendChild(headRow); + table.appendChild(thead); + var tbody = h('tbody'); + (block.rows || []).forEach(function (row) { + var tr = h('tr'); + var labelTd = h('td', null, row.label || ''); + tr.appendChild(labelTd); + (row.cells || []).forEach(function (cell) { tr.appendChild(tableCellNode(cell, ctx)); }); + if (row.unused_by_solve) { + tr.classList.add('unused-by-solve'); + tr.title = UNUSED_TEXT; + labelTd.appendChild(h('span', 'unused-tag', 'not used')); + } + tbody.appendChild(tr); + if (row.hint) ctx.notes.hints.push({ label: row.label, text: row.hint }); + }); + table.appendChild(tbody); + return table; + } + + // ------------------------------------------------------------ lc_table + + function lcExistingUserIds(name) { + var re = new RegExp('^load_combo\\.' + escapeReg(name) + '\\.u(\\d+)\\.'); + var ids = {}; + Object.keys(state.values).forEach(function (k) { + var m = re.exec(k); + if (m) ids[parseInt(m[1], 10)] = true; + }); + return Object.keys(ids).map(Number).sort(function (a, b) { return a - b; }); + } + + function renderLcTable(block, ctx) { + var wrap = document.createElement('div'); + var table = h('table', 'pa-lc'); + var thead = h('thead'); + var headRow = h('tr'); + (block.headers || []).forEach(function (hd) { headRow.appendChild(h('th', null, hd)); }); + headRow.appendChild(h('th', null, '')); + thead.appendChild(headRow); + table.appendChild(thead); + var tbody = h('tbody'); + + function buildRow(idText, cells, isUserRow, userId) { + var tr = h('tr'); + tr.appendChild(h('td', null, idText)); + cells.forEach(function (cell) { tr.appendChild(tableCellNode(cell, ctx)); }); + var rmTd = h('td', 'row-remove'); + if (isUserRow) { + var btn = h('button', 'row-remove-btn', '✕'); + btn.title = 'Remove this row'; + btn.addEventListener('click', function () { + setValue('load_combo.' + block.name + '.u' + userId + '.include', ''); + tr.remove(); + }); + rmTd.appendChild(btn); + } + tr.appendChild(rmTd); + return tr; + } + + (block.rows || []).forEach(function (row) { + tbody.appendChild(buildRow(row[0], row.slice(1), false, null)); + }); + lcExistingUserIds(block.name).forEach(function (uid) { + var cells = (block.factors || []).map(function (f) { + return { editor: 'num', key: 'load_combo.' + block.name + '.u' + uid + '.' + f }; + }); + cells.push({ editor: 'check', key: 'load_combo.' + block.name + '.u' + uid + '.include' }); + tbody.appendChild(buildRow('u' + uid, cells, true, uid)); + }); + table.appendChild(tbody); + wrap.appendChild(table); + + var addBtn = h('button', 'btn add-row-btn', 'Add LC'); + addBtn.addEventListener('click', function () { + var uid = (lcExistingUserIds(block.name).pop() || 0) + 1; + (block.factors || []).forEach(function (f) { + setValue('load_combo.' + block.name + '.u' + uid + '.' + f, '0'); + }); + setValue('load_combo.' + block.name + '.u' + uid + '.include', 'yes'); + scheduleSave(); + var cells = (block.factors || []).map(function (f) { + return { editor: 'num', key: 'load_combo.' + block.name + '.u' + uid + '.' + f }; + }); + cells.push({ editor: 'check', key: 'load_combo.' + block.name + '.u' + uid + '.include' }); + tbody.appendChild(buildRow('u' + uid, cells, true, uid)); + }); + wrap.appendChild(addBtn); + return wrap; + } + + // ------------------------------------------------------- editable_table + + function tableColumns(tableName, blockColumns) { + var def = state.spec.tables && state.spec.tables[tableName]; + if (def && def.columns) return def.columns.map(function (c) { + return { key: c.key, label: c.label, editor: c.editor, choices: c.choices, unit: c.unit }; + }); + return (blockColumns || []).map(function (c) { + return { key: c.col, label: c.label, editor: c.editor, choices: c.choices, unit: null }; + }); + } + + function rowIds(tableName) { + var re = new RegExp('^' + escapeReg(tableName) + '\\.(\\d+)\\.present$'); + var ids = []; + Object.keys(state.values).forEach(function (k) { + var m = re.exec(k); + if (m && truthy(state.values[k])) ids.push(parseInt(m[1], 10)); + }); + return ids.sort(function (a, b) { return a - b; }); + } + function maxEverId(tableName) { + var re = new RegExp('^' + escapeReg(tableName) + '\\.(\\d+)\\.'); + var max = 0; + Object.keys(state.values).forEach(function (k) { + var m = re.exec(k); + if (m) max = Math.max(max, parseInt(m[1], 10)); + }); + return max; + } + + function renderEditableTable(block, ctx) { + var tableName = block.table; + var cols = tableColumns(tableName, block.columns); + var wrap = document.createElement('div'); + var table = h('table', 'pa-editable'); + var thead = h('thead'); + var headRow = h('tr'); + cols.forEach(function (c) { headRow.appendChild(h('th', null, c.label)); }); + headRow.appendChild(h('th', null, '')); + thead.appendChild(headRow); + table.appendChild(thead); + var tbody = h('tbody'); + table.appendChild(tbody); + wrap.appendChild(table); + + function buildRow(n) { + var tr = h('tr'); + cols.forEach(function (c) { + var key = tableName + '.' + n + '.' + c.key; + var td = document.createElement('td'); + var editor = buildEditor(c.editor, key, { choices: c.choices, colUnit: c.unit }); + td.appendChild(editor.el); + var icon = provIcon(key); + if (icon) td.appendChild(icon); + registerFieldEl(key, editor.input); + tr.appendChild(td); + }); + var rmTd = h('td', 'row-remove'); + var btn = h('button', 'row-remove-btn', '✕'); + btn.title = 'Remove this row'; + btn.addEventListener('click', function () { + setValue(tableName + '.' + n + '.present', ''); + scheduleSave(); + redrawDependents(tableName + '.' + n + '.present'); + tr.remove(); + var t = state.tableEls[tableName]; + if (t) t.refresh(); + recomputeLiveKv(tableName + '.' + n + '.present'); + }); + rmTd.appendChild(btn); + tr.appendChild(rmTd); + return tr; + } + + rowIds(tableName).forEach(function (n) { tbody.appendChild(buildRow(n)); }); + + // Empty state (issue #22, items 5/11): a table with no rows says WHY + // -- the model read's own basis sentence when it left one (the + // block's `empty_state.basis_keys`, looked up in `spec.basis`), else + // the block's `empty_state.text`, else a generic "nothing entered + // yet". Could-not-judge and judged-empty must never look alike. + var emptyEl = h('div', 'table-empty-state'); + wrap.appendChild(emptyEl); + function refreshEmpty() { + var live = rowIds(tableName).length; + if (live) { emptyEl.hidden = true; emptyEl.textContent = ''; return; } + emptyEl.hidden = false; + emptyEl.textContent = emptyStateText(tableName, block); + } + refreshEmpty(); + + var errEl = h('div', 'table-error-msg'); + errEl.hidden = true; + wrap.appendChild(errEl); + state.tableEls[tableName] = { wrap: wrap, errEl: errEl, emptyEl: emptyEl, refresh: refreshEmpty }; + wrap.classList.add('editable-table-wrap'); + + var addBtn = h('button', 'btn add-row-btn', block.add_label || 'Add row'); + addBtn.addEventListener('click', function () { + var n = maxEverId(tableName) + 1; + setValue(tableName + '.' + n + '.present', 'yes'); + cols.forEach(function (c) { setValue(tableName + '.' + n + '.' + c.key, ''); }); + scheduleSave(); + tbody.appendChild(buildRow(n)); + refreshEmpty(); + clearTableError(tableName); + redrawDependents(tableName + '.' + n + '.present'); + recomputeLiveKv(tableName + '.' + n + '.present'); + }); + wrap.appendChild(addBtn); + return wrap; + } + + function emptyStateText(tableName, block) { + var def = (state.spec.tables && state.spec.tables[tableName]) || {}; + var es = block.empty_state || def.empty_state || null; + var basis = state.spec.basis || {}; + if (es && es.basis_keys) { + for (var i = 0; i < es.basis_keys.length; i++) { + var sentence = basis[es.basis_keys[i]]; + if (sentence) return 'Model read: ' + sentence; + } + } + if (es && es.text) return es.text; + return 'No rows yet \u2014 nothing was read from the model for this table and nothing has been entered.'; + } + + // ------------------------------------------------------------ drawing + + function cutFracValue() { + if (!state.cutline) return 0.5; + var v = state.values[state.cutline.key]; + var n = parseFloat(v); + if (v === undefined || v === '' || isNaN(n)) n = state.cutline.default; + return Math.max(0, Math.min(1, n)); + } + + function setDrawingProblems(el, problems) { + el.textContent = (problems && problems.length) ? problems.join(' — ') : ''; + } + + function redrawOne(inst) { + var block = inst.block; + var geom = state.spec.geometry[block.drawing_key]; + try { + var tool = window.ToolDrawings; + if (tool && typeof tool[block.source] === 'function') { + tool[block.source](inst, state, inst.host, geom); + setDrawingProblems(inst.probEl, geom && geom.problems); + return; + } + if (block.source === 'outline' && inst.page.id === 'concrete_extents') { + D.renderOutline(inst.host, geom, cutFracValue(), { + onDrag: function (frac) { + state.values[state.cutline.key] = String(frac); + redrawDependents(state.cutline.key); + }, + onDragEnd: function (frac) { + state.values[state.cutline.key] = String(frac); + scheduleSave(); + } + }, block.aspect); + setDrawingProblems(inst.probEl, geom && geom.problems); + } else if (block.source === 'outline' && inst.page.id === 'reinforcement') { + D.renderReinforcementOutline(inst.host, geom, block.aspect); + setDrawingProblems(inst.probEl, geom && geom.problems); + } else if (block.source === 'section' && inst.page.id === 'concrete_extents') { + var outlineGeom = state.spec.geometry['concrete_extents.outline']; + var xft = cutFracValue() * ((outlineGeom && outlineGeom.length_ft) || 0); + var sec = D.sectionAt(outlineGeom, xft); + D.renderSection(inst.host, sec, state.values, block.aspect); + setDrawingProblems(inst.probEl, sec.problems); + } else if (block.source === 'freebody') { + D.renderFreebody(inst.host, geom, block.aspect); + setDrawingProblems(inst.probEl, geom && geom.problems); + } else if (block.source === 'lifter_dunnage') { + D.renderLifterDunnage(inst.host, geom, block.aspect); + setDrawingProblems(inst.probEl, geom && geom.problems); + } else if (block.source === 'section' && inst.page.id === 'reinforcement') { + D.renderReinforcementSection(inst.host, geom, block.aspect); + setDrawingProblems(inst.probEl, geom && geom.problems); + } else { + inst.host.textContent = ''; + inst.host.appendChild(h('div', 'unknown-block', 'Unknown drawing source: ' + block.source)); + } + } catch (e) { + logError('drawing render failed (' + block.drawing_key + '): ' + (e && e.stack || e)); + } + } + + // `state.spec.geometry` is a SNAPSHOT fetched once, at page load + // (`init`'s single `/api/spec` call) -- editing a value never updates it + // on its own. Dragging the cut line stays a pure client-side recompute + // (`redrawOne`'s own 'section' branch slices the SAME outline snapshot at + // a new x, via `D.sectionAt`) because the outline's own geometry has not + // changed, only which station is shown -- that is what keeps the drag + // itself instant ("跟做核磁一样"). Anything else a drawing `depends_on` + // (a row table -- `opening`, `restraint`, `wind_zone`, ... -- or a field + // like `length_ft` / `width_ft`) DOES change the real geometry (a new + // opening, a moved support), so that snapshot has to be rebuilt off the + // current `state.values` before any of those drawings can redraw + // correctly; `scheduleGeometryRefresh` debounces that rebuild (one + // `/api/geometry` round trip, not one per keystroke) and then redraws + // every drawing on the page against the refreshed snapshot. + var geomRefreshTimer = null; + + function redrawDependents(changedKey) { + var needsRefresh = false; + state.drawings.forEach(function (inst) { + var deps = inst.block.depends_on || []; + var hit = deps.indexOf(changedKey) !== -1 || + deps.some(function (d) { return changedKey.indexOf(d + '.') === 0; }); + if (!hit) return; + if (state.cutline && changedKey === state.cutline.key) { + redrawOne(inst); + } else { + needsRefresh = true; + } + }); + if (needsRefresh) scheduleGeometryRefresh(); + } + + function scheduleGeometryRefresh() { + if (geomRefreshTimer) clearTimeout(geomRefreshTimer); + geomRefreshTimer = setTimeout(refreshGeometryAndRedraw, 250); + } + + function refreshGeometryAndRedraw() { + geomRefreshTimer = null; + fetch('/api/geometry', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: state.values }) + }).then(function (res) { return res.json(); }) + .then(function (data) { + if (data && data.geometry) state.spec.geometry = data.geometry; + state.drawings.forEach(redrawOne); + }) + .catch(function (e) { logError('geometry refresh failed: ' + e); }); + } + + function renderDrawingBlock(block, ctx) { + var wrap = h('div', 'drawing-block'); + if (block.caption) wrap.appendChild(h('div', 'drawing-caption', block.caption)); + var host = h('div', 'drawing-host'); + wrap.appendChild(host); + var probEl = h('div', 'drawing-problems'); + wrap.appendChild(probEl); + + if (block.cutline) state.cutline = { key: block.cutline.key, default: block.cutline.default }; + + var inst = { block: block, host: host, probEl: probEl, page: ctx.page }; + state.drawings.push(inst); + redrawOne(inst); + return wrap; + } + + // -------------------------------------------------------------- pages + + function buildNotes(notesData, pageProblems) { + if (!notesData.hints.length && !(pageProblems && pageProblems.length)) return null; + var det = document.createElement('details'); + det.className = 'pa-notes'; + det.appendChild(h('summary', null, 'Notes')); + var body = h('div', 'pa-notes-body'); + if (notesData.hints.length) { + body.appendChild(h('h4', null, 'Field notes')); + var ul = h('ul'); + notesData.hints.forEach(function (n) { + var li = document.createElement('li'); + if (n.label) { + var b = document.createElement('b'); + b.textContent = n.label + ': '; + li.appendChild(b); + } + li.appendChild(document.createTextNode(n.text || '')); + ul.appendChild(li); + }); + body.appendChild(ul); + } + if (pageProblems && pageProblems.length) { + body.appendChild(h('h4', null, 'Known limitations on this page')); + var ul2 = h('ul'); + pageProblems.forEach(function (p) { ul2.appendChild(h('li', null, p)); }); + body.appendChild(ul2); + } + det.appendChild(body); + return det; + } + + function renderPage(page, index) { + var container = h('div', 'page-panel'); + container.id = 'page-' + page.id; + var notesData = { hints: [] }; + renderBlockList(container, page.blocks, { page: page, notes: notesData }); + var notes = buildNotes(notesData, page.problems); + if (notes) container.appendChild(notes); + document.getElementById('pages').appendChild(container); + state.pageContainers[index] = container; + } + + function switchTab(index) { + state.activeIndex = index; + state.pageContainers.forEach(function (c, i) { c.classList.toggle('active', i === index); }); + state.tabButtons.forEach(function (b, i) { b.classList.toggle('active', i === index); }); + } + + function buildTabs() { + var tabsEl = document.getElementById('tabs'); + state.spec.pages.forEach(function (page, i) { + var btn = h('button', 'tab', page.title); + btn.addEventListener('click', function () { switchTab(i); }); + tabsEl.appendChild(btn); + state.tabButtons.push(btn); + }); + } + + function expandAll(root) { + root.querySelectorAll('details').forEach(function (d) { d.open = true; }); + } + + // --------------------------------------------------------------- run + + // The banner carries one of two faces, and they must never look alike: + // 'input' (amber) is the piece or the form still missing a value -- the + // user's to fill, nothing broke; 'crash' (red) is the server, the solve + // or the network breaking -- a bug, not a data gap. A validation refusal + // and a crash used to share one red banner, and a fixed dependency bug was + // mistaken for a new one because both read as "Panel Analysis errored" + // (issue #19). `kind` defaults to 'crash': an unlabelled failure renders + // loud, never calm. + function showBanner(text, kind) { + var b = document.getElementById('banner'); + b.className = 'banner ' + (kind === 'input' ? 'banner--input' : 'banner--crash'); + b.textContent = text; + b.hidden = false; + } + function hideBanner() { + document.getElementById('banner').hidden = true; + } + + function pageIndexOf(el) { + var container = el.closest && el.closest('.page-panel'); + if (!container) return -1; + return state.pageContainers.indexOf(container); + } + + function showProblems(problems, kind) { + var general = []; + var firstEl = null; + var fieldCount = 0; + // A problem keyed by a LEGACY field name (`supports_ft`) that no page + // renders any more is routed onto the row table that replaced it + // (`spec.problem_targets`, from panelanalysisform.LEGACY_TABLE_KEYS), + // or onto a table named directly -- highlighted and switched to, never + // dropped into the banner with no pointer (issue #22, item 11). + var targets = (state.spec && state.spec.problem_targets) || {}; + problems.forEach(function (p) { + var key = p.key || ''; + var els = key ? state.fieldEls[key] : null; + if (els && els.length) { + els.forEach(function (el) { setFieldError(el, p.text); }); + if (!firstEl) firstEl = els[0]; + fieldCount += 1; + return; + } + var tableName = key && (state.tableEls[key] ? key : targets[key]); + var wrapEl = tableName ? setTableError(tableName, p.text) : null; + if (wrapEl) { + if (!firstEl) firstEl = wrapEl; + fieldCount += 1; + return; + } + general.push((key ? key + ': ' : '') + p.text); + }); + if (kind === 'input') { + // Always a banner, even when every problem sits on a field: the + // user has to be told this is a data gap and not a broken tool. + var head = 'Cannot run yet — input still needed, nothing is broken'; + if (fieldCount) head += ' (' + fieldCount + ' field' + (fieldCount === 1 ? '' : 's') + ' marked below)'; + showBanner(head + (general.length ? ': ' + general.join(' — ') : '.'), 'input'); + } else { + var text = 'The analysis broke — this is a bug, not missing input: ' + + (general.length ? general.join(' — ') : '(no message)') + + '. The traceback is in the server log.'; + showBanner(text, 'crash'); + logError('run failed (' + (kind || 'unlabelled') + '): ' + general.join(' — ')); + } + if (firstEl) { + var pi = pageIndexOf(firstEl); + if (pi >= 0) switchTab(pi); + firstEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (typeof firstEl.focus === 'function') firstEl.focus(); + } + } + + function onRun() { + hideBanner(); + clearAllFieldErrors(); + flushSave().then(function () { + return fetch('/api/run', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: state.values }) + }); + }).then(function (res) { return res.json(); }) + .then(function (data) { + if (data.ok) { + window.location = data.result_url; + } else { + showProblems(data.problems || [], data.kind); + } + }) + .catch(function (e) { + var msg = 'Could not reach the server: ' + (e && e.message || e); + showBanner(msg, 'crash'); + logError('run failed (network): ' + msg); + }); + } + + function onClose() { + // Inside Revit the page sits in a WebView2 host window (vibe.webhost) + // that only closes on a `{type: 'close'}` postMessage -- the server + // exiting leaves the window on screen (issue #22, item 10). A plain + // desktop browser has no `window.chrome.webview` and skips this; the + // server call below is the same on both paths. + try { + if (window.chrome && window.chrome.webview && typeof window.chrome.webview.postMessage === 'function') { + window.chrome.webview.postMessage({ type: 'close' }); + } + } catch (e) { logError('host close message failed: ' + e); } + fetch('/api/close', { method: 'POST' }).catch(function () { /* server exits; ignore */ }); + } + + // -------------------------------------------------------------- init + + function findCutlineInSpec(spec) { + var found = null; + function walk(blocks) { + (blocks || []).forEach(function (b) { + if (found) return; + if (b.kind === 'drawing' && b.cutline) { found = b.cutline; return; } + if (b.kind === 'columns') b.columns.forEach(walk); + if (b.kind === 'section') walk(b.blocks); + }); + } + spec.pages.forEach(function (p) { walk(p.blocks); }); + return found; + } + + function init() { + fetch('/api/spec').then(function (res) { return res.json(); }).then(function (spec) { + state.spec = spec; + state.values = Object.assign({}, spec.values || {}); + state.cutline = findCutlineInSpec(spec); + loadOverrides(); + + if (state.qs.cut !== undefined && state.cutline) { + var outlineGeom = spec.geometry['concrete_extents.outline']; + var length = (outlineGeom && outlineGeom.length_ft > 0) ? outlineGeom.length_ft : 1; + var cutFt = parseFloat(state.qs.cut); + if (!isNaN(cutFt)) { + var frac = Math.max(0, Math.min(1, cutFt / length)); + state.values[state.cutline.key] = String(frac); + } + } + + document.getElementById('pa-mark').textContent = spec.mark ? (' — ' + spec.mark) : ''; + document.title = spec.title ? (spec.title + (spec.mark ? ' - ' + spec.mark : '')) : 'Panel Analysis'; + + if (spec.problems && spec.problems.length) { + var sp = document.getElementById('spec-problems'); + sp.textContent = spec.problems.join(' — '); + sp.hidden = false; + } + + buildTabs(); + spec.pages.forEach(function (page, i) { renderPage(page, i); }); + + var initialTab = 0; + if (state.qs.tab !== undefined) { + var n = parseInt(state.qs.tab, 10); + if (!isNaN(n)) initialTab = Math.max(0, Math.min(spec.pages.length - 1, n - 1)); + } + switchTab(initialTab); + + if (state.qs.expand === 'all') { + state.pageContainers.forEach(expandAll); + } + + document.getElementById('btn-run').addEventListener('click', onRun); + document.getElementById('btn-close').addEventListener('click', onClose); + }).catch(function (e) { + showBanner('Could not load the form: ' + (e && e.message || e)); + logError('spec load failed: ' + (e && e.stack || e)); + }); + } + + document.addEventListener('DOMContentLoaded', init); +})(); diff --git a/tools/pa_web/static/draw.js b/tools/pa_web/static/draw.js new file mode 100644 index 0000000..ab1dc3d --- /dev/null +++ b/tools/pa_web/static/draw.js @@ -0,0 +1,952 @@ +// SVG drawings for Panel Analysis's five geometry blocks, plus the pure +// `sectionAt()` cut-profile function, reimplemented byte-for-byte from +// `vibe.pa_sketch_extents.section_at()` per docs/pa-web-spec.md section 6 -- +// the two must always agree, not just on the panels covered by whatever +// tests happen to exist. +// +// Everything here works off real-unit numbers (ft, in) straight out of the +// spec's `geometry` dict -- no pixels arrive from the server, all layout and +// scaling happens in here. Plain ES2017, no modules, no dependencies other +// than window.Units (units.js, loaded first). +(function () { + 'use strict'; + + var NS = 'http://www.w3.org/2000/svg'; + + // --------------------------------------------------------- small helpers + + function safeNum(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'boolean') return null; + if (typeof value === 'number') return isNaN(value) ? null : value; + var s = String(value).trim(); + if (s === '') return null; + var n = parseFloat(s); + return isNaN(n) ? null : n; + } + + function clamp(v, lo, hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; + } + + function el(name, attrs, parent) { + var e = document.createElementNS(NS, name); + if (attrs) { + Object.keys(attrs).forEach(function (k) { + if (attrs[k] !== null && attrs[k] !== undefined) { + e.setAttribute(k, attrs[k]); + } + }); + } + if (parent) parent.appendChild(e); + return e; + } + + function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); + } + + // A nominal-coordinate SVG root sized by the block's own aspect ratio -- + // width always "fills the container" via width:100%, height follows from + // aspect the way every drawing block in the spec declares it. + function svgRoot(container, aspect, nominalW) { + nominalW = nominalW || 1000; + var h = Math.max(60, Math.round(nominalW * (aspect || 0.3))); + clear(container); + // `aspect-ratio` (not a bare height:auto SVG attribute -- Chrome does + // NOT reliably derive height from viewBox alone on a replaced + // element, and falls back to a tall default box, letterboxing the + // drawing) is what actually makes the rendered box track the block's + // own aspect as the container's width changes. + var svg = el('svg', { + viewBox: '0 0 ' + nominalW + ' ' + h, + style: 'display:block;width:100%;aspect-ratio:' + nominalW + '/' + h + ';background:#fff' + }, container); + return { svg: svg, W: nominalW, H: h }; + } + + // Text with an opaque background patch behind it (drawn AFTER the text is + // measured, so it never overlaps a crossing line or another label). + function label(svg, x, y, text, opts) { + opts = opts || {}; + var t = el('text', { + x: x, y: y, + 'text-anchor': opts.anchor || 'middle', + 'font-size': opts.size || 15, + fill: opts.fill || '#1b1e23', + 'font-family': 'inherit' + }, svg); + t.textContent = text; + if (opts.rotate) { + t.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')'); + } + if (opts.bg !== false) { + var box; + try { box = t.getBBox(); } catch (e) { box = null; } + if (box) { + var pad = 2; + var rect = el('rect', { + x: box.x - pad, y: box.y - pad, + width: box.width + pad * 2, height: box.height + pad * 2, + fill: opts.bgFill || '#fff', + opacity: opts.bgOpacity === undefined ? 0.85 : opts.bgOpacity + }); + svg.insertBefore(rect, t); + if (opts.rotate) { + rect.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')'); + } + } + } + return t; + } + + // A horizontal dimension string: line + end ticks + centred text. + // `below` puts the text under the line instead of above it. + function dimH(svg, x1, x2, y, text, opts) { + opts = opts || {}; + var tick = opts.tick || 6; + var g = el('g', { class: 'dim' }, svg); + el('line', { x1: x1, y1: y - tick, x2: x1, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g); + el('line', { x1: x2, y1: y - tick, x2: x2, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g); + el('line', { x1: x1, y1: y, x2: x2, y2: y, stroke: '#8a8f98', 'stroke-width': 1 }, g); + label(g, (x1 + x2) / 2, opts.below ? y + 16 : y - 6, text, { size: opts.size || 13 }); + return g; + } + + // A vertical dimension string: line + end ticks + rotated centred text. + function dimV(svg, y1, y2, x, text, opts) { + opts = opts || {}; + var tick = opts.tick || 6; + var g = el('g', { class: 'dim' }, svg); + el('line', { x1: x - tick, y1: y1, x2: x + tick, y2: y1, stroke: '#8a8f98', 'stroke-width': 1 }, g); + el('line', { x1: x - tick, y1: y2, x2: x + tick, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g); + el('line', { x1: x, y1: y1, x2: x, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g); + var mx = opts.left ? x - 10 : x + 10; + label(g, mx, (y1 + y2) / 2, text, { size: opts.size || 13, rotate: -90 }); + return g; + } + + function ftIn(v) { return window.Units.fmtFtIn(v); } + function inch(v) { return window.Units.fmtIn(v); } + + // A cheap text-width estimate (no DOM measurement needed) for staggering + // labels BEFORE they are drawn -- good enough to keep a long label like + // 'Floor tie @ 13\'-2 1/2"' from being scored as if it were as narrow as + // a short one, which is what let two such labels print on top of each + // other despite `stagger()` "succeeding". + function estWidth(text, size) { return String(text).length * size * 0.58; } + + // Greedy label-collision avoidance: given item extents [{x0,x1}], returns + // a stacking "level" per item (0, 1, 2, ...) such that no two items on the + // same level overlap within `gap`. Used to stagger restraint / lifter + // labels that would otherwise sit on top of one another. + function stagger(items, gap) { + gap = gap || 4; + var levels = []; // each: last x1 placed on that level + var out = new Array(items.length); + var order = items.map(function (it, i) { return i; }) + .sort(function (a, b) { return items[a].x0 - items[b].x0; }); + order.forEach(function (i) { + var it = items[i]; + var lvl = 0; + while (lvl < levels.length && levels[lvl] > it.x0 - gap) lvl++; + levels[lvl] = it.x1; + out[i] = lvl; + }); + return out; + } + + // One continuous dimension chain's cells: consecutive `[lo, hi]` spans (in + // inches-from-left) starting at `railPosIn`, each labelled with the chain + // length as typed (`cs[i]`). Shared by the reinforcement plan and section + // so the two can never disagree about the chain itself (issue #23 item 9i). + function chainCellsFromRail(xs, cs, railPosIn) { + var cells = []; + var prev = railPosIn; + for (var i = 0; i < xs.length; i++) { + var x = safeNum(xs[i]); + if (x === null) x = prev; + cells.push({ lo: Math.min(prev, x), hi: Math.max(prev, x), label: cs[i] }); + prev = x; + } + return cells; + } + + // ------------------------------------------------------- section-at-cut + + function mergeRanges(ranges) { + var clean = ranges.filter(function (r) { return r[1] > r[0]; }) + .sort(function (a, b) { return a[0] - b[0]; }); + var merged = []; + clean.forEach(function (r) { + if (merged.length && r[0] <= merged[merged.length - 1][1]) { + merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], r[1]); + } else { + merged.push([r[0], r[1]]); + } + }); + return merged; + } + + var FALLBACK_LENGTH_FT = 1.0; + var FALLBACK_WIDTH_FT = 0.3; + + // Reimplements vibe.pa_sketch_extents.section_at() -- see + // docs/pa-web-spec.md section 6. Never throws. + function sectionAt(geometry, xFt) { + geometry = (geometry && typeof geometry === 'object') ? geometry : {}; + var problems = []; + var lengthFt = safeNum(geometry.length_ft); + if (lengthFt === null || lengthFt <= 0) lengthFt = FALLBACK_LENGTH_FT; + var widthFt = safeNum(geometry.width_ft); + if (widthFt === null || widthFt <= 0) widthFt = FALLBACK_WIDTH_FT; + var thicknessIn = safeNum(geometry.thickness_in); + + var x = safeNum(xFt); + if (x === null) { + problems.push('x_ft is not a number; using the panel midpoint.'); + x = lengthFt / 2.0; + } + x = clamp(x, 0, lengthFt); + + var rawRanges = []; + (geometry.openings || []).forEach(function (row) { + if (!row || typeof row !== 'object') return; + var type = String(row.type || 'opening').trim().toLowerCase(); + if (type !== 'opening' && type !== '') return; + var along = row.along; + if (!along || along.length !== 2 || along[0] === null || along[1] === null) return; + var lo = along[0], hi = along[1]; + if (!(lo <= x && x <= hi)) return; + var vert = row.vert; + if (!vert || vert.length !== 2 || vert[0] === null || vert[1] === null) { + problems.push('opening ' + JSON.stringify(row.label || row.index) + + ' crosses this station but has no across-width position entered; ' + + 'not punched into the section.'); + return; + } + rawRanges.push([Number(vert[0]), Number(vert[1])]); + }); + + var voids = mergeRanges(rawRanges); + return { x_ft: x, width_ft: widthFt, thickness_in: thicknessIn, voids: voids, problems: problems }; + } + + // -------------------------------------------------------------- outline + + // Renders the panel outline (Concrete Extents drawing 1) with openings + // and a draggable cut line. `cutFrac` is the initial 0..1 fraction; + // `cb.onDrag(frac)` fires continuously while dragging, `cb.onDragEnd(frac)` + // once on release -- both mouse and touch, via Pointer Events. + function renderOutline(container, geometry, cutFrac, cb, aspect) { + geometry = geometry || {}; + cb = cb || {}; + aspect = aspect || 0.32; + var root = svgRoot(container, aspect); + var svg = root.svg, W = root.W, H = root.H; + + var lengthFt = geometry.length_ft || FALLBACK_LENGTH_FT; + var widthFt = geometry.width_ft || FALLBACK_WIDTH_FT; + + var marginL = 60, marginR = 20, marginTop = 34, marginBottom = 24; + // The degraded-placeholder note sits above the overall-length dimension + // string -- give it its own row instead of sharing the dimension's, or + // the two labels print on top of each other (both centred on the same + // x, both near y=14). + if (geometry.degraded) marginTop += 16; + var drawW = W - marginL - marginR; + var drawH = H - marginTop - marginBottom; + var scale = drawW / lengthFt; + var rectH = Math.min(drawH, widthFt * scale); + var rectY = marginTop + (drawH - rectH) / 2; + + function xAt(ft) { return marginL + ft * scale; } + function yAt(vertFt) { return rectY + rectH - vertFt * scale; } // vert measured from bottom + + if (geometry.degraded) { + label(svg, W / 2, 14, 'No length/width entered yet -- placeholder shown', { size: 12, fill: '#b45309' }); + } + + // panel outline + el('rect', { + x: marginL, y: rectY, width: drawW, height: rectH, + fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5 + }, svg); + + // openings + (geometry.openings || []).forEach(function (op) { + if (!op.along) return; + var x0 = xAt(op.along[0]), x1 = xAt(op.along[1]); + var type = (op.type || '').toLowerCase(); + if (op.vert) { + var y0 = yAt(op.vert[1]), y1 = yAt(op.vert[0]); + el('rect', { + x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), + fill: type === 'opening' ? '#ffffff' : (type === 'reveal' ? '#eef2ff' : '#f1f5f9'), + stroke: '#334155', + 'stroke-dasharray': type === 'reveal' ? '4,3' : null, + 'stroke-width': 1.2 + }, svg); + } else { + // no across-width position: show a dashed full-height band + note + el('rect', { + x: x0, y: rectY, width: Math.max(1, x1 - x0), height: rectH, + fill: 'none', stroke: '#b45309', 'stroke-dasharray': '3,3', 'stroke-width': 1 + }, svg); + label(svg, (x0 + x1) / 2, rectY + rectH / 2, '?', { size: 11, fill: '#b45309' }); + } + if (op.label) { + label(svg, (x0 + x1) / 2, (op.vert ? yAt(op.vert[1]) : rectY) - 4, op.label, { size: 10 }); + } + }); + + // overall dimensions + dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt)); + dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true }); + + // cut line + var cutX = marginL + clamp(cutFrac, 0, 1) * drawW; + var lineGroup = el('g', { style: 'cursor:ew-resize' }, svg); + var hit = el('rect', { + x: cutX - 8, y: rectY - 6, width: 16, height: rectH + 12, + fill: 'transparent' + }, lineGroup); + var cutLine = el('line', { + x1: cutX, y1: rectY - 6, x2: cutX, y2: rectY + rectH + 6, + stroke: '#d33', 'stroke-width': 2 + }, lineGroup); + el('polygon', { + points: [cutX - 5, rectY - 6, cutX + 5, rectY - 6, cutX, rectY - 6 + 8].join(' '), + fill: '#d33' + }, lineGroup); + + function moveTo(clientX) { + var box = svg.getBoundingClientRect(); + var svgX = (clientX - box.left) / box.width * W; + var frac = clamp((svgX - marginL) / drawW, 0, 1); + var px = marginL + frac * drawW; + cutLine.setAttribute('x1', px); cutLine.setAttribute('x2', px); + hit.setAttribute('x', px - 8); + return frac; + } + + var dragging = false; + hit.addEventListener('pointerdown', function (ev) { + dragging = true; + try { hit.setPointerCapture(ev.pointerId); } catch (e) { /* ignore */ } + ev.preventDefault(); + }); + hit.addEventListener('pointermove', function (ev) { + if (!dragging) return; + var frac = moveTo(ev.clientX); + if (cb.onDrag) cb.onDrag(frac); + }); + function endDrag(ev) { + if (!dragging) return; + dragging = false; + var frac = moveTo(ev.clientX); + if (cb.onDragEnd) cb.onDragEnd(frac); + } + hit.addEventListener('pointerup', endDrag); + hit.addEventListener('pointercancel', endDrag); + + return { + setCutFrac: function (frac) { moveTo(marginL + clamp(frac, 0, 1) * drawW + + svg.getBoundingClientRect().left); } + }; + } + + // -------------------------------------------------------------- section + + function renderSection(container, sec, values, aspect) { + sec = sec || { width_ft: FALLBACK_WIDTH_FT, thickness_in: 8, voids: [] }; + aspect = aspect || 0.16; + var root = svgRoot(container, aspect); + var svg = root.svg, W = root.W, H = root.H; + + var widthFt = sec.width_ft || FALLBACK_WIDTH_FT; + var thicknessIn = sec.thickness_in || 8; + + var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 46; + var drawW = W - marginL - marginR; + var drawH = H - marginTop - marginBottom; + var scale = drawW / widthFt; + var rectH = Math.min(drawH, (thicknessIn / 12) * scale * 4); // exaggerate thin strips + if (rectH < 10) rectH = 10; + var rectY = marginTop + (drawH - rectH) / 2; + + function xAt(ft) { return marginL + ft * scale; } + + // draw solid segments, punching voids + var voids = (sec.voids || []).slice().sort(function (a, b) { return a[0] - b[0]; }); + var cursor = 0; + var segs = []; + voids.forEach(function (v) { + if (v[0] > cursor) segs.push([cursor, v[0]]); + cursor = Math.max(cursor, v[1]); + }); + if (cursor < widthFt) segs.push([cursor, widthFt]); + + segs.forEach(function (s) { + el('rect', { + x: xAt(s[0]), y: rectY, width: Math.max(0, xAt(s[1]) - xAt(s[0])), height: rectH, + fill: '#e2e8f0', stroke: '#1b1e23', 'stroke-width': 1.2 + }, svg); + }); + voids.forEach(function (v) { + label(svg, (xAt(v[0]) + xAt(v[1])) / 2, rectY + rectH / 2, 'open', { size: 10, fill: '#94a3b8' }); + }); + + dimH(svg, marginL, marginL + drawW, rectY + rectH + 18, ftIn(widthFt), { below: true }); + dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true }); + label(svg, W - marginR, marginTop - 8, 'cut at ' + ftIn(sec.x_ft), { anchor: 'end', size: 11, fill: '#6b7280' }); + + if (sec.thickness_in && (thicknessIn / 12) * scale * 4 > drawH + 2) { + label(svg, marginL, marginTop - 8, '(vertically exaggerated)', { anchor: 'start', size: 10, fill: '#6b7280' }); + } + } + + // ------------------------------------------------------------ freebody + + // A classic beam-style free-body diagram: the member drawn as ONE + // horizontal line regardless of its true (vertical) orientation, with + // restraints, point loads and wind-zone bands positioned along it by + // elevation. Matches the wide/short aspect every drawing block declares. + function renderFreebody(container, geom, aspect) { + geom = geom || {}; + aspect = aspect || 0.24; + var root = svgRoot(container, aspect); + var svg = root.svg, W = root.W, H = root.H; + + var bot = geom.bottom_elevation_ft, top = geom.top_elevation_ft; + if (bot === undefined || bot === null) bot = 0; + if (top === undefined || top === null || top <= bot) top = bot + 1; + + var marginL = 40, marginR = 40, marginTop = 60, marginBottom = 70; + var drawW = W - marginL - marginR; + var lineY = marginTop + (H - marginTop - marginBottom) / 2; + var scale = drawW / (top - bot); + function xAt(elev) { return marginL + (elev - bot) * scale; } + + el('line', { x1: marginL, y1: lineY, x2: marginL + drawW, y2: lineY, stroke: '#1b1e23', 'stroke-width': 3 }, svg); + dimH(svg, marginL, marginL + drawW, H - 14, ftIn(top - bot) + ' overall', { below: false, size: 11 }); + + // restraints, ABOVE the line (owner ruling, 2026-09-05: supports below, + // restraints above), staggered by ESTIMATED LABEL WIDTH (not a fixed + // guess) so a long "Floor tie @ 13'-2 1/2"" label never overlaps its + // neighbour the way a fixed half-width would on a tightly spaced run. + // Reserves a fixed ~34px band directly above the line; point loads + // (below) start their own arrows further up still, clear of this band. + var restraints = geom.restraints || []; + var rTexts = restraints.map(function (r) { return (r.type || '?') + ' @ ' + ftIn(r.elevation_ft); }); + var rItems = restraints.map(function (r, i) { + var x = xAt(r.elevation_ft); + var w = estWidth(rTexts[i], 10); + return { x0: x - w / 2, x1: x + w / 2 }; + }); + var rLevels = stagger(rItems, 6); + restraints.forEach(function (r, i) { + var x = xAt(r.elevation_ft); + var y0 = lineY; + var lvl = rLevels[i]; + var sym = el('g', {}, svg); + var type = (r.type || '').toLowerCase(); + if (type === 'pin') { + el('polygon', { points: [x - 7, y0 - 14, x + 7, y0 - 14, x, y0].join(' '), fill: '#fff', stroke: '#1b1e23' }, sym); + } else if (type === 'fixed') { + el('line', { x1: x - 9, y1: y0 - 10, x2: x + 9, y2: y0 - 10, stroke: '#1b1e23', 'stroke-width': 3 }, sym); + for (var h = -8; h <= 8; h += 4) { + el('line', { x1: x + h, y1: y0 - 10, x2: x + h - 4, y2: y0 - 18, stroke: '#1b1e23', 'stroke-width': 1 }, sym); + } + } else if (type === 'floor tie') { + el('circle', { cx: x, cy: y0 - 10, r: 5, fill: '#fff', stroke: '#1b1e23' }, sym); + el('line', { x1: x, y1: y0, x2: x, y2: y0 - 5, stroke: '#1b1e23' }, sym); + } else { // Free / unknown + el('circle', { cx: x, cy: y0 - 8, r: 3, fill: '#94a3b8' }, sym); + } + label(svg, x, lineY - 34 - lvl * 14, (r.type || '?') + ' @ ' + ftIn(r.elevation_ft), { size: 10 }); + }); + + // supports (bearing positions for THIS analysis), BELOW the line -- + // filled triangles, apex on the line, base below, one label each. + // Owner ruling, 2026-09-05: `support` table x_ft positions, already + // converted to this drawing's elevation axis by + // `pa_sketch_freebody._support_rows` (bottom_elevation_ft + x_ft). + var supports = geom.supports || []; + var sTexts = supports.map(function (s) { return 'Support @ ' + ftIn(s.elevation_ft); }); + var sItems = supports.map(function (s, i) { + var x = xAt(s.elevation_ft); + var w = estWidth(sTexts[i], 10); + return { x0: x - w / 2, x1: x + w / 2 }; + }); + var sLevels = stagger(sItems, 6); + supports.forEach(function (s, i) { + var x = xAt(s.elevation_ft); + var lvl = sLevels[i]; + el('polygon', { points: [x - 7, lineY + 14, x + 7, lineY + 14, x, lineY].join(' '), fill: '#1b1e23', stroke: '#1b1e23' }, svg); + label(svg, x, lineY + 34 + lvl * 14, sTexts[i], { size: 10, fill: '#1b1e23' }); + }); + + // point loads, above the line -- pushed clear of the restraint band. + var loads = geom.conc_loads || []; + var lTexts = loads.map(function (ld) { + return (ld.label || 'P') + ': D=' + (ld.dead_k == null ? '-' : ld.dead_k) + + 'k L=' + (ld.live_k == null ? '-' : ld.live_k) + 'k'; + }); + var lItems = loads.map(function (ld, i) { + var x = xAt(ld.elevation_ft); + var w = estWidth(lTexts[i], 10); + return { x0: x - w / 2, x1: x + w / 2 }; + }); + var lLevels = stagger(lItems, 6); + var loadTop = restraints.length ? 60 : 40; + loads.forEach(function (ld, i) { + var x = xAt(ld.elevation_ft); + var lvl = lLevels[i]; + var topY = lineY - loadTop - lvl * 26; + el('line', { x1: x, y1: topY, x2: x, y2: lineY - 4, stroke: '#2f6fed', 'stroke-width': 2, 'marker-end': null }, svg); + el('polygon', { points: [x - 4, lineY - 10, x + 4, lineY - 10, x, lineY - 2].join(' '), fill: '#2f6fed' }, svg); + label(svg, x, topY - 6, lTexts[i], { size: 10, fill: '#2f6fed' }); + }); + + // wind zones, distributed arrows over their extent, below loads / above line + var zones = geom.wind_zones || []; + zones.forEach(function (z, zi) { + var x0 = xAt(Math.min(z.from_elev_ft, z.to_elev_ft)); + var x1 = xAt(Math.max(z.from_elev_ft, z.to_elev_ft)); + var y = marginTop + 10 + zi * 16; + el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#16a34a', 'stroke-width': 1.5 }, svg); + var steps = Math.max(2, Math.round((x1 - x0) / 40)); + for (var s = 0; s <= steps; s++) { + var ax = x0 + (x1 - x0) * (s / steps); + el('line', { x1: ax, y1: y - 8, x2: ax, y2: y, stroke: '#16a34a', 'stroke-width': 1 }, svg); + } + var pieces = []; + if (z.pressure_psf !== null && z.pressure_psf !== undefined) pieces.push('p=' + z.pressure_psf + ' psf'); + if (z.suction_psf !== null && z.suction_psf !== undefined) pieces.push('s=' + z.suction_psf + ' psf'); + label(svg, (x0 + x1) / 2, y - 12, (z.label || 'Wind') + (pieces.length ? ' ' + pieces.join(', ') : '') + + (z.flagged ? ' (reversed)' : ''), { size: 10, fill: '#16a34a' }); + }); + + if ((!restraints.length) && (!supports.length) && (!loads.length) && (!zones.length)) { + label(svg, W / 2, lineY + 20, 'Nothing to draw yet -- add restraints, supports, loads or wind zones.', { size: 11, fill: '#6b7280' }); + } + } + + // ------------------------------------------------------ lifter/dunnage + + function renderLifterDunnage(container, geom, aspect) { + geom = geom || {}; + aspect = aspect || 0.26; + var root = svgRoot(container, aspect); + var svg = root.svg, W = root.W, H = root.H; + + if (geom.length_ft === null || geom.length_ft === undefined || + geom.width_ft === null || geom.width_ft === undefined) { + label(svg, W / 2, H / 2, 'Enter length and width to see lifter positions.', { size: 12, fill: '#6b7280' }); + return; + } + + var lengthFt = geom.length_ft, widthFt = geom.width_ft; + var marginL = 40, marginR = 40, marginTop = 40, marginBottom = 30; + var drawW = W - marginL - marginR; + var drawH = H - marginTop - marginBottom; + var scale = drawW / lengthFt; + var rectH = Math.min(drawH, widthFt * scale); + var rectY = marginTop + (drawH - rectH) / 2; + + function xAt(ft) { return marginL + ft * scale; } + function yAt(vertFt) { return rectY + rectH - vertFt * scale; } + + el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f8fafc', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg); + + (geom.openings || []).forEach(function (op) { + if (!op.along || !op.vert) return; + el('rect', { + x: xAt(op.along[0]), y: yAt(op.vert[1]), + width: Math.max(1, xAt(op.along[1]) - xAt(op.along[0])), + height: Math.max(1, yAt(op.vert[0]) - yAt(op.vert[1])), + fill: '#ffffff', stroke: '#94a3b8', 'stroke-dasharray': '3,2' + }, svg); + }); + + (geom.dunnage_positions_ft || []).forEach(function (d) { + var x = xAt(d); + el('line', { x1: x, y1: rectY - 6, x2: x, y2: rectY + rectH + 6, stroke: '#a855f7', 'stroke-dasharray': '5,3', 'stroke-width': 1.5 }, svg); + }); + + (geom.face_lifters || []).forEach(function (fl) { + var x = xAt(fl.x_ft), y = rectY + rectH * fl.frac; + el('circle', { cx: x, cy: y, r: 6, fill: '#1b1e23' }, svg); + }); + + // An 'end' lifter sits at a fixed x (a panel end) and is spread across + // the width by `frac`; a 'side' lifter sits at a fixed y (one of the + // long edges, named by `side`) and is spread along the length by + // `x_ft`. Conflating the two (deciding y from `side` alone) collapsed + // every 'end' lifter at the same end onto one point, since their + // distinguishing coordinate -- `frac` -- was never read for them. + (geom.edge_lifters || []).forEach(function (el2) { + var x, y, horiz; + if (el2.kind === 'end') { + x = xAt(el2.x_ft); + y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5); + horiz = true; // marker points along the length, toward the panel interior + } else { + x = xAt(el2.x_ft); + if (el2.side === 'top') { y = rectY; } + else if (el2.side === 'bottom') { y = rectY + rectH; } + else { y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5); } + horiz = false; + } + var inward = el2.x_ft > (geom.length_ft || 0) / 2 ? -1 : 1; + var downward = (el2.side === 'top') ? 1 : (el2.side === 'bottom' ? -1 : (y < rectY + rectH / 2 ? 1 : -1)); + var pts = horiz + ? [x, y - 6, x, y + 6, x + inward * 10, y].join(' ') + : [x - 6, y, x + 6, y, x, y + downward * 10].join(' '); + el('polygon', { points: pts, fill: el2.ambiguous ? '#f59e0b' : '#2f6fed' }, svg); + }); + + if (geom.centroid && geom.centroid.x_ft !== null && geom.centroid.x_ft !== undefined && + geom.centroid.z_ft !== null && geom.centroid.z_ft !== undefined) { + var cx = xAt(geom.centroid.x_ft), cy = yAt(geom.centroid.z_ft); + el('line', { x1: cx - 8, y1: cy, x2: cx + 8, y2: cy, stroke: '#d33', 'stroke-width': 1.5 }, svg); + el('line', { x1: cx, y1: cy - 8, x2: cx, y2: cy + 8, stroke: '#d33', 'stroke-width': 1.5 }, svg); + el('circle', { cx: cx, cy: cy, r: 9, fill: 'none', stroke: '#d33', 'stroke-width': 1 }, svg); + label(svg, cx, cy - 12, 'CG', { size: 10, fill: '#d33' }); + } + + dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt)); + dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true }); + } + + // --------------------------------------------------- reinforcement.outline + + // The Reinforcement page's PLAN view (issue #23 item 9i): the panel + // rectangle (length x width), strand lines along the length at their + // across-width x_in, the rail datum, and a per-group continuous dimension + // chain for strands (from the rail) and bars (from their own basis) plus a + // light hatched mesh sheet. No cut line here -- this is the plan half of + // the outline+section pair, not Concrete Extents' draggable outline. + function renderReinforcementOutline(container, geom, aspect) { + geom = geom || {}; + aspect = aspect || 0.44; + var root = svgRoot(container, aspect); + var svg = root.svg, W = root.W, H = root.H; + + var lengthFt = safeNum(geom.length_ft); + var widthFt = safeNum(geom.width_ft); + if (lengthFt === null || lengthFt <= 0 || widthFt === null || widthFt <= 0) { + label(svg, W / 2, H / 2, 'Enter length and width to see the reinforcement plan.', { size: 12, fill: '#6b7280' }); + return; + } + var widthIn = widthFt * 12; + + var marginL = 110, marginR = 28, marginTop = 40, marginBottom = 48; + var drawW = W - marginL - marginR; + var drawH = H - marginTop - marginBottom; + var scale = drawW / lengthFt; + var rectH = Math.min(drawH, widthFt * scale); + var rectY = marginTop + (drawH - rectH) / 2; + + function xAt(ft) { return marginL + ft * scale; } + function yAtIn(inches) { return rectY + rectH - (inches / 12) * scale; } + + el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg); + + dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt)); + dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true }); + + var rail = geom.rail; + var strands = geom.strands || []; + var bars = geom.bars || []; + var mesh = geom.mesh; + + // Strands -- thin lines along the length at their across-width position + // x_in (inches from the panel's left edge). A row with no resolved x_in + // spreads evenly across the width, the same fallback the section uses. + strands.forEach(function (s) { + var count = (s.count && s.count > 0) ? s.count : 1; + var xs = s.x_in; + if (xs && xs.length) { + xs.forEach(function (x) { + el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#1b1e23', 'stroke-width': 1.4 }, svg); + }); + } else { + for (var i = 0; i < count; i++) { + var x = widthIn * (i + 1) / (count + 1); + el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#94a3b8', 'stroke-width': 1, 'stroke-dasharray': '3,3' }, svg); + } + } + }); + + // Rail -- a dashed line at the rail side, offset by rail.offset_in. + if (rail && rail.location) { + var railPos = rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0); + railPos = Math.max(0, Math.min(widthIn, railPos)); + var ry = yAtIn(railPos); + el('line', { x1: marginL, y1: ry, x2: marginL + drawW, y2: ry, stroke: '#b45309', 'stroke-width': 1.6, 'stroke-dasharray': '6,4' }, svg); + label(svg, marginL + drawW + 4, ry - 4, 'rail (' + rail.location + ')', { anchor: 'start', size: 9, fill: '#b45309' }); + } + + // Strand group chain -- vertical, from the rail outward, one cell per + // chain_in length, on its own column left of the rectangle. + var chainRow = null; + for (var i = 0; i < strands.length; i++) { + if (strands[i] && strands[i].chain_in && strands[i].chain_in.length && + strands[i].x_in && strands[i].x_in.length) { chainRow = strands[i]; break; } + } + var strandChainX = marginL - 42; + if (chainRow && rail && rail.location) { + label(svg, strandChainX, rectY - 6, 'strands', { anchor: 'middle', size: 10, fill: '#1b1e23' }); + var cells = chainCellsFromRail(chainRow.x_in, chainRow.chain_in, + rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0)); + cells.forEach(function (c) { + dimV(svg, yAtIn(c.hi), yAtIn(c.lo), strandChainX, inch(c.label), { size: 9 }); + }); + } + + // Bars -- longitudinal bars run along the length (horizontal lines at + // their depth_in); transverse bars run across the width (vertical lines + // at their from_ft station). + var longitBars = [], transBars = []; + bars.forEach(function (b) { + if (b.depth_in === null || b.depth_in === undefined) return; + if ((b.direction || '').toLowerCase().indexOf('trans') === 0) transBars.push(b); + else longitBars.push(b); + }); + longitBars.forEach(function (b) { + var y = yAtIn(b.depth_in); + var x0 = (b.from_ft !== null && b.from_ft !== undefined) ? xAt(b.from_ft) : marginL; + var x1 = (b.to_ft !== null && b.to_ft !== undefined) ? xAt(b.to_ft) : marginL + drawW; + el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#2f6fed', 'stroke-width': 1.4 }, svg); + }); + transBars.forEach(function (b) { + var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft + : ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2); + el('line', { x1: xAt(xv), y1: rectY, x2: xAt(xv), y2: rectY + rectH, stroke: '#2f6fed', 'stroke-width': 1.2, 'stroke-dasharray': '5,3' }, svg); + }); + + // Bar group chain -- from each bar's own reference. Longitudinal bars + // measure their depth up from the panel's bottom edge (a vertical chain + // on its own column); transverse bars measure their station along the + // length (a horizontal chain stacked below the rectangle). + var barChainX = marginL - 74; + if (longitBars.length) { + label(svg, barChainX, rectY - 6, 'bars', { anchor: 'middle', size: 10, fill: '#2f6fed' }); + } + longitBars.forEach(function (b) { + dimV(svg, yAtIn(b.depth_in), rectY + rectH, barChainX, (b.size || 'bar') + ' longit.', { size: 9 }); + }); + transBars.forEach(function (b, ti) { + var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft + : ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2); + var by = rectY + rectH + 20 + ti * 16; + if (ti === 0 && !longitBars.length) { + label(svg, marginL, by - 4, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' }); + } + dimH(svg, marginL, xAt(xv), by, (b.size || 'bar') + ' transv.', { size: 9, below: true }); + }); + + // Mesh -- a light hatched sheet over the panel, labelled once. + if (mesh && mesh.designation) { + var pid = 'reinout-hatch'; + var defs = el('defs', {}, svg); + var pat = el('pattern', { id: pid, patternUnits: 'userSpaceOnUse', width: 7, height: 7 }, defs); + el('path', { d: 'M0 7 L7 0', stroke: '#94a3b8', 'stroke-width': 0.8 }, pat); + el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: 'url(#' + pid + ')', opacity: 0.22 }, svg); + var meshText = 'mesh: ' + mesh.designation; + if (mesh.layers !== null && mesh.layers !== undefined) meshText += ' \u00d7 ' + mesh.layers; + if (mesh.depth_in !== null && mesh.depth_in !== undefined) meshText += ' @ ' + inch(mesh.depth_in); + label(svg, marginL + 6, rectY + 14, meshText, { anchor: 'start', size: 9, fill: '#334155' }); + } + } + + // --------------------------------------------------- reinforcement.section + + function renderReinforcementSection(container, geom, aspect) { + geom = geom || {}; + aspect = aspect || 0.28; + var root = svgRoot(container, aspect); + var svg = root.svg, W = root.W, H = root.H; + + var widthFt = geom.width_ft || FALLBACK_WIDTH_FT; + var thicknessIn = geom.thickness_in || 8; + + var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 128; + var drawW = W - marginL - marginR; + var drawH = H - marginTop - marginBottom; + var scaleX = drawW / widthFt; + var exaggerate = Math.max(1, Math.min(6, drawH / ((thicknessIn / 12) * scaleX || 1))); + var rectH = (thicknessIn / 12) * scaleX * exaggerate; + if (rectH > drawH) { exaggerate *= drawH / rectH; rectH = drawH; } + var rectY = marginTop + (drawH - rectH) / 2; + + function xAt(ft) { return marginL + ft * scaleX; } + function xAtIn(inches) { return marginL + (inches / 12) * scaleX; } + function yAtDepth(depthIn) { return rectY + (depthIn / 12) * scaleX * exaggerate; } + + el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.2 }, svg); + + // strands: filled circles, x from strand_x_in flattened in strand order + var xs = geom.strand_x_in; + var flatIdx = 0; + var strandItems = []; + (geom.strands || []).forEach(function (s) { + for (var i = 0; i < (s.count || 0); i++) { + var xIn; + if (xs && !geom.used_fallback_strand_x && xs.length > flatIdx) { + xIn = xs[flatIdx]; + } else { + xIn = null; // filled in after we know the total count + } + strandItems.push({ xIn: xIn, depth: s.depth_in, dia: s.dia_in, id: s.id }); + flatIdx++; + } + }); + if (geom.used_fallback_strand_x || !xs) { + var n = strandItems.length; + strandItems.forEach(function (it, i) { + it.xIn = n > 1 ? (widthFt * 12) * (i + 0.5) / n : (widthFt * 12) / 2; + }); + } + strandItems.forEach(function (it) { + var x = xAt(it.xIn / 12), y = yAtDepth(it.depth); + el('circle', { cx: x, cy: y, r: 5, fill: '#1b1e23' }, svg); + }); + if (geom.used_fallback_strand_x) { + label(svg, marginL, rectY + rectH + 112, '(strand positions spread evenly -- no explicit across-width layout entered)', { anchor: 'start', size: 10, fill: '#6b7280' }); + } + + // mild bars -- owner ruling 2026-09-05, matching `pa_sketch_reinf.py`'s + // own (already-correct) WPF picture: a LONGITUDINAL bar runs along the + // panel's LENGTH, so this cross-section (cut across the WIDTH) sees it + // END-ON -- one open circle per bar, at its own across-width position + // (the row's own `spacing_in` pitch when it is a real number, else + // spread evenly). A TRANSVERSE bar runs ACROSS the width, so the same + // cut sees it ALONG its own length -- one dashed line spanning the + // full section width at its depth, labelled once (e.g. '#4 @ 19" + // transv.'), never a row of circles the way this used to draw it + // (backwards from the physical picture -- see the fix's own note). + // Two bars whose depths sit within a fraction of an inch of each other + // (common: a top mat and a bottom mat of two different sizes) can land + // within a few px once exaggerated -- cascade the label sideways in + // that case, computed on DEPTH-SORTED groups (never original array + // order, which can interleave the two directions and miss an adjacent + // pair, or falsely flag two non-adjacent ones) and kept separate per + // direction since they print on opposite sides of the strip. + var allBars = geom.bars || []; + function isTransverse(b) { return (b.direction || '').toLowerCase().indexOf('trans') === 0; } + function cascadeOffsets(list) { + var withY = list.map(function (b) { return { id: b.id, y: yAtDepth(b.depth_in) }; }) + .sort(function (a, b) { return a.y - b.y; }); + var offsets = {}; var lastY = null, c = 0; + withY.forEach(function (it) { + c = (lastY !== null && Math.abs(it.y - lastY) < 11) ? c + 1 : 0; + lastY = it.y; + offsets[it.id] = c * 34; + }); + return offsets; + } + var transverseOffsets = cascadeOffsets(allBars.filter(isTransverse)); + var longitOffsets = cascadeOffsets(allBars.filter(function (b) { return !isTransverse(b); })); + + allBars.forEach(function (b) { + var y = yAtDepth(b.depth_in); + if (isTransverse(b)) { + el('line', { x1: marginL + 4, y1: y, x2: marginL + drawW - 4, y2: y, stroke: '#2f6fed', 'stroke-width': 1, 'stroke-dasharray': '6,3' }, svg); + var spacingText = b.spacing_in ? ' @ ' + Math.round(b.spacing_in * 100) / 100 + '"' : ''; + label(svg, marginL + drawW - 6 - (transverseOffsets[b.id] || 0), y - 4, (b.size || '') + spacingText + ' transv.', { anchor: 'end', size: 9, fill: '#2f6fed' }); + } else { + var count = b.count && b.count > 0 ? b.count : 1; + var dotCount = Math.min(count, 20); + var xs = []; + var innerW = drawW - 8; + if (b.spacing_in && b.spacing_in > 0) { + var pitchPx = (b.spacing_in / 12) * scaleX; + for (var x = marginL + 4 + pitchPx; x < marginL + 4 + innerW && xs.length < dotCount; x += pitchPx) xs.push(x); + } + if (!xs.length) { + for (var i = 0; i < dotCount; i++) xs.push(marginL + 4 + innerW * (i + 1) / (dotCount + 1)); + } + xs.forEach(function (cx) { + el('circle', { cx: cx, cy: y, r: 4, fill: '#fff', stroke: '#2f6fed', 'stroke-width': 1.4 }, svg); + }); + label(svg, marginL + 4 + (longitOffsets[b.id] || 0), y - 6, (b.size || '') + ' longit.', { anchor: 'start', size: 9, fill: '#2f6fed' }); + } + }); + + // Per-group dimension chains along the section's width (issue #23 item + // 9i): strands from the rail, bars from their own basis, stacked on + // their own rows below the overall width dimension so they never overlap. + var widthInSec = widthFt * 12; + var chainRow = null; + for (var ci = 0; ci < (geom.strands || []).length; ci++) { + var sr = geom.strands[ci]; + if (sr && sr.chain_in && sr.chain_in.length && sr.x_in && sr.x_in.length) { + chainRow = sr; + break; + } + } + if (chainRow) { + var cxs = chainRow.x_in, ccs = chainRow.chain_in; + var railPos = (cxs[0] <= cxs[cxs.length - 1]) + ? (cxs[0] - ccs[0]) : (cxs[0] + ccs[0]); + railPos = Math.max(0, Math.min(widthInSec, railPos)); + label(svg, marginL, rectY + rectH + 44, 'strands', { anchor: 'start', size: 10, fill: '#1b1e23' }); + chainCellsFromRail(cxs, ccs, railPos).forEach(function (c) { + dimH(svg, xAtIn(c.lo), xAtIn(c.hi), rectY + rectH + 48, inch(c.label), { size: 9, below: true }); + }); + } + + var longitBarRows = allBars.filter(function (b) { return !isTransverse(b); }); + var transBarRows = allBars.filter(isTransverse); + var barChainY = rectY + rectH + 74; + var barRow = 0; + if (longitBarRows.length || transBarRows.length) { + label(svg, marginL, barChainY, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' }); + } + longitBarRows.forEach(function (b) { + if (!b.spacing_in || b.spacing_in <= 0) return; + var x1 = Math.min(marginL + drawW, xAtIn(b.spacing_in)); + dimH(svg, marginL, x1, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' longit.', { size: 9, below: true }); + barRow += 1; + }); + transBarRows.forEach(function (b) { + dimH(svg, marginL, marginL + drawW, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' transv.', { size: 9, below: true }); + barRow += 1; + }); + + dimH(svg, marginL, marginL + drawW, rectY + rectH + 16, ftIn(widthFt), { below: true }); + dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true }); + if (exaggerate > 1.05) { + label(svg, marginL, marginTop - 8, '(depth exaggerated ' + exaggerate.toFixed(1) + '×)', { anchor: 'start', size: 10, fill: '#6b7280' }); + } + label(svg, marginL + drawW, marginTop - 8, '● strand ○ bar', { anchor: 'end', size: 10, fill: '#6b7280' }); + } + + window.Draw = { + sectionAt: sectionAt, + renderOutline: renderOutline, + renderSection: renderSection, + renderFreebody: renderFreebody, + renderLifterDunnage: renderLifterDunnage, + renderReinforcementOutline: renderReinforcementOutline, + renderReinforcementSection: renderReinforcementSection, + _stagger: stagger, + svgRoot: svgRoot, + label: label, + dimH: dimH, + dimV: dimV, + ftIn: ftIn, + inch: inch, + el: el, + clear: clear, + safeNum: safeNum, + clamp: clamp, + mergeRanges: mergeRanges, + estWidth: estWidth + }; +})(); diff --git a/tools/pa_web/static/index.html b/tools/pa_web/static/index.html new file mode 100644 index 0000000..d90dd96 --- /dev/null +++ b/tools/pa_web/static/index.html @@ -0,0 +1,19 @@ + +Panel Analysis + +
              +
              +
              Panel Analysis
              +
              + + +
              + + +
              +
              +
              + + + + diff --git a/tools/pa_web/static/units.js b/tools/pa_web/static/units.js new file mode 100644 index 0000000..df484be --- /dev/null +++ b/tools/pa_web/static/units.js @@ -0,0 +1,246 @@ +// Feet-inches formatting and parsing -- the browser twin of +// `panellift/src/panellift/units.py`. Same rules, same rounding, same +// accepted input forms; kept as a byte-for-byte port on purpose so a value +// typed on the page and the same value printed by the CPython report never +// disagree. See units.py's own module docstring for the owner's ruling this +// exists to satisfy (2026-09-05: every length on any front-end or results +// page is feet-inches, rounded to the nearest 1/8", never a decimal). +// +// Plain ES2017, no modules -- loaded with a bare '.format( + mark=R.esc(panel.mark), css=R.CSS, extra=EXTRA_CSS, body=body, + hover=R.hover_script(), js=EDIT_JS)) + + +# ------------------------------------------------------------------- CLI + +def main(argv=None): + ap = argparse.ArgumentParser( + description='Render a panel-analysis demo page for one job deck.') + ap.add_argument('deck', help='path to a *.job.json deck') + ap.add_argument('-o', '--out', required=True, help='output .html path') + ap.add_argument('--spec', default=None, + help='design-extract YAML to use instead of the ' + 'auto-located one') + ap.add_argument('--open', action='store_true', + help='open the rendered page in a browser') + args = ap.parse_args(argv) + + html = build_page(args.deck, spec_override=args.spec) + with io.open(args.out, 'w', encoding='utf-8') as fh: + fh.write(html) + print('wrote {0}'.format(args.out)) + if args.open: + webbrowser.open('file://' + os.path.abspath(args.out)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/panelanalysis_schema.py b/tools/panelanalysis_schema.py new file mode 100644 index 0000000..ba2ced3 --- /dev/null +++ b/tools/panelanalysis_schema.py @@ -0,0 +1,1149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""The Panel Analysis deck schema -- `panelanalysis/1`, and how it maps. + +WHY A SECOND SCHEMA. `panellift/1` (see `panellift.cli`'s docstring) is a +HANDLING job: a panel, a rig, and PCI Table 8.3.1 stages. Panel Analysis is a +different report -- Eriksson Wall's structural analysis of a wall panel -- and +it is organised the way Eriksson organises its own input, in named blocks, so +that later stages can fill each block in without re-shaping the file: + + project_information mark, design number, materials (f'c / f'ci), losses. + settings analysis options: stress limits, resistance factors, + development multipliers. Seeded from knowledge in a + later stage; a Stage 0 fixture hand-authors them. + concrete_extents the panel as a section: length / width / thickness, + finish, product type, and the openings. Section + properties come off this at L/10, Eriksson's own + convention. + structural_model how the piece is held. `supports` is a list of + {x_ft, type} -- the dunnage / bearing lines the + Handling beam spans. `restraints` and `bearing` are + seeded from clash detection in a later stage (Stage + 1); here they carry an explicit null + reason. + reinforcement `prestress`: the strands, exactly the shape + `panellift.cli.read_prestress` already reads. + loading `self_weight` is the only real load now (a factor and + an on/off); `wind` and `combinations` are the + in-place engine (Stage 3) and carry null + reason. + handling the Handling result block -- self-weight beam on the + `structural_model.supports`. Computed and rendered by + `panelanalysis.py`; in a deck it is an empty result + placeholder the tool fills. + in_place the In-Place result block -- panel on pin supports + under lateral + gravity. NOT built (Stage 3): an + explicit null + reason, never fabricated zeros. + source provenance. For a Stage 0 fixture it is synthetic and + says so. + +THE FILE CONTRACT, unchanged from `panellift`: a field that was gathered but +not read is an EXPLICIT null carrying its reason, never a silent omission and +never a fabricated value. `unread(reason)` is the one shape those take -- +`{"value": null, "reason": "..."}` -- so a reader (and a test) can tell "nobody +has wired this yet" apart from "this is genuinely empty". See +`willtools-error-patterns` / the whole of CLAUDE.md sec.3 on why a silent zero +is the cardinal sin here. + +HOW IT REACHES THE ENGINE. Nothing in this file re-implements any mechanics. +`to_panel()` translates the blocks into the panel dict `panellift.cli. +read_panel` already validates, so every refusal that function makes (an empty +strand list, a strand with no pull, a bad number) is made here too, unchanged. +`handling_stage()` builds a `panellift.model.Stage` whose `supports` are the +`structural_model.supports` x-positions -- which is exactly the dunnage path +`panellift.statics.analyse` already solves as a continuous beam on rigid +supports. The support `type` (pinned / roller / fixed) is carried for the page +to show; the Handling solve treats every support as a rigid line, which is what +that path is. +""" +from __future__ import annotations + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, os.pardir)) +PANELLIFT_SRC = os.path.join(REPO, 'panellift', 'src') +if PANELLIFT_SRC not in sys.path: + sys.path.insert(0, PANELLIFT_SRC) + +from panellift import cli as pl_cli # noqa: E402 +from panellift import inplace # noqa: E402 +from panellift import windload # noqa: E402 +from panellift.model import Reinforcement, Rigging, Stage # noqa: E402 + +# The wind PRECEDENCE (direct > computed > blank) is the form's pure logic, +# host-tested in `vibe.panelanalysisform`; reuse it here rather than restating +# it. The `vibe` stub is already registered by `deckcheck` (imported first in +# `panelanalysis.py`) and by the test conftest, so this bare import resolves the +# Revit-free module the same way the tests do. +from vibe import panelanalysisform as _paf # noqa: E402 + + +SCHEMA = 'panelanalysis/1' + +# The blocks, in the order the page prints them. Named here so the tool and a +# test can agree on the set without either one hardcoding a list of strings. +BLOCKS = ( + 'project_information', + 'settings', + 'concrete_extents', + 'structural_model', + 'reinforcement', + 'loading', + 'handling', + 'in_place', + 'source', +) + +# The default handling load factor. 1.0 is the bare self weight -- the Handling +# model is "the panel's own weight on its dunnage / bearing lines", and a stage +# that wants PCI Table 8.3.1's stripping/transport factor states it in +# `loading.self_weight.load_factor` rather than having one guessed here. +DEFAULT_SELF_WEIGHT_FACTOR = 1.0 + + +class DeckError(ValueError): + """The panelanalysis deck does not describe a panel that can be analysed. + + A subclass of ValueError, so a caller that already catches the ValueError + `panellift` raises (the <2-supports refusal) catches this the same way. + """ + + +def unread(reason, value=None): + """The one shape a gathered-but-unread field takes: null + why. + + `{"value": None, "reason": "..."}`. A reader tells this apart from a field + nobody thought of (absent) and from a real empty value; a test asserts the + reason is present rather than that the key merely exists. `value` is kept + so a partially-known field can carry what little is known beside the reason, + but for Stage 0 it is None everywhere. + """ + return {'value': value, 'reason': str(reason)} + + +# ----------------------------------------------------------------- reading + +def _block(deck, name): + block = deck.get(name) + if block is None: + return {} + if not isinstance(block, dict): + raise DeckError('block {0!r} must be a mapping, got {1!r}' + .format(name, block)) + return block + + +def check_schema(deck): + """Refuse a deck that is not this schema, by name. Absent is allowed. + + A file with no `schema` key is taken as this one (the same leniency + `panellift` gives its own legacy files); a file naming a DIFFERENT schema + is refused rather than misread -- a `panellift/1` job has no + `structural_model` block and reading it here would silently analyse a piece + on no supports. + """ + schema = deck.get('schema') + if schema and schema != SCHEMA: + raise DeckError('deck says schema {0!r}; this reads {1!r}' + .format(schema, SCHEMA)) + + +def to_panel(deck): + """A `panellift.model.Panel` from the deck's blocks. + + Built by translating the blocks into the panel dict `read_panel` already + validates, so every refusal that function makes is made here unchanged -- + this file adds no second opinion about what a valid panel is. + """ + pi = _block(deck, 'project_information') + ce = _block(deck, 'concrete_extents') + rf = _block(deck, 'reinforcement') + panel_dict = { + 'mark': pi.get('mark'), + 'design_number': pi.get('design_number'), + 'length_ft': ce.get('length_ft'), + 'width_ft': ce.get('width_ft'), + 'thickness_in': ce.get('thickness_in'), + 'unit_weight_pcf': pi.get('unit_weight_pcf', 150.0), + 'fc_psi': pi.get('fc_psi'), + 'fci_psi': pi.get('fci_psi'), + 'weight_lb': ce.get('weight_lb'), + 'extra_cast_ft3': ce.get('extra_cast_ft3') or 0.0, + 'finish': ce.get('finish') or 'smooth', + 'product_type': ce.get('product_type') or 'flat', + 'blockouts': ce.get('blockouts') or [], + 'recesses': ce.get('recesses') or [], + # The Handling model here is a beam on dunnage supports, not a pick, so + # the panel carries no lifters -- the supports live on the stage. + 'lifters': [], + 'prestress': rf.get('prestress'), + 'source': deck.get('source') or {}, + } + try: + panel = pl_cli.read_panel(panel_dict) + except pl_cli.JobError as exc: + # Re-raise as our own type so a caller catches one exception, but keep + # the message -- it names the exact field `read_panel` refused. + raise DeckError(str(exc)) + # Mild (non-prestressed) reinforcement, so the flexure check has a mild + # capacity path for a rebar wall panel. `read_panel` does not read it -- + # `panellift.cli` is strand-only by construction and a test pins that -- so + # the deck's mild layers are attached here, onto `Panel.reinforcement` + # (which the model already carries and `capacity.mild_flexure_at` reads). + # The strand path is untouched; a pure-strand or bare deck gets []. + panel.reinforcement = mild_reinforcement(deck) + mild_bar_reinforcement(deck) + return panel + + +def mild_reinforcement(deck): + """[`model.Reinforcement`] -- the deck's mild bar / mesh layers, or []. + + Read off `reinforcement.mild`, a list of layers each + `{area_in2, z_in, grade_ksi, count?, kind?, label?}`. `area_in2` is the + area of ONE bar and `count` how many are in the layer (total = area*count, + the shape `model.Reinforcement` already uses); `z_in` is the layer's depth + up from the form face, the same axis the strands use, so `capacity` can + compare it against a neutral axis directly. `grade_ksi` is the yield f_y. + + A missing block is a piece with no mild steel and returns [] -- NOT an + error, the ordinary case for a prestressed piece. A layer missing a + required number is refused loudly, the same discipline `read_panel` uses, + rather than silently dropped (a dropped tension layer overstates nothing + but understates capacity, which is a FAIL the section does not have). + """ + rf = _block(deck, 'reinforcement') + items = rf.get('mild') + if items is None: + return [] + if not isinstance(items, list): + raise DeckError('reinforcement.mild must be a list of layers, got ' + '{0!r}'.format(items)) + out = [] + for i, r in enumerate(items): + at = 'reinforcement.mild[{0}]'.format(i) + if not isinstance(r, dict): + raise DeckError('{0} must be a mapping, got {1!r}'.format(at, r)) + for key in ('area_in2', 'z_in', 'grade_ksi'): + if r.get(key) is None: + raise DeckError('{0} has no {1!r}; a mild layer needs area_in2 ' + '(per bar), z_in (depth up from the form face) ' + 'and grade_ksi (f_y)'.format(at, key)) + try: + out.append(Reinforcement( + kind=str(r.get('kind') or 'bar'), + label=str(r.get('label') or 'M{0}'.format(i + 1)), + area_in2=float(r['area_in2']), + z_in=float(r['z_in']), + grade_ksi=float(r['grade_ksi']), + count=int(r.get('count', 1)))) + except (TypeError, ValueError) as exc: + raise DeckError('{0}: a mild-layer field is not a number ({1})' + .format(at, exc)) + return out + + +#: `settings.rebar_fy_ksi`'s default -- Grade 60, `vibe.panelanalysisform`'s +#: own default for the same field ('60'). `reinforcement.mild_bars` carries no +#: per-bar grade (PANELANALYSIS-4's shape names size/count/area/depth/ +#: direction/from/to only), so every bar in that block shares this ONE deck- +#: wide setting rather than each guessing its own. +DEFAULT_MILD_BAR_GRADE_KSI = 60.0 + + +def mild_bar_grade_ksi(deck): + """The f_y every `reinforcement.mild_bars` layer is credited at.""" + return _settings_number(deck, 'rebar_fy_ksi', DEFAULT_MILD_BAR_GRADE_KSI) + + +def mild_bars(deck): + """[{size, count, area_in2, depth_in, direction, from_ft, to_ft}] -- + `reinforcement.mild_bars`, AS ENTERED, or [] for a deck with no block. + + This is the raw per-row read the page prints (every row, whether or not + it ends up in the capacity); `mild_bar_reinforcement` is the narrower + filter that turns the eligible rows into `model.Reinforcement` layers. + `count` and `area_in2` (the layer's TOTAL area, already count-multiplied + -- the field model's own convention, unlike `reinforcement.mild`'s + per-bar `area_in2`) are required; `depth_in` may be null (a bar the + field model could not measure a depth for), `from_ft`/`to_ft` may be null + (present over the whole length) -- neither is refused, both travel as + None. `direction` defaults to 'Longitudinal' when a row omits it, the + common case for a deck built before this field existed on the producer + side. + """ + rf = _block(deck, 'reinforcement') + items = rf.get('mild_bars') + if items is None: + return [] + if not isinstance(items, list): + raise DeckError('reinforcement.mild_bars must be a list of bar ' + 'layers, got {0!r}'.format(items)) + out = [] + for i, r in enumerate(items): + at = 'reinforcement.mild_bars[{0}]'.format(i) + if not isinstance(r, dict): + raise DeckError('{0} must be a mapping, got {1!r}'.format(at, r)) + for key in ('count', 'area_in2'): + if r.get(key) is None: + raise DeckError('{0} has no {1!r}; a mild bar layer needs ' + 'count and area_in2 (the layer\'s TOTAL area)' + .format(at, key)) + try: + out.append({ + 'size': str(r.get('size') or ''), + 'count': int(r['count']), + 'area_in2': float(r['area_in2']), + 'depth_in': _num_or_none(r.get('depth_in')), + 'direction': str(r.get('direction') or 'Longitudinal'), + 'from_ft': _num_or_none(r.get('from_ft')), + 'to_ft': _num_or_none(r.get('to_ft')), + }) + except (TypeError, ValueError) as exc: + raise DeckError('{0}: a mild-bar field is not a number ({1})' + .format(at, exc)) + return out + + +def mild_bar_reinforcement(deck): + """[model.Reinforcement] -- the LONGITUDINAL `mild_bars` rows WITH a + depth, ready for `capacity.mild_flexure_at`'s strand-and-mild strain + compatibility (it already combines strand + mild layers on + `panel.reinforcement`; this only has to populate that list correctly). + + A row is excluded (contributes nothing here) when EITHER: + * `direction` is not 'Longitudinal' -- a Transverse bar is listed on + the page but never enters the out-of-plane flexural capacity, the + wrong axis for it to help this check; + * `depth_in` is None -- the field model could not measure one, and a + capacity is never assumed from an unmeasured depth. The page reports + this row as "not in the capacity (no depth given)"; `mild_bars` + carries it, this function does not. + + A deck with no `reinforcement.mild_bars` block at all (every deck before + PANELANALYSIS-4, including the handling goldens) returns [] and changes + NOTHING -- `to_panel` concatenates this with `mild_reinforcement(deck)`, + and an empty list added to `panel.reinforcement` is the identity. + `area_in2` here is already the layer's TOTAL area (`mild_bars`'s own + convention), so it is carried straight across with `count=1` -- + `Reinforcement.total_area_in2` is `area_in2 * count`, and `count=1` + keeps that total exactly what the deck stated rather than multiplying it + a second time by the bar count. + """ + fy = mild_bar_grade_ksi(deck) + out = [] + for i, b in enumerate(mild_bars(deck)): + if b['direction'] != 'Longitudinal' or b['depth_in'] is None: + continue + out.append(Reinforcement( + kind='bar', label=b['size'] or 'MB{0}'.format(i + 1), + area_in2=b['area_in2'], z_in=b['depth_in'], grade_ksi=fy, + count=1, from_ft=b['from_ft'], to_ft=b['to_ft'])) + return out + + +def supports(deck): + """[(x_ft, type)], in the order written. The Handling beam's supports. + + `type` is carried for the page; the solve treats every one as a rigid + line. A missing/short list is NOT refused here -- the refusal belongs to + the solve, which raises `panellift`'s own "a supported stage needs at least + two supports" so the page can surface that exact sentence as NOT JUDGED + rather than this file inventing a parallel message. + """ + sm = _block(deck, 'structural_model') + out = [] + for i, s in enumerate(sm.get('supports') or []): + if not isinstance(s, dict): + raise DeckError('structural_model.supports[{0}] must be a mapping ' + 'with an "x_ft", got {1!r}'.format(i, s)) + if s.get('x_ft') is None: + raise DeckError('structural_model.supports[{0}] has no "x_ft"' + .format(i)) + try: + x = float(s['x_ft']) + except (TypeError, ValueError): + raise DeckError('structural_model.supports[{0}].x_ft is not a ' + 'number ({1!r})'.format(i, s['x_ft'])) + out.append((x, str(s.get('type') or 'support'))) + return out + + +def self_weight_factor(deck): + """The Handling load factor from `loading.self_weight`, or the default.""" + sw = (_block(deck, 'loading').get('self_weight')) or {} + if not isinstance(sw, dict): + return DEFAULT_SELF_WEIGHT_FACTOR + factor = sw.get('load_factor') + if factor is None: + return DEFAULT_SELF_WEIGHT_FACTOR + try: + return float(factor) + except (TypeError, ValueError): + raise DeckError('loading.self_weight.load_factor is not a number ' + '({0!r})'.format(factor)) + + +def handling_fc_psi(panel): + """The concrete strength the Handling model is checked at. + + f'ci -- the release strength -- when the deck states one, because handling + (stripping, yard) happens on young concrete and that is the conservative + of the two. f'c otherwise. Returned separately from the panel so the caller + can label the page with which one it used, and so the capacity table can + also run the 28-day strength beside it. + """ + return panel.fci_psi or panel.fc_psi or 0.0 + + +# The stress limits are a JOB decision resolved through the knowledge layers +# (see `Stage.tension_limit_sqrt_fc` / `.compression_limit_ratio`), and the +# deck carries whatever they resolved to in its `settings` block. Until now +# nothing read them; the Handling solve built a Stage with the panellift +# DEFAULTS (5.0, 0.6) even when the deck plainly stated otherwise. These pull +# them off `settings`, falling back to the panellift default when the deck is +# silent -- so a deck with no settings behaves exactly as before. +DEFAULT_TENSION_LIMIT_SQRT_FC = 5.0 +DEFAULT_COMPRESSION_LIMIT_RATIO = 0.6 + + +def _settings_number(deck, key, default): + st = _block(deck, 'settings').get(key) + if st is None: + return default + try: + return float(st) + except (TypeError, ValueError): + raise DeckError('settings.{0} is not a number ({1!r})'.format(key, st)) + + +def tension_limit_sqrt_fc(deck): + return _settings_number(deck, 'tension_limit_sqrt_fc', + DEFAULT_TENSION_LIMIT_SQRT_FC) + + +def compression_limit_ratio(deck): + return _settings_number(deck, 'compression_limit_ratio', + DEFAULT_COMPRESSION_LIMIT_RATIO) + + +def development_length_multiplier(deck): + """Eriksson's 'Development Multipliers' setting -- 1.0 unless stated. + + It lengthens the transfer/development region the phi ramp reads across, so + a deck that carries 1.3 holds phi below 0.90 further from the piece ends. + """ + return _settings_number(deck, 'development_length_multiplier', 1.0) + + +def _stage(deck, panel, name, pci_case, multiplier, fc_psi): + """A supported Stage carrying THIS deck's stress limits, not the defaults. + + `tension_limit_source` / `compression_limit_source` are 'job' when the deck + stated a limit and 'default' when it did not, exactly the provenance + `Stage` documents -- so the page can say who set the allowable it printed. + """ + st = _block(deck, 'settings') + xs = [x for x, _type in supports(deck)] + return Stage( + name=name, multiplier=multiplier, fc_psi=fc_psi, pci_case=pci_case, + supports=xs, + tension_limit_sqrt_fc=tension_limit_sqrt_fc(deck), + tension_limit_source=('job' if st.get('tension_limit_sqrt_fc') + is not None else 'default'), + compression_limit_ratio=compression_limit_ratio(deck), + compression_limit_source=('job' if st.get('compression_limit_ratio') + is not None else 'default')) + + +def handling_stage(deck, panel): + """A `panellift.model.Stage` for the self-weight Handling solve. + + `supports` set means `panellift.statics.analyse` runs the dunnage path: a + continuous beam on rigid supports, self weight only. The x-positions come + straight off `structural_model.supports`; nothing else about the stage + matters to the solve except `fc_psi`, which sets the elastic modulus for + the (indeterminate, 3+-support) load distribution and the strength the + capacity columns are measured at. + + The multiplier is `loading.self_weight.load_factor` (the deck's own + stripping factor), the strength is f'ci, and the stress limits are the + deck's settings -- see `_stage`. Kept as the single-stage entry point the + page's Handling section and the parity harness already call; the two-stage + default is `handling_stages`. + """ + return _stage(deck, panel, name='handling (self weight)', + pci_case='stripping', multiplier=self_weight_factor(deck), + fc_psi=handling_fc_psi(panel)) + + +# PCI/Eriksson default handling stages (docs/eriksson-wall-algorithms.md sec.7, +# QC p.133 `gamma_strip=1.4, gamma_transport=1.5, gamma_erection=1.2`): +# stripping at the release strength f'ci with the stripping dynamic factor, +# transportation AND erection at the 28-day strength f'c with their own +# factors. Overridable per deck from the `handling` block -- a `handling.stages` +# list of {name, pci_case, multiplier, strength} entries, or +# `handling.stripping_multiplier` / `.transport_multiplier` / `.erection_ +# multiplier` to nudge just the factors. Defaults per the reference tool. +DEFAULT_STRIPPING_MULTIPLIER = 1.4 +DEFAULT_TRANSPORT_MULTIPLIER = 1.5 +DEFAULT_ERECTION_MULTIPLIER = 1.2 + + +def _strength_for(panel, which): + """'release' -> f'ci (falling back to f'c); '28day'/'service' -> f'c.""" + if which in ('release', 'stripping', 'fci'): + return panel.fci_psi or panel.fc_psi or 0.0 + return panel.fc_psi or panel.fci_psi or 0.0 + + +def handling_stages(deck, panel): + """The handling stages to run, each a `Stage` carrying the deck's limits. + + Default is the two Eriksson runs -- Stripping x1.4 at f'ci, Transportation + x1.5 at f'c -- and the `loading.self_weight.load_factor`, if the deck + states one, overrides the stripping multiplier so a deck hand-tuned for one + factor still drives it. A `handling.stages` list replaces the defaults + outright; `handling.stripping_multiplier` / `handling.transport_multiplier` + nudge just those numbers. + + A stage whose strength comes out 0 (a deck stating neither f'c nor f'ci) is + dropped rather than run at no strength -- the same refuse-loudly discipline + the solve uses, one layer earlier. + """ + handling = _block(deck, 'handling') + out = [] + + override = handling.get('stages') + if override is not None: + if not isinstance(override, list): + raise DeckError('handling.stages must be a list, got {0!r}' + .format(override)) + for i, s in enumerate(override): + if not isinstance(s, dict): + raise DeckError('handling.stages[{0}] must be a mapping, got ' + '{1!r}'.format(i, s)) + fc = _strength_for(panel, s.get('strength') or 'release') + if not fc: + continue + out.append(_stage( + deck, panel, name=s.get('name') or 'stage {0}'.format(i + 1), + pci_case=s.get('pci_case') or 'stripping', + multiplier=float(s.get('multiplier', DEFAULT_STRIPPING_MULTIPLIER)), + fc_psi=fc)) + return out + + # A deck-stated self-weight factor is the stripping factor the page has + # always driven; keep honouring it so nothing regresses. + sw = self_weight_factor(deck) + strip_mult = handling.get('stripping_multiplier') + strip_mult = (float(strip_mult) if strip_mult is not None + else (sw if sw != DEFAULT_SELF_WEIGHT_FACTOR + else DEFAULT_STRIPPING_MULTIPLIER)) + trans_mult = handling.get('transport_multiplier') + trans_mult = (float(trans_mult) if trans_mult is not None + else DEFAULT_TRANSPORT_MULTIPLIER) + erect_mult = handling.get('erection_multiplier') + erect_mult = (float(erect_mult) if erect_mult is not None + else DEFAULT_ERECTION_MULTIPLIER) + + fci = _strength_for(panel, 'release') + if fci: + out.append(_stage(deck, panel, name='Stripping', pci_case='stripping', + multiplier=strip_mult, fc_psi=fci)) + fc = _strength_for(panel, '28day') + if fc: + out.append(_stage(deck, panel, name='Transportation', + pci_case='transportation', multiplier=trans_mult, + fc_psi=fc)) + out.append(_stage(deck, panel, name='Erection', + pci_case='erection', multiplier=erect_mult, + fc_psi=fc)) + return out + + +def handling_rigging(): + """The rigging object the dunnage path needs but does not read. + + `statics.analyse` takes a `Rigging`; on the supports path it is only + stored on the returned `Analysis` (no cable groups, no sling angle). Static + mode names what a trailer/dunnage actually is. + """ + return Rigging(mode='static') + + +# ------------------------------------------------------------- in-place (stage3) +# +# The In-Place model inputs -- lateral restraints, wind pressures, applied axial +# loads and the load combinations -- read off the deck's structural_model and +# loading blocks. Every one is OPTIONAL and STAGE-3 tagged: a deck that has not +# had them wired carries the `unread(...)` shape (value null + reason) these +# readers already understand, and the page keeps its explicit placeholder rather +# than fabricating an in-place result. The handling path is untouched. + + +def _is_unread(block): + """True for the `{value: None, reason: ...}` gathered-but-unread shape.""" + return (isinstance(block, dict) and 'reason' in block + and block.get('value') is None and 'x_ft' not in block + and 'zones' not in block) + + +# A restraint's `type` naming 'Free' is NOT a lateral restraint at all -- the +# field model's own vocabulary (`vibe.panelanalysisform`'s +# `restraint_foundation_connection`) carries it beside 'Pin' / 'Fixed', and a +# 'Floor tie' the same producer may also emit. Matched case-insensitively so a +# stray capitalisation does not silently un-skip a free end; every OTHER type +# word (including the golden's own 'restrained', 'dunnage', or anything this +# reader has never seen) is treated as a lateral restraint -- unrecognised +# never means "exclude" here, only 'Free' by NAME does (CLAUDE.md sec.3's +# "unrecognised state renders red, never grey", the same discipline in +# reverse: an unknown type is COUNTED, not quietly dropped). +def _is_free_restraint(type_word): + return str(type_word or '').strip().lower() == 'free' + + +def restraint_rows(deck): + """[{x_ft, type, elevation_ft, gravity, thermal, stiffness_kin, lateral}] + -- every row of `structural_model.restraints`, AS ENTERED, for the page. + + Tolerates every extra key the field model now carries (`elevation_ft`, + `gravity`, `thermal`, `stiffness_kin`) by simply passing each one through + -- absent keys come back None, never invented. `lateral` is False only for + a 'Free' type (see `_is_free_restraint`); every other row is True, whether + or not this reader recognises the word. [] for a missing/unread block or + one that is not a list -- the caller (`restraints()`) owns refusing a + malformed block; this is display-only and never raises. + """ + sm = _block(deck, 'structural_model') + blk = sm.get('restraints') + if blk is None or _is_unread(blk): + return [] + items = blk.get('value') if isinstance(blk, dict) and 'value' in blk else blk + if not isinstance(items, list): + return [] + out = [] + for r in items: + if not isinstance(r, dict): + continue + typ = r.get('type') + out.append({ + 'x_ft': r.get('x_ft'), + 'type': typ, + 'elevation_ft': r.get('elevation_ft'), + 'gravity': r.get('gravity'), + 'thermal': r.get('thermal'), + 'stiffness_kin': r.get('stiffness_kin'), + 'lateral': not _is_free_restraint(typ), + }) + return out + + +def restraints(deck): + """([x_ft], reason) -- the LATERAL restraints the in-place panel spans. + + A list of {x_ft, type, ...} under `structural_model.restraints`, OR the + `unread(...)` placeholder (-> [], reason). A row whose `type` is 'Free' + (`_is_free_restraint`) is not a lateral restraint at all and is skipped -- + every other type word (recognised or not) counts. The in-place solve + refuses fewer than two the same way the handling solve does; this reader + does not second-guess that refusal, it only parses and filters by type. + """ + sm = _block(deck, 'structural_model') + blk = sm.get('restraints') + if blk is None: + return [], 'no structural_model.restraints on this deck' + if _is_unread(blk): + return [], blk.get('reason') or 'restraints not wired yet' + items = blk.get('value') if isinstance(blk, dict) and 'value' in blk else blk + if not isinstance(items, list): + raise DeckError('structural_model.restraints must be a list of ' + '{{x_ft, type}} or an unread placeholder, got {0!r}' + .format(blk)) + out = [] + for i, r in enumerate(items): + if not isinstance(r, dict) or r.get('x_ft') is None: + raise DeckError('structural_model.restraints[{0}] needs an "x_ft"' + .format(i)) + if _is_free_restraint(r.get('type')): + continue + try: + out.append(float(r['x_ft'])) + except (TypeError, ValueError): + raise DeckError('structural_model.restraints[{0}].x_ft is not a ' + 'number ({1!r})'.format(i, r['x_ft'])) + return out, None + + +def compute_asce_pressures(deck): + """(windload.WindResult, reason). Runs `windload.compute` off loading.wind.asce. + + (None, reason) when there is nothing to compute from -- no `asce` block, or + one missing the numeric inputs `windload.compute` cannot run without (Vult, + mean roof height, effective wind area). A WindResult whose `.not_judged` is + set is returned AS the result (h>60, un-mined ASCE version, bad selectors): + the reason travels on it, never fabricated into a pressure. This is the + CPython deck-solve step that owns the ASCE arithmetic -- `windload` is + CPython and cannot be reached from the IronPython form. + """ + blk = _block(deck, 'loading').get('wind') + if not isinstance(blk, dict): + return None, 'no loading.wind block' + a = blk.get('asce') + if not isinstance(a, dict): + return None, (blk.get('asce_incomplete') + or 'no loading.wind.asce inputs to compute from') + + def _f(key): + v = a.get(key) + try: + return float(v) if v is not None and v != '' else None + except (TypeError, ValueError): + return None + + v_ult = _f('v_ult_mph') + h = _f('mean_roof_height_ft') + area = _f('effective_wind_area_ft2') + if v_ult is None or h is None or area is None: + return None, ('loading.wind.asce needs numeric v_ult_mph, ' + 'mean_roof_height_ft and effective_wind_area_ft2') + cc = a.get('cc_chapter') + try: + cc_chapter = (int(float(cc)) if cc not in (None, '') + else windload.CC_CHAPTER) + except (TypeError, ValueError): + cc_chapter = windload.CC_CHAPTER + kzt = _f('kzt') + res = windload.compute( + v_ult_mph=v_ult, + risk_category=a.get('risk_category'), + exposure=a.get('exposure'), + enclosure=a.get('enclosure'), + code_version=a.get('code_version'), + mean_roof_height_ft=h, + effective_wind_area_ft2=area, + cc_chapter=cc_chapter, + kzt=kzt if kzt is not None else 1.0) + return res, None + + +def _governing_pressures(res): + """(governing_mag, positive_psf, negative_psf, uncertainty_psf) across the + C&C zones, or None where the result is not_judged / empty. + + The in-place model runs a single out-of-plane pressure; a straight wall + bends both ways, so the GOVERNING magnitude is what matters -- the largest + |p| over Zones 4/5, positive or negative (suction). + """ + if res is None or res.not_judged or not res.zones: + return None + pos = max(z.positive_psf for z in res.zones) + neg = min(z.negative_psf for z in res.zones) # most negative + unc = max(z.uncertainty_psf for z in res.zones) + return max(abs(pos), abs(neg)), pos, neg, unc + + +def resolve_wind(deck): + """The in-place wind-pressure DECISION + provenance, or None. + + None when `loading.wind` is absent/unread, or carries an explicit `zones` + list (the golden given-pressure path, handled by `wind_zones` directly). + Otherwise a dict from `panelanalysisform.resolve_wind_pressure` (the pure + direct > computed > blank precedence) enriched for the page: + + source 'direct' | 'computed' | 'not_judged' | 'blank' + pressure_psf governing magnitude fed to the in-place wind case + positive_psf, the C&C design values, for display (computed path) + negative_psf + uncertainty_psf +/- band from the figure-read GCp (computed path) + cite, gcpi, windload provenance (computed path) + gcpi_cite + result the windload.WindResult (computed path) or None + reason not-judged / blank reason (else None) + """ + blk = _block(deck, 'loading').get('wind') + if not isinstance(blk, dict) or _is_unread(blk) or 'zones' in blk: + return None + if blk.get('mode') == 'project_config': + # Not built (PANELANALYSIS-4): `wind_zones` already returns this + # deck's own refusal; nothing here should try an ASCE/direct-entry + # resolution behind it and print a DIFFERENT (misleading) answer. + return None + + direct_p = blk.get('direct_pressure_psf') + direct_s = blk.get('direct_suction_psf') + dvals = [] + for v in (direct_p, direct_s): + if v is not None: + try: + dvals.append(abs(float(v))) + except (TypeError, ValueError): + pass + direct_mag = max(dvals) if dvals else None + + res, reason = compute_asce_pressures(deck) + gov = _governing_pressures(res) + computed_mag = gov[0] if gov else None + computed_reason = None + if res is not None and res.not_judged: + computed_reason = res.not_judged + elif computed_mag is None and reason: + computed_reason = reason + + info = dict(_paf.resolve_wind_pressure(direct_mag, computed_mag, + computed_reason)) + info['result'] = res + info['positive_psf'] = gov[1] if gov else None + info['negative_psf'] = gov[2] if gov else None + info['uncertainty_psf'] = gov[3] if gov else None + info['direct_pressure_psf'] = _num_or_none(direct_p) + info['direct_suction_psf'] = _num_or_none(direct_s) + if res is not None and res.gcpi is not None: + info['gcpi'], info['gcpi_cite'] = res.gcpi.value, res.gcpi.cite + else: + info['gcpi'], info['gcpi_cite'] = None, None + info['cite'] = res.zones[0].basis if (res is not None and res.zones) else None + return info + + +def _num_or_none(v): + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +#: `wind_zones`'s fixed reason for `mode: 'project_config'` -- named here so +#: the page and a test can assert the exact sentence rather than a substring +#: guess. PANELANALYSIS-4: "wind from project config" is a real future mode +#: (a Project Config wind-speed/exposure module), not built yet. +WIND_PROJECT_CONFIG_NOT_BUILT = 'wind from project config is not built yet' + + +def wind_zones(deck, panel=None): + """([LateralPressureZone], tributary_width_ft, reason). + + FOUR ways a deck states the out-of-plane wind, checked by `loading.wind. + mode` first and falling back to the pre-existing shape-sniffing for a deck + with no `mode` at all (every deck built before PANELANALYSIS-4): + + * `mode: 'manual'` -- an explicit `zones` list, each carrying its OWN + `pressure_psf` and/or `suction_psf` (either may be blank -> None, + meaning that direction is simply absent for that zone) and its own + `width_ft` (None -> the panel's tributary width). This is the new + manual per-zone contract; `panellift.inplace` reads `suction_psf` off + these same zone objects to build an independent W- field + (`build_wind_suction_cases`) rather than assuming symmetry. + * `mode: 'project_config'` -- NOT built: ([], trib, + `WIND_PROJECT_CONFIG_NOT_BUILT`), always, regardless of any other key + the block carries. Never fabricated from whatever else happens to be + present. + * no `mode` (legacy) -- exactly the pre-existing two paths, UNCHANGED: + an explicit `zones` list -- {x0_ft, x1_ft, pressure_psf, label}, one + magnitude per zone, no suction -- the GIVEN-pressure path the goldens + and fixtures use; or the ASCE inputs (`asce`) and/or a direct-entry + pressure, which `resolve_wind` turns into ONE governing pressure over + the panel length (direct > computed > blank). + * the `unread(...)` placeholder -> ([], None, reason). + + A legacy zone's `LateralPressureZone` carries `suction_psf=None` (the + dataclass default) -- `inplace.InPlaceInputs.wind_zone_mode` (set by + `in_place_inputs`, off this same `mode` key) is what keeps those decks on + the historic negate-the-whole-field suction shortcut; this reader does not + need to say so itself. + """ + blk = _block(deck, 'loading').get('wind') + if blk is None: + return [], None, 'no loading.wind on this deck' + if _is_unread(blk): + return [], None, blk.get('reason') or 'wind not wired yet' + + mode = blk.get('mode') if isinstance(blk, dict) else None + + if mode == 'project_config': + trib = blk.get('tributary_width_ft') + trib = float(trib) if trib is not None else None + return [], trib, WIND_PROJECT_CONFIG_NOT_BUILT + + trib = blk.get('tributary_width_ft') + trib = float(trib) if trib is not None else None + + if mode == 'manual': + zdefs = blk.get('zones') + if not isinstance(zdefs, list): + raise DeckError('loading.wind.zones must be a list, got {0!r}' + .format(zdefs)) + zones = [] + for i, z in enumerate(zdefs): + if not isinstance(z, dict): + raise DeckError('loading.wind.zones[{0}] must be a mapping, ' + 'got {1!r}'.format(i, z)) + try: + x0 = float(z['x0_ft']) + x1 = float(z['x1_ft']) + except (KeyError, TypeError, ValueError) as exc: + raise DeckError('loading.wind.zones[{0}] needs numeric ' + 'x0_ft, x1_ft ({1})'.format(i, exc)) + width = z.get('width_ft') + width = _num_or_none(width) + pressure = _num_or_none(z.get('pressure_psf')) + suction = _num_or_none(z.get('suction_psf')) + zones.append(inplace.LateralPressureZone( + x0_ft=x0, x1_ft=x1, pressure_psf=pressure, + suction_psf=suction, width_ft=width, + label=str(z.get('label') or 'W{0}'.format(i + 1)))) + return zones, trib, None + + if isinstance(blk, dict) and 'zones' in blk: + zdefs = blk.get('zones') + if not isinstance(zdefs, list): + raise DeckError('loading.wind.zones must be a list, got {0!r}' + .format(zdefs)) + zones = [] + for i, z in enumerate(zdefs): + try: + zones.append(inplace.LateralPressureZone( + x0_ft=float(z['x0_ft']), x1_ft=float(z['x1_ft']), + pressure_psf=float(z['pressure_psf']), + label=str(z.get('label') or 'W{0}'.format(i + 1)))) + except (KeyError, TypeError, ValueError) as exc: + raise DeckError('loading.wind.zones[{0}] needs numeric x0_ft, ' + 'x1_ft, pressure_psf ({1})'.format(i, exc)) + return zones, trib, None + + # ASCE-computed / direct-entry path. + info = resolve_wind(deck) + if info is None or info.get('pressure_psf') is None: + return [], trib, (info or {}).get('reason') or 'no wind pressure supplied' + length = getattr(panel, 'length_ft', None) if panel is not None else None + x1 = float(length) if length else 1.0 + zone = inplace.LateralPressureZone( + x0_ft=0.0, x1_ft=x1, pressure_psf=float(info['pressure_psf']), + label='W-{0}'.format(info['source'])) + return [zone], trib, None + + +def applied_axial(deck): + """[AppliedAxial] -- concentrated gravity bearing loads, or []. + + `ecc_in` is read directly, or as its alias `offset_in` -- the name + `panelanalysisform.parse_conc_loads`/`build_deck` actually writes on each + row (Eriksson's own "Offset" column). `ecc_in` wins if a row somehow + carries both. Neither present -> 0.0, a pure axial load, unchanged from + before this field existed. + """ + items = _block(deck, 'loading').get('applied_axial') or [] + if not isinstance(items, list): + raise DeckError('loading.applied_axial must be a list, got {0!r}' + .format(items)) + out = [] + for i, a in enumerate(items): + try: + ecc = a.get('ecc_in') + if ecc is None: + ecc = a.get('offset_in') + out.append(inplace.AppliedAxial( + x_ft=float(a['x_ft']), dead_lb=float(a.get('dead_lb') or 0.0), + live_lb=float(a.get('live_lb') or 0.0), + label=str(a.get('label') or 'P{0}'.format(i + 1)), + ecc_in=float(ecc) if ecc is not None else 0.0)) + except (KeyError, TypeError, ValueError) as exc: + raise DeckError('loading.applied_axial[{0}] needs a numeric x_ft ' + '({1})'.format(i, exc)) + return out + + +def service_options(deck): + """The SERVICE-side inputs (Stage 3), from `loading.service`, or defaults. + + A mapping {deflection_limit_ratio, cracking_class_t, initial_bow_in, + axial_eccentricity_in} -- or the `unread(...)` placeholder / absent, which + leaves each field at its could-not-judge default (deflection_limit_ratio + None => the deflection LIMIT check is NOT JUDGED, never faked). Numbers are + validated; a non-number is refused by name. + """ + blk = _block(deck, 'loading').get('service') + if blk is None or _is_unread(blk): + return {} + if not isinstance(blk, dict): + raise DeckError('loading.service must be an unread placeholder or a ' + 'mapping, got {0!r}'.format(blk)) + out = {} + for key in ('deflection_limit_ratio', 'initial_bow_in', + 'axial_eccentricity_in'): + if blk.get(key) is not None: + try: + out[key] = float(blk[key]) + except (TypeError, ValueError): + raise DeckError('loading.service.{0} is not a number ({1!r})' + .format(key, blk[key])) + if 'cracking_class_t' in blk: + out['cracking_class_t'] = bool(blk['cracking_class_t']) + return out + + +def in_place_inputs(deck, panel): + """(InPlaceInputs, reason). None+reason where the in-place model is unwired. + + Requires at least the lateral restraints; wind may be absent (a gravity-only + in-place run is legitimate and the engine flags the zero lateral demand). + Returns (None, reason) whenever the restraints are unwired -- which is the + Stage-0 default -- so the page keeps its explicit placeholder. + """ + rxs, r_reason = restraints(deck) + if len(rxs) < 2: + return None, (r_reason or 'fewer than two lateral restraints') + zones, trib, _w_reason = wind_zones(deck, panel) + combos = None + combo_blk = _block(deck, 'loading').get('combinations') + if isinstance(combo_blk, list): + combos = _parse_combinations(combo_blk) + svc = service_options(deck) + return inplace.InPlaceInputs( + restraint_xs=rxs, wind_zones=zones, + applied_axial=applied_axial(deck), tributary_width_ft=trib, + combinations=combos, fc_psi=handling_fc_psi_inplace(panel), + deflection_limit_ratio=svc.get('deflection_limit_ratio'), + cracking_class_t=svc.get('cracking_class_t', False), + initial_bow_in=svc.get('initial_bow_in', 0.0) or 0.0, + axial_eccentricity_in=svc.get('axial_eccentricity_in', 0.0) or 0.0, + wind_zone_mode=wind_zone_mode(deck)), None + + +def wind_zone_mode(deck): + """'manual' when the deck states `loading.wind.mode == 'manual'`, else + 'legacy' -- the flag `inplace.InPlaceInputs.wind_zone_mode` reads to + choose between the new per-zone pressure/suction envelope and the + historic negate-the-field shortcut (see `wind_zones`'s own docstring). + """ + blk = _block(deck, 'loading').get('wind') + if isinstance(blk, dict) and blk.get('mode') == 'manual': + return 'manual' + return 'legacy' + + +def handling_fc_psi_inplace(panel): + """f'c for the in-place model -- the 28-day strength (docs sec.6).""" + return panel.fc_psi or panel.fci_psi or 0.0 + + +def _parse_combinations(items): + """Deck-supplied load combinations, or None to take the ASCE default set.""" + out = [] + for i, c in enumerate(items): + if not isinstance(c, dict) or not isinstance(c.get('factors'), dict): + raise DeckError('loading.combinations[{0}] needs a "factors" map' + .format(i)) + out.append(inplace.Combo( + name=str(c.get('name') or 'LC{0}'.format(i + 1)), + label=str(c.get('label') or ''), + factors={str(k): float(v) for k, v in c['factors'].items()}, + kind=str(c.get('kind') or 'strength'), + cite=str(c.get('cite') or ''))) + return out + + +# ----------------------------------------------------------------- writing + +def new_deck(mark='PNL101', design_number='PNL1', + length_ft=24.0, width_ft=8.0, thickness_in=8.0, + fc_psi=6000.0, fci_psi=4000.0, unit_weight_pcf=150.0, + prestress_loss_pct=8.0, support_xs=(2.0, 22.0), + support_type='dunnage', self_weight_factor=1.0, + strands=None, blockouts=None, weight_lb=None, + tension_limit_sqrt_fc=5.0, compression_limit_ratio=0.6): + """A well-formed `panelanalysis/1` deck, for fixtures and documentation. + + Every unread block is written as an explicit null + reason via `unread()`, + so a deck this builds is a worked example of the file contract as much as a + test input. The defaults describe a plain solid panel on two dunnage lines + with two straight strands -- the simplest thing the Handling solve has a + real answer for. + """ + if strands is None: + strands = [ + {'y': 1.0, 'z_in': 1.5, 'area_in2': 0.153, + 'initial_pull_lb': 28900.0, 'label': 'S1'}, + {'y': width_ft - 1.0, 'z_in': 1.5, 'area_in2': 0.153, + 'initial_pull_lb': 28900.0, 'label': 'S2'}, + ] + reinforcement = {} + if strands: + reinforcement['prestress'] = { + 'diameter_in': 0.5, + 'loss_pct': prestress_loss_pct, + 'fpu_ksi': 270.0, + 'strands': list(strands), + } + else: + reinforcement['prestress'] = None + reinforcement['_note'] = ('no prestress on this piece; capacity is ' + 'strand-only, so flexural capacity is not ' + 'computed and says so') + return { + 'schema': SCHEMA, + 'project_information': { + 'mark': mark, + 'design_number': design_number, + 'fc_psi': fc_psi, + 'fci_psi': fci_psi, + 'unit_weight_pcf': unit_weight_pcf, + 'prestress_loss_pct': prestress_loss_pct, + }, + 'settings': { + 'tension_limit_sqrt_fc': tension_limit_sqrt_fc, + 'compression_limit_ratio': compression_limit_ratio, + 'phi_flexure': 0.90, + 'phi_shear': 0.75, + 'development_length_multiplier': 1.0, + '_seeded_from': unread( + 'settings are seeded from knowledge/defaults in a later ' + 'stage; a Stage 0 fixture hand-authors them'), + }, + 'concrete_extents': { + 'length_ft': length_ft, + 'width_ft': width_ft, + 'thickness_in': thickness_in, + 'finish': 'smooth', + 'product_type': 'flat', + 'weight_lb': weight_lb, + 'blockouts': list(blockouts or []), + 'recesses': [], + }, + 'structural_model': { + 'supports': [{'x_ft': float(x), 'type': support_type} + for x in support_xs], + 'restraints': unread( + 'restraints are seeded from clash detection in a later stage ' + '(Stage 1); a Stage 0 fixture hand-authors supports only'), + 'bearing': unread( + 'in-place bearing is not read off any model yet'), + }, + 'reinforcement': reinforcement, + 'loading': { + 'self_weight': {'included': True, + 'load_factor': self_weight_factor}, + 'wind': unread('wind / lateral load cases are the in-place engine, ' + 'built in a later stage (Stage 3)'), + 'combinations': unread('ACI 318 load combinations are built with ' + 'the in-place engine (Stage 3)'), + 'service': unread('in-place SERVICE inputs (L/n deflection limit, ' + 'Class T cracking, initial bow, axial ' + 'eccentricity) are the in-place engine (Stage ' + '3); the ASCE 2.4 defaults run without them'), + }, + 'handling': { + '_note': 'result block; filled by panelanalysis.py from the ' + 'self-weight solve on structural_model.supports', + }, + 'in_place': unread('the in-place wind/gravity engine is not built yet ' + '(Stage 3); its result block is a placeholder, ' + 'never fabricated zeros'), + 'source': { + 'synthetic': True, + 'note': 'Stage 0 hand-authored fixture; not exported from any ' + 'model', + }, + } diff --git a/tools/qna_watch.py b/tools/qna_watch.py new file mode 100644 index 0000000..f7f15b5 --- /dev/null +++ b/tools/qna_watch.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Watch the QNA folder and report updates, so the agent can act on them. + +Why this exists +--------------- +QNA/ is where Will drops questions and tasks (`questions.md`, `for-dsh-*.md`, +`task_*.md`). An older attempt (QNA/.watch.register.ps1) watched the folder +but only *logged* events -- nobody read the log. This tool closes the loop: +it runs as a background job of the live agent session, exits the moment +something changes, and the harness delivers its stdout to the agent, who then +reads the update and does whatever it asks, then re-arms the watcher. + +Tracked files +------------- +Top-level files in QNA/ ending in .md or .txt, excluding README.md (the +convention doc, effectively static) and anything starting with '.' (logs, +state). Subfolders (wp345_case/, .dsh-home/) are work artifacts, not tasks -- +not tracked. + +Usage +----- + python tools/qna_watch.py --status # what is tracked, what is pending + python tools/qna_watch.py # diff now against last armed state, exit + python tools/qna_watch.py --wait 21600 # arm: block up to 6 h, exit on first change + python tools/qna_watch.py --interval 2.0 # poll cadence (default 2 s) + +Exit codes: 0 on report (change or timeout) -- the caller just reads stdout. +State lives in QNA/.qna-watch.json (gitignored like the rest of QNA). + +CPython 3 only -- this sits beside rvt.py/sweep.py, not under the extension. +""" + +import argparse +import hashlib +import json +import os +import re +import sys +import time +from datetime import datetime + +STATE_NAME = '.qna-watch.json' +TRACKED_EXT = ('.md', '.txt') +EXCLUDED_NAMES = {'readme.md'} # convention doc, not tasks + + +def qna_dir_default(): + return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'QNA')) + + +def list_tracked(qna): + """Names of top-level files worth watching, sorted.""" + out = [] + try: + entries = os.listdir(qna) + except OSError as exc: + sys.stderr.write('cannot list {0}: {1}\n'.format(qna, exc)) + return out + for name in entries: + path = os.path.join(qna, name) + if name.startswith('.'): + continue + if name.lower() in EXCLUDED_NAMES: + continue + if not os.path.isfile(path): + continue + if os.path.splitext(name)[1].lower() not in TRACKED_EXT: + continue + out.append(name) + return sorted(out) + + +def snapshot(qna): + """{name: sha256hex} for every tracked file. Missing/unreadable -> None.""" + snap = {} + for name in list_tracked(qna): + path = os.path.join(qna, name) + try: + with open(path, 'rb') as fh: + snap[name] = hashlib.sha256(fh.read()).hexdigest() + except OSError: + snap[name] = None + return snap + + +def load_state(qna): + path = os.path.join(qna, STATE_NAME) + try: + with open(path, 'r', encoding='utf-8') as fh: + data = json.load(fh) + if isinstance(data, dict) and isinstance(data.get('hashes'), dict): + return data['hashes'] + except (OSError, ValueError): + pass + return None + + +def save_state(qna, hashes): + path = os.path.join(qna, STATE_NAME) + tmp = path + '.tmp' + with open(tmp, 'w', encoding='utf-8') as fh: + json.dump({'version': 1, 'saved': datetime.now().isoformat(timespec='seconds'), + 'hashes': hashes}, fh, ensure_ascii=False, indent=1) + os.replace(tmp, path) + + +def diff_hashes(old, new): + """(changed, added, deleted) as sorted name lists.""" + changed = sorted(n for n in set(old) & set(new) if old[n] != new[n]) + added = sorted(set(new) - set(old)) + deleted = sorted(set(old) - set(new)) + return changed, added, deleted + + +QUESTION_RE = re.compile(r'^##\s+(Q\s*[::])', re.IGNORECASE) +ANSWER_RE = re.compile(r'^\*\*A') + + +def unanswered_questions(path): + """First lines of `## Q:` sections that carry no `**A**` reply yet.""" + try: + with open(path, 'r', encoding='utf-8', errors='replace') as fh: + lines = fh.read().splitlines() + except OSError: + return [] + found, current, in_fence, in_q = [], None, False, False + for raw in lines: + if raw.lstrip().startswith('```'): + in_fence = not in_fence + if current is not None: + current.append(raw) + continue + if not in_fence and raw.startswith('## '): + if in_q and current is not None: + found.append(current) + in_q = bool(QUESTION_RE.match(raw)) + current = [raw.strip()] if in_q else None + continue + if in_q and current is not None: + if ANSWER_RE.match(raw.lstrip()): + in_q = False # answered; stop collecting + current = None + else: + current.append(raw) + if in_q and current is not None: + found.append(current) + out = [] + for sec in found: + head = sec[0] + body = next((l.strip() for l in sec[1:] if l.strip()), '') + out.append('{0} -- {1}'.format(head, body[:120])) + return out + + +def report(qna, changed, added, deleted, hashes): + stamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print('=== QNA-WATCH update detected {0} ==='.format(stamp)) + for name in changed: + print(' changed: {0}'.format(name)) + for name in added: + print(' added: {0}'.format(name)) + for name in deleted: + print(' deleted: {0}'.format(name)) + qpath = os.path.join(qna, 'questions.md') + if os.path.isfile(qpath): + pend = unanswered_questions(qpath) + if pend: + print('unanswered questions in questions.md ({0}):'.format(len(pend))) + for one in pend: + print(' - {0}'.format(one.replace('\n', ' '))) + if hashes is not None: + save_state(qna, hashes) + print('AGENT ACTION: read the changed file(s) under QNA/, do what they ask,') + print('then re-arm: python tools/qna_watch.py --wait 21600') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--root', default=qna_dir_default(), help='folder to watch (default: repo QNA)') + ap.add_argument('--wait', type=float, default=0.0, + help='seconds to block waiting for a change (0 = diff once and exit)') + ap.add_argument('--interval', type=float, default=2.0, help='poll seconds while waiting') + ap.add_argument('--status', action='store_true', help='print tracked files + pending questions, exit') + args = ap.parse_args() + + try: + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + except Exception: + pass + + qna = os.path.normpath(args.root) + if not os.path.isdir(qna): + sys.stderr.write('no such folder: {0}\n'.format(qna)) + return 2 + + if args.status: + names = list_tracked(qna) + print('watching {0} ({1} tracked)'.format(qna, len(names))) + for name in names: + path = os.path.join(qna, name) + mt = datetime.fromtimestamp(os.path.getmtime(path)).strftime('%Y-%m-%d %H:%M:%S') + print(' {0} {1}'.format(mt, name)) + qpath = os.path.join(qna, 'questions.md') + if os.path.isfile(qpath): + pend = unanswered_questions(qpath) + print('unanswered questions: {0}'.format(len(pend))) + for one in pend: + print(' - {0}'.format(one)) + return 0 + + prev = load_state(qna) + base = snapshot(qna) + + if prev is None: + # first ever run: lay down the baseline quietly, nothing counts as new + save_state(qna, base) + print('(qna-watch) baseline initialized: {0} tracked files'.format(len(base))) + prev, base = base, snapshot(qna) + else: + changed, added, deleted = diff_hashes(prev, base) + if changed or added or deleted: + # something happened while nobody was watching -- backlog first + report(qna, changed, added, deleted, base) + return 0 + + if args.wait <= 0: + print('(qna-watch) no changes since last arm') + return 0 + + deadline = time.monotonic() + args.wait + while time.monotonic() < deadline: + time.sleep(max(0.2, args.interval)) + now = snapshot(qna) + if now == base: + continue + # debounce: let the writer finish, take the settled snapshot + time.sleep(1.0) + now = snapshot(qna) + changed, added, deleted = diff_hashes(base, now) + report(qna, changed, added, deleted, now) + return 0 + + print('(qna-watch) no changes in {0:.0f}s; exiting for re-arm'.format(args.wait)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/reinforce_offline.py b/tools/reinforce_offline.py new file mode 100644 index 0000000..0a96de4 --- /dev/null +++ b/tools/reinforce_offline.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +"""Reinforcing suggestions with NO REVIT RUNNING -- answered from the index. + + python tools/reinforce_offline.py WP157 + python tools/reinforce_offline.py WP157 --index "<...>/reinforce/index.json" + python tools/reinforce_offline.py --model sample_job WP157 + python tools/reinforce_offline.py --list WP + python tools/reinforce_offline.py WP157 --all --out report.txt + +WHAT IT PRINTS, AND WHY MOST OF IT IS FOLDED +--------------------------------------------- +The owner's rule for this report, 2026-08-30: the target and the donor +proposals are the answer and everything else starts folded, because "除非有啥 +问题我感觉我不会读" -- unless something is wrong, he will not read it. A +section that HAS something wrong in it opens itself and says why, so the page +carries a contract: if nothing below the answer is open, nothing needs you. +`--all` opens every section; `--out FILE` writes the whole thing unfolded, so +a folded section is never the only copy of those words. The folding itself is +`vibe.foldreport`, shared with the button. + +WHY THIS CAN EXIST AT ALL +------------------------- +A Revit model file cannot be parsed outside Revit -- it is a proprietary +format and nothing here pretends otherwise. (Spelling out the extension would +trip `tools/scrub.py`, which reads `.rvt` as a leaked model filename.) +But the model is not what the matcher reads. +It reads the INDEX: `//reinforce/index.json`, roughly a +megabyte, which already holds every measurement the search needs. Measured on +this project, 2026-08-30: a full index rebuild inside Revit is ~10-16 s, and +the match itself is about 0.05 s of ordinary CPython. + +Up to schema 4 exactly one thing on that path still needed a document: the +TARGET was measured live, because donor eligibility kept every not-yet- +reinforced piece -- which is to say, every ordinary target -- out of the file. +Schema 5 measures in-scope non-donors too (`reinforcecore.plan(include=...)`), +and with that gone the whole chain runs off the snapshot. Refresh the index +once with the model open, close Revit, and suggestions run all day. + +WHAT THAT COSTS, SAID ON EVERY PAGE THIS PRINTS +------------------------------------------------ +The answer is as true as the model was when the index was written and no +truer. Inside Revit the cheap signature pass reruns on every press, so a +member added, removed or swapped, a piece newly reinforced, held or typed +MASTER is caught; only a geometry edit that leaves the member set alone +(an opening dragged, a panel stretched) can go stale. Offline NONE of it is +caught, because nothing looks at the model at all. Every report this prints +opens with the OFFLINE banner and the build stamp, and closes with +`reinforcereport.staleness_note(offline=True)` spelling the whole list out. + +NO REVIT IMPORT IS REACHABLE FROM HERE, and that is a property of the call +graph, not a hope: `reinforcecore`, `reinforcematch`, `reinforcereport`, +`scope` and `compat` touch nothing; `reinforceindex` imports the Revit API +only inside the functions that need a document, none of which this calls; +`reinforcebind` likewise, and the two functions in it that do need one -- +`outline_audit` and `suggest_for_piece` -- are the two this file never calls. +It calls `suggest_from_index`, which is the same code with the cached record +in place of the live measurement. `tests/test_reinforceoffline.py` asserts +that no `Autodesk` module was loaded after a full offline run. + +CPython 3.12 -- this file is a `tools/` script, so modern syntax is fine here. +The `vibe` modules it imports are IronPython 2.7 and stay that way. +""" + +from __future__ import print_function + +import argparse +import json +import os +import sys +import types + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LIB = os.path.join(_HERE, '..', 'WillTools.extension', 'lib') +sys.path.insert(0, _LIB) + +# The same trick `tools/ticket_delta.py` and the test conftest use, for the +# same reason: `vibe/__init__.py` imports the Revit API at module scope, which +# is right inside Revit and fatal here. Register a bare `vibe` package pointed +# at the directory so the Revit-free submodules import normally and +# `__init__.py` never runs. +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [os.path.join(_LIB, 'vibe')] + sys.modules['vibe'] = _stub + +from vibe import foldreport as fold # noqa: E402 +from vibe import reinforcebind as bind # noqa: E402 +from vibe import reinforcecore as core # noqa: E402 +from vibe import reinforceindex as index # noqa: E402 +from vibe import reinforcereport as R # noqa: E402 + +JOBS_ENV = 'WILLTOOLS_JOBS' +WIDTH = 78 + + +# ------------------------------------------------------------ finding the file + +def resolve_index(index_arg=None, jobs=None, model=None): + """(path, how) for the index to read. Never guesses silently. + + Order: an explicit `--index` path; else `//reinforce/ + index.json`, which is what WILLTOOLS_JOBS means. With a jobs root and no + `--model`, a root holding exactly one model is used and named in the + output; a root holding several refuses and lists them, because picking one + of somebody's jobs at random is the kind of quiet wrong answer this + project spends its comments avoiding. + """ + if index_arg: + return os.path.abspath(index_arg), 'given with --index' + root = jobs or os.environ.get(JOBS_ENV) + if not root: + raise SystemExit( + 'No index to read. Give one of:\n' + ' --index \n' + ' --jobs --model \n' + ' set %s and pass --model ' % JOBS_ENV) + if not os.path.isdir(root): + raise SystemExit('jobs root does not exist: %s' % root) + if not model: + names = sorted(n for n in os.listdir(root) + if os.path.isdir(os.path.join(root, n))) + if len(names) != 1: + raise SystemExit( + 'Which model? %s holds %d:\n%s\n\nPass --model .' + % (root, len(names), '\n'.join(' ' + n for n in names))) + model = names[0] + return (index.index_path_in(index.jobs_folder(root, model)), + 'jobs root %s, model %s' % (root, model)) + + +def load_index(path): + """The cache, or a refusal that says what to do about it. + + A schema mismatch is not a corrupt file and must not be reported as one: + it means the index was written by an older version of this tool, and the + fix is one refresh inside Revit. The cache is discarded WHOLE -- see + `reinforcecore.load` -- because a schema-4 file holds eligible donors only + and adopting its pieces would answer "that piece is not in the index" for + every ordinary target while looking entirely healthy. + """ + if not os.path.isfile(path): + raise SystemExit( + 'No index at %s\n\n' + 'The index is written from inside Revit -- press Reinforcing ' + 'Suggestion once with the model open, then this will work with ' + 'Revit closed.' % path) + cache = core.load(path) + problem = cache.get('_load_error') + if problem: + raise SystemExit( + 'The index at\n %s\ncould not be used: %s\n\n' + 'Nothing was adopted from it -- an index from another schema is ' + 'discarded whole rather than half-read. Open the model in Revit, ' + 'press Reinforcing Suggestion once to rewrite it (a full rebuild ' + 'measured ~10-16 s), and run this again.' % (path, problem)) + return cache + + +# ---------------------------------------------------------------- printing + +# The plain-text renderers live in `vibe.foldreport`, not here, because the +# button needs the identical ones to write its unfolded copy to disk. These +# are thin names over them so the CLI reads as a CLI. +plain = fold.md_text +table = fold.table_text + + +def emit(sections, show_all=False): + """Print the report, folding the routine sections to one line each. + + The owner's rule, applied at a terminal: the answer, then a short list of + what else there is, and full text only for the sections that have a + problem in them. `--all` prints everything. + """ + print(fold.text(sections, show_all=show_all, width=WIDTH)) + + +# -------------------------------------------------------------------- the run + +def run(cache, name, folder, include_voids=True, prefixes=None, + no_scope=False, max_proposals=5, max_donors=4, full_path=None): + """(suggestion, info, sections) for one target, entirely from the snapshot.""" + the_scope, stamped = index.scope_in(cache) + if no_scope: + from vibe import scope as scopelib + the_scope = scopelib.Scope() + elif prefixes: + from vibe import scope as scopelib + the_scope = scopelib.Scope(prefixes=prefixes, + source='the --prefix option') + + # The outline audit's RESULT, read from the file the live run left beside + # the index. It cannot be recomputed here -- it needs each panel's real + # solid -- and never having been run is not the same as every outline + # being a clean rectangle, which is why `load_audit_at` returns None + # rather than an empty set and the report says which it got. + approximate = bind.load_audit_at(folder) + + suggestion, info = bind.suggest_from_index( + cache, name, approximate=approximate, the_scope=the_scope, + include_voids=include_voids, max_proposals=max_proposals, + max_donors=max_donors) + info['scope_stamped'] = stamped + + target = (cache.get('pieces') or {}).get(name) or {} + rows = (bind.explain(suggestion, info, suggestion.best) + if suggestion is not None and suggestion.best else []) + sections = R.render(suggestion, info, rows, cache, + model=cache.get('doc_key'), offline=True, + reinforced=target.get('reinforced'), + on_hold=target.get('on_hold'), + full_path=full_path) + if not stamped: + # An index that never recorded a scope is a problem, not a footnote: + # every count below rests on a pool bounded by nobody knows what. + sections[0].md( + '> **This index carries no declared scope.** It was built before ' + 'the scope was stamped into the file, so the donor pool below is ' + 'bounded by nothing except the filters named in the rejection ' + 'table. That is "nobody recorded it", not "no scope was ' + 'declared". Pass `--prefix` to bound it yourself.') + return suggestion, info, sections + + +def listing(cache, prefix=None): + """What this snapshot can answer about. Donors and targets kept apart.""" + donors = [r['name'] for r in core.donors(cache, include_unjudged=True)] + targets = [r['name'] for r in core.targets(cache)] + if prefix: + donors = [n for n in donors if n.startswith(prefix)] + targets = [n for n in targets if n.startswith(prefix)] + return donors, targets + + +def main(argv=None): + ap = argparse.ArgumentParser( + description='Reinforcing suggestions from the cached index, with no ' + 'Revit running.', + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('mark', nargs='?', help='the piece to suggest donors for') + ap.add_argument('--index', help='path to reinforce/index.json') + ap.add_argument('--jobs', help='jobs root (default: $%s)' % JOBS_ENV) + ap.add_argument('--model', help='model name under the jobs root') + ap.add_argument('--list', dest='list_prefix', nargs='?', const='', + help='list what the index holds, optionally by prefix') + ap.add_argument('--prefix', action='append', + help='bound the donor pool to these mark prefixes, ' + 'instead of the scope stamped in the index') + ap.add_argument('--no-scope', action='store_true', + help='ignore the stamped scope; every donor may donate') + ap.add_argument('--no-voids', action='store_true', + help='ligaments only -- do not ask a donor to account ' + 'for the target\'s openings as well') + ap.add_argument('--max-donors', type=int, default=4) + ap.add_argument('--max-proposals', type=int, default=5) + ap.add_argument('--all', dest='show_all', action='store_true', + help='print every section, including the folded ones') + ap.add_argument('--out', dest='out_path', + help='write the whole report, nothing folded, to a file') + ap.add_argument('--json', dest='json_out', + help='write the raw (info minus features) to a file') + args = ap.parse_args(argv) + + path, how = resolve_index(args.index, args.jobs, args.model) + cache = load_index(path) + folder = os.path.dirname(path) + + if args.list_prefix is not None: + donors, targets = listing(cache, args.list_prefix or None) + print('index: %s (%s)' % (path, how)) + print('built: %s' % (cache.get('built_at') or 'never')) + print('%d donor(s), %d piece(s) indexed only as targets' + % (len(donors), len(targets))) + print('\ndonors (may be copied from):') + print(_columns(donors)) + print('\ntargets only (not reinforced, or on hold -- ask about these):') + print(_columns(targets)) + if not targets: + print(' (none -- this index was built without target pieces. ' + 'Refresh it inside Revit to add them.)') + return 0 + + if not args.mark: + ap.error('give a piece mark, or --list') + + suggestion, info, sections = run( + cache, args.mark, folder, + include_voids=not args.no_voids, + prefixes=args.prefix, no_scope=args.no_scope, + max_proposals=args.max_proposals, max_donors=args.max_donors, + full_path=args.out_path) + if args.out_path: + # FOLDED IS NOT GONE. When the reader has asked for a file, it holds + # every section unfolded -- so a section the terminal showed as one + # line is never the only copy of those words. + with open(args.out_path, 'w', encoding='utf-8') as fh: + fh.write(fold.text(sections, show_all=True, width=WIDTH)) + emit(sections, show_all=args.show_all) + print('') + folded = [s for s in sections if not s.is_open and not s.headline] + opened = fold.sections_with_problems(sections) + if folded and not args.show_all: + print(' %d section(s) folded: %s' + % (len(folded), ', '.join(s.key for s in folded))) + # THE CONTRACT, SAID OUT LOUD. Claiming "nothing needs you" on a page + # where a section opened itself would be exactly the lie the whole rule + # exists to prevent, so the two cases get two different sentences. + if opened: + print(' %d section(s) opened themselves: %s' + % (len(opened), ', '.join(s.key for s in opened))) + elif not args.show_all: + print(' nothing below the answer needs you. --all opens them.') + print(' index: %s' % path) + print(' found: %s' % how) + if args.out_path: + print(' full: %s' % args.out_path) + + if args.json_out: + slim = dict((k, v) for k, v in info.items() + if k not in ('target_features', 'features_by_donor')) + slim['status'] = suggestion.status if suggestion else None + with open(args.json_out, 'w') as fh: + json.dump(slim, fh, indent=1, default=str, sort_keys=True) + print(' json: %s' % args.json_out) + return 0 + + +def _columns(names, per_line=8): + if not names: + return ' (none)' + out = [] + for i in range(0, len(names), per_line): + out.append(' ' + ' '.join(n.ljust(10) for n in names[i:i + per_line])) + return '\n'.join(out).rstrip() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/rvt.py b/tools/rvt.py new file mode 100644 index 0000000..d05dba0 --- /dev/null +++ b/tools/rvt.py @@ -0,0 +1,763 @@ +#!/usr/bin/env python3 +"""rvt - talk to the live Revit session from your terminal. + +Requires: pyRevit routes server running inside Revit (see ../README.md). +Standard library only, Python 3.8+. + + python tools/rvt.py status + python tools/rvt.py exec scratch/scratch.py + python tools/rvt.py exec -c "print(doc.Title)" + python tools/rvt.py exec scratch/rename.py --tx "Rename walls" + echo "print(len(list(FilteredElementCollector(doc))))" | python tools/rvt.py exec - + python tools/rvt.py sel + python tools/rvt.py collect OST_Walls --limit 20 + python tools/rvt.py params 123456 + python tools/rvt.py reload + python tools/rvt.py --strict status # refuse an unverifiable answer + python tools/rvt.py bridges # list live bridge sessions + python tools/rvt.py use "sample_job" # resolve a model title to a port + +Every request carries a correlation id and every answer is checked against it. +See `call()` and `REQUEST_CORRELATION` below for why that is not optional. +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import sys +import time +import urllib.error +import urllib.request +import uuid + +# pyRevit does not pin the routes port: `serverinfo._get_next_available_port` +# starts at the configured one and walks UP past every port a running Revit +# already holds. So with two sessions open, 48884 is "whichever Revit started +# first" and the second is on 48885. That is not a detail - it is how a sweep +# aimed at the sandbox lands in the live model instead. +# +# The port is therefore settable from the environment rather than hard-coded, +# so `tools/snapshot_cycle.py` can scan the small range, find the session that +# is actually the sandbox, and hand that port down to every batch command it +# runs. Unset, this is exactly what it always was. +DEFAULT_PORT = 48884 +PORT_ENV = "WILLTOOLS_BRIDGE_PORT" + + +def _port() -> int: + try: + return int(os.environ.get(PORT_ENV) or DEFAULT_PORT) + except (TypeError, ValueError): + return DEFAULT_PORT + + +def base_url() -> str: + """The bridge base URL for THIS call. + + Read per call, not once at import: a caller that sets the variable and + then makes a request is the ordinary case, and a module-level constant + would silently serve the port that was set when the module loaded. + """ + return f"http://localhost:{_port()}/vibe" + + +BASE = base_url() +TIMEOUT = 300 + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SESSION_LOG = ROOT / ".bridge-sessions.log" + +# --------------------------------------------------------- request correlation +# +# REQUEST_CORRELATION: why every request carries an id, and every answer is +# checked against it. +# +# Measured 2026-09-01, twice, during a 282-piece sweep. One request timed out +# client-side ("[260/282] no response from Revit within 300s"), and from then +# on the pairing was off by one: `sweep_in_revit.py`'s `open_model_title()` +# asked for `doc.Title` and got the SWEEP's progress string back, which it then +# printed as the document title; an agent polling `doc.Title` as a drift guard +# read the same stolen answer as "the model changed underneath me" and raised a +# false alarm. The data on disk was fine. The attribution was not, and **a +# response from the wrong request is indistinguishable from a valid answer**. +# +# WHERE THE DESYNC ACTUALLY LIVES -- not here. This client is stateless per +# request: `urlopen` builds a new connection every call and urllib sends +# `Connection: close`, so there is no client-side socket to go out of step and +# nothing here to "drain". The shared state is inside pyRevit, in +# `pyrevit/routes/server/server.py`: +# +# REQUEST_HNDLR = handler.RequestHandler() # ONE, module level +# EVENT_HNDLR = UI.ExternalEvent.Create(REQUEST_HNDLR) +# +# The HTTP server is a `ThreadingMixIn` server, so each connection gets its own +# thread -- but every one of those threads writes the SAME singleton +# (`REQUEST_HNDLR.request = request`), calls the SAME `reset()` (clearing one +# shared `_response` / `_done` pair), raises the SAME external event, and then +# spins in `req_hndlr.join()` waiting on that one `_done` flag before reading +# that one `_response`. +# +# So a client-side timeout does not cancel anything. The server thread is still +# spinning, Revit still owes it an answer, and when the next request arrives: +# +# * it overwrites `REQUEST_HNDLR.request`/`.handler` with its own, so the +# abandoned event -- if Revit has not serviced it yet -- executes against +# the NEW request; and `ExternalEvent.Raise()` on an already-pending event +# coalesces, so the new request's own raise can be swallowed and its +# snippet never run at all; and +# * both threads then wait on one `_done` flag and read one `_response`, so +# whichever handler finishes first answers BOTH sockets. The abandoned +# one's write goes to a closed socket and is lost; the live one carries the +# abandoned request's body to a client that asked something else. +# +# That is the whole mechanism, and it is why the fix is in two halves that both +# have to exist: +# +# 1. an id minted here and echoed by `WillTools.extension/startup.py`, so a +# stolen answer is REFUSED loudly instead of returned (`_verified`); and +# 2. a latch (`_latch_timeout`) written the moment a request times out, which +# makes the NEXT call drain that shared slot with a self-identifying probe +# before it sends anything real (`_drain_channel`). The latch is a file +# because `tools/sweep.py` runs `rvt.py` as a fresh subprocess per call -- +# an in-process flag would not survive to the request it has to protect, +# which is precisely the request that got the stolen answer. +# +# Nothing here can stop the theft happening inside Revit. It can stop the +# result being mistaken for data, which is the part that cost real time. + +CORRELATION_KEY = "_rid" +UNVERIFIED_KEY = "_unverified" +STRICT_ENV = "WILLTOOLS_BRIDGE_STRICT" + +# Where the "the last request timed out" latch lives. One file per port: which +# Revit owns which port is decided by start order (see DEFAULT_PORT above), so +# a latch left by a call to 48885 must not gate a call to 48884. +LATCH_DIR = ROOT + +# Escalating waits for the drain probes. Short on purpose: the point is to find +# out whether the channel is clear, not to sit out another long request. One +# abandoned request leaves at most one stale answer, so two probes are the +# minimum that can both consume it and then confirm - the third is headroom for +# a first probe that gets no answer at all because Revit is still busy. +DRAIN_TIMEOUTS = (15, 30, 60) + +_STRICT = False + + +class BridgeTimeout(Exception): + """A request that got no answer. + + Deliberately an exception and never a value: "Revit did not answer" and + "Revit answered" are different states, and the whole incident above is what + happens when something downstream reads one as the other. + """ + + +def _strict() -> bool: + """Whether an unverifiable answer is refused rather than flagged.""" + return bool(_STRICT or (os.environ.get(STRICT_ENV) or "").strip()) + + +def warn(msg: str): + """Say something on stderr. + + stderr, always: `tools/sweep.py` parses this process's STDOUT as JSON, so a + warning printed there would corrupt the answer it is warning about. + """ + print(msg, file=sys.stderr) + + +def new_request_id() -> str: + """A request id that is unique across processes and across time. + + The pid alone is not enough (`sweep.py` runs one `rvt.py` per call, and pids + are reused); a counter alone is not enough for the same reason. This is + pid + wall clock + random, which is unique enough to make a match mean + something and short enough to read in an error message. + """ + return "{0}-{1:x}-{2}".format(os.getpid(), int(time.time() * 1000), + uuid.uuid4().hex[:8]) + + +def _latch_path() -> pathlib.Path: + return LATCH_DIR / ".bridge-timeout-{0}.json".format(_port()) + + +def _latch_timeout(rid: str, path: str, reason: str): + """Record that this bridge may still owe an answer to a dead request.""" + body = {"rid": rid, "path": path, "reason": reason, "port": _port(), + "at": time.time(), "pid": os.getpid()} + try: + _latch_path().write_text(json.dumps(body, indent=1), encoding="utf-8") + except OSError as exc: + # A guard that silently failed to arm is worse than no guard: say so + # here rather than letting the next call believe the channel is clean. + warn("WARNING: could not write the bridge timeout latch at {0} ({1}).\n" + " The NEXT request will not know to drain the channel first, so\n" + " it may be handed this request's late answer. Treat the next\n" + " answer as unattributed." + .format(_latch_path(), exc)) + + +def _read_latch(): + """The latch for this port, or None. An unreadable latch still counts.""" + path = _latch_path() + if not path.exists(): + return None + try: + body = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + return {"rid": None, "path": None, "reason": "unreadable latch: {0}" + .format(exc), "at": None} + return body if isinstance(body, dict) else {"rid": None, "path": None, + "reason": "malformed latch", + "at": None} + + +def _clear_latch(): + try: + _latch_path().unlink() + except OSError: + pass + + +def _drain_channel(): + """Consume any late reply still sitting in the bridge's one response slot. + + Returns (clear, sentence). + + The probe is `/exec` with a snippet that returns a nonce this process just + invented. That gives the answer TWO independent ways to identify itself: + the echoed correlation id (only a current in-Revit half sends it) and the + nonce coming back as `result` (works against any half, including the one + running right now, which was installed before ids existed). An answer that + is neither is somebody else's -- reading it here is what removes it from + the shared slot, which is the actual draining. + """ + stale = 0 + notes = [] + for wait in DRAIN_TIMEOUTS: + nonce = "vibe-drain-" + uuid.uuid4().hex[:16] + rid = new_request_id() + body = {"code": "result = {0!r}".format(nonce), CORRELATION_KEY: rid} + try: + res = _send("/exec", "POST", body, wait) + except BridgeTimeout: + notes.append("a {0}s probe got no answer at all".format(wait)) + continue + if isinstance(res, dict) and res.get("refused"): + return False, ("the bridge is refusing every route, so nothing can " + "prove the channel is clean: {0}" + .format(res.get("error"))) + echoed = res.get(CORRELATION_KEY) if isinstance(res, dict) else None + if echoed is not None and echoed != rid: + stale += 1 + notes.append("a probe came back stamped {0}, which is another " + "request's id".format(echoed)) + continue + if isinstance(res, dict) and res.get("result") == nonce: + return True, ("the bridge answered its own probe{0}" + .format("" if not stale else + " after {0} stale repl{1} drained" + .format(stale, "y" if stale == 1 else "ies"))) + stale += 1 + notes.append("a probe came back without its own nonce in `result` " + "-- that answer belonged to something else") + return False, "; ".join(notes) or "no probe could identify itself" + + +def _resolve_timeout_latch(): + """Refuse to reuse a channel that may still be carrying a dead request. + + Called before every real request. Draining is attempted, loudly, and only a + probe that identifies itself clears the latch -- "the bridge answered" is + not the same fact as "the bridge answered ME", and this whole module exists + because those two were once the same code path. + """ + latch = _read_latch() + if latch is None: + return + warn("NOTE: the last request on port {0} ({1}) did not complete: {2}.\n" + " pyRevit serves every route through one shared response slot, so a\n" + " late answer to that request would be handed to this one. Draining\n" + " before sending anything real." + .format(_port(), latch.get("path") or "unknown route", + latch.get("reason") or "unknown")) + clear, why = _drain_channel() + if clear: + _clear_latch() + warn(" channel clear: {0}. Continuing.".format(why)) + return + die("REFUSING to send - this bridge cannot be proven clean.\n\n" + " The previous request ({0}) never came back, and every probe since\n" + " has failed to identify itself: {1}.\n\n" + " Sending now risks being handed that request's answer and reading it\n" + " as an answer to this one - which is not a hypothetical, it is what\n" + " happened on 2026-09-01 (see REQUEST_CORRELATION in this file).\n\n" + " Wait for Revit to finish what it is doing and run this again; the\n" + " drain retries every time. If Revit is idle and this keeps failing,\n" + " a modal dialog is blocking the API context, or the session needs a\n" + " RESTART (never a pyRevit reload).\n\n" + " Latch: {2}".format(latch.get("path") or "unknown route", why, + _latch_path())) + + +def check_bridge_safe(): + """Refuse to talk to a bridge that has survived a pyRevit reload. + + pyRevit's routes handler keeps pointing at the scripting engine from before + the reload. The next request executes against a dead engine and kills Revit + outright - an unhandled exception on a non-main thread, which Revit cannot + catch. Our code never runs, so this has to be caught out here, before the + request is ever sent. + + startup.py appends one line per pyRevit load. Two lines for the same Revit + process means a reload happened and the bridge is now a landmine. + + One exception, proven live 2026-09-03: a 4-field line stamped `hotreload` + is a load that came up AFTER the bridge was stopped cleanly first (the + stop-first Hot Reload under Setup > Config > Hot Reload). The engine is + fresh and the handler points at it, so a second load is safe when -- and + only when -- the LAST load of this process carries that flag. A second+ + load with NO such flag is a raw `pyRevit > Reload` into a live bridge, + which is the crash. + """ + if not SESSION_LOG.exists(): + # Either Revit has never loaded the extension, or the tripwire in + # startup.py is not firing. Say so - a safety net that silently stopped + # working is worse than one that was never there. + print( + "WARNING: no bridge session log at " + f"{SESSION_LOG}\n" + " The reload tripwire is not recording. If Revit IS running with\n" + " pyRevit loaded, the guard is broken - do not reload pyRevit.", + file=sys.stderr, + ) + return + + entries = [] + for raw in SESSION_LOG.read_text(encoding="utf-8", errors="replace").splitlines(): + parts = raw.strip().split("|") + # when | pid | proc_start [| hotreload]. The optional 4th field is the + # stop-first Hot Reload stamp (startup.py); an older half writes 3. + if len(parts) >= 3: + flag = parts[3] if len(parts) >= 4 else "" + entries.append((parts[0], parts[1], parts[2], flag)) + if not entries: + return + + # This process's lines are those of the LAST entry: startup.py stamps the + # pid and process start of whatever pyRevit load wrote the line, so the + # current engine is the one the newest line belongs to. + _, pid, proc_start, last_flag = entries[-1] + own = [e for e in entries if e[1] == pid and e[2] == proc_start] + if len(own) > 1: + if last_flag == "hotreload": + # A stop-first Hot Reload: the bridge was stopped cleanly BEFORE + # this load re-registered a fresh server against a fresh engine, so + # the handler is not pointing at a dead engine. Proven live + # 2026-09-03: 4 clean requests after a hot reload on a throwaway + # model, Revit unharmed. + return + die( + f"REFUSING to send - the bridge is unsafe right now.\n\n" + f" pyRevit has been reloaded {len(own) - 1}x inside this Revit process (pid {pid}).\n" + f" Its request handler still points at the scripting engine from before\n" + f" the reload, so the next request would crash Revit outright.\n\n" + f" Fix: use Setup > Config > Hot Reload (it stops the bridge first), or\n" + f" restart Revit. A raw 'pyRevit > Reload' is what leaves the handler on\n" + f" a dead engine.\n\n" + f" Log: {SESSION_LOG}" + ) + + +def _send(path: str, method: str, payload, timeout: int): + """One HTTP round trip. Raises BridgeTimeout; never checks correlation. + + Arguments always travel as a JSON body, never a query string, and never a + header. pyRevit's routes `Request` does not reliably expose a parsed query + string across versions - on pyRevit 5.0.1 it exposes none at all, under any + attribute name (see startup.py's `_body` docstring) - so every endpoint + reads `request.data` instead, and `payload` is how this client fills it in. + A header would be no better: `base.Request.__init__` sets `_headers = {}` + and `HttpRequestHandler._prepare_request` never fills it, so the request + headers are as unreachable from handler code as the query string is. That + is why the correlation id travels in the body, on GET routes too - the + server reads a body whenever Content-Length is set, whatever the method. + """ + base = base_url() + url = base + path + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + except TimeoutError: + raise BridgeTimeout(path) + except urllib.error.URLError as exc: + # A CONNECT timeout arrives wrapped in URLError (socket.timeout is an + # OSError, and `do_open` wraps those); a READ timeout is raised bare and + # is caught above. Both are the same answer - nobody replied - and must + # not be reported as "cannot reach Revit", which reads as "Revit is + # closed" and is a different fact. + if isinstance(exc.reason, TimeoutError): + raise BridgeTimeout(path) + die( + f"cannot reach Revit at {base}\n" + f" -> {exc.reason}\n" + " Is Revit open with pyRevit loaded? Routes server must be enabled\n" + " (pyrevit configs routes enable) and Revit restarted once after install." + ) + except ConnectionResetError as exc: + die( + f"Revit closed the connection ({type(exc).__name__}).\n" + " Usually means the routes server or Revit itself went down mid-request.\n" + " Check Revit is still running, then restart it if the bridge stays dead." + ) + try: + return json.loads(body) + except json.JSONDecodeError: + return body + + +def _verified(res, rid: str, path: str): + """The answer, but only if it is an answer to THIS request. + + Three outcomes, and they are three, not two: + + matching id the answer, with the id stripped back off so callers see + exactly the shape they always saw. + wrong id REFUSED. Named loudly, both ids printed, nothing returned - + a stolen answer is indistinguishable from a real one, so + there is no safe value to hand back. + no id at all the in-Revit half predates this and cannot say. Warned and + flagged `_unverified`, or refused under --strict; see the + warning text for why that is the default. + """ + got = res.get(CORRELATION_KEY) if isinstance(res, dict) else None + + if got is not None and got != rid: + # Proof, not suspicion: the channel is serving somebody else's answers. + _latch_timeout(rid, path, "an answer arrived stamped with another " + "request's id ({0})".format(got)) + die("REFUSING this answer - it belongs to a different request.\n\n" + " asked as {rid}\n" + " answered as {got}\n" + " route {path}\n\n" + " pyRevit serves every route through one shared response slot, so\n" + " an abandoned request's answer can be handed to the next caller.\n" + " This is that, caught. Nothing is returned, because a wrong\n" + " answer and a right one look identical once they are a value.\n\n" + " The channel is now latched; the next call drains it first." + .format(rid=rid, got=got, path=path)) + + if got is None: + if _strict(): + die("REFUSING this answer - it cannot be attributed ({0}).\n\n" + " This client stamped the request {1} and the in-Revit half\n" + " echoed nothing back, so there is no way to tell this answer\n" + " from an abandoned request's answer served out of pyRevit's\n" + " shared response slot.\n\n" + " The in-Revit half only echoes ids once Revit has been\n" + " RESTARTED after this version of the extension was installed\n" + " (never a pyRevit reload). Drop --strict / unset {2} to take\n" + " the answer with a warning instead." + .format(path, rid, STRICT_ENV)) + warn("WARNING: this answer could not be attributed.\n" + " Asked as {0} on {1}; the in-Revit half echoed no id back, so it\n" + " predates request correlation and cannot say which request it is\n" + " answering. It is almost certainly right - but if a request has\n" + " timed out in this session, an answer like this one is exactly\n" + " how the wrong body gets read as data.\n" + " Fix: RESTART Revit (never a pyRevit reload) to load the current\n" + " extension. Use --strict / {2}=1 to refuse instead of warn." + .format(rid, path, STRICT_ENV)) + if isinstance(res, dict): + res[UNVERIFIED_KEY] = True + return res + + res.pop(CORRELATION_KEY, None) + return res + + +def call(path: str, method: str = "GET", payload=None): + """Call one bridge endpoint, and prove the answer is this call's answer. + + See REQUEST_CORRELATION at the top of this file for the incident and the + mechanism. In short: a correlation id goes out in the body (every route, + GET included), the current in-Revit half echoes it, and an answer that does + not carry this request's id is refused rather than returned. + """ + check_bridge_safe() + _resolve_timeout_latch() + rid = new_request_id() + body = dict(payload or {}) + body[CORRELATION_KEY] = rid + try: + res = _send(path, method, body, TIMEOUT) + except BridgeTimeout: + _latch_timeout(rid, path, "no answer within {0}s".format(TIMEOUT)) + die( + f"COULD NOT JUDGE: no response from Revit within {TIMEOUT}s ({path}).\n" + " This is not an answer about the model. Nothing was read and\n" + " nothing was ruled out - the request may still be running inside\n" + " Revit, and its answer may arrive after this process is gone.\n" + " A modal dialog in Revit blocks the API context the bridge runs on -\n" + " dismiss any open dialog, then retry.\n\n" + f" The bridge is now latched ({_latch_path().name}): the next call\n" + " drains that late answer out of pyRevit's shared response slot\n" + " before it sends anything, so it cannot be served as data to\n" + " whoever asks next." + ) + return _verified(res, rid, path) + + +def die(msg: str, code: int = 1): + print(msg, file=sys.stderr) + raise SystemExit(code) + + +def show(res): + """Print an /exec-style response in a human- and agent-friendly way.""" + if not isinstance(res, dict): + print(res) + return 0 + out = res.get("stdout") + if out: + print(out, end="" if out.endswith("\n") else "\n") + if res.get("ok"): + result = res.get("result") + if result is not None: + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + print("ERROR: " + str(res.get("error", res)), file=sys.stderr) + if res.get("traceback"): + print(res["traceback"], file=sys.stderr) + return 1 + + +def cmd_status(_): + return show_json(call("/status")) + + +def show_json(res): + print(json.dumps(res, indent=2, ensure_ascii=False)) + return 0 if isinstance(res, dict) and res.get("ok") else 1 + + +def cmd_exec(args): + if args.code: + code = args.code + elif args.file == "-": + code = sys.stdin.read() + elif args.file: + with open(args.file, "r", encoding="utf-8") as fh: + code = fh.read() + else: + die("give a file, -c CODE, or - to read stdin") + return show(call("/exec", "POST", {"code": code, "transaction": args.tx})) + + +def cmd_sel(_): + return show_json(call("/selection")) + + +def cmd_collect(args): + return show_json( + call("/collect", "POST", {"category": args.category, + "limit": args.limit, + "types": bool(args.types)}) + ) + + +def cmd_params(args): + return show_json(call("/params", "POST", {"id": args.id})) + + +def _probe_status(port: int, timeout: int = 3): + """A bare GET /vibe/status probe for bridge DISCOVERY. + + Deliberately not routed through `call()`/`_send()`: correlation, the drain + latch and the reload tripwire all belong to real requests, and this probe + is the opposite - it only asks "is anything live on this port". It is also + cheap and bounded (`timeout` seconds), where a real request may legitimately + take the full 300s. Returns the parsed JSON body, or None when the port + refuses the connection or answers something unparseable. `refused` bridges + DO answer HTTP (with a refusal body), so they come back as a dict - that is + how `bridges` distinguishes a live-but-protected session from a dead port. + """ + url = "http://localhost:{0}/vibe/status".format(port) + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + except Exception: + return None + try: + return json.loads(body) + except ValueError: + return None + + +def discover_bridges(base=None, span=8, timeout=3, _probe=None): + """Which of ports base..base+span-1 has a live bridge on it. + + Returns a list of dicts (empty when nothing is up): + {"port": port, "model": , "refused": bool, + "ok": bool} + A port that will not answer at all contributes nothing. `_probe` is an + injection seam for tests and defaults to `_probe_status`; it must accept + (port, timeout) and return a dict, or None. + """ + probe = _probe or _probe_status + base = base or DEFAULT_PORT + found = [] + for port in range(base, base + span): + d = probe(port, timeout) + if not isinstance(d, dict): + continue + found.append({"port": port, + "model": d.get("doc_title"), + "refused": bool(d.get("refused")), + "ok": bool(d.get("ok"))}) + return found + + +def cmd_bridges(args): + found = discover_bridges(span=args.span, timeout=args.timeout) + if args.json: + print(json.dumps(found, ensure_ascii=False)) + return 0 if found else 1 + if not found: + warn("no live bridge was found on the scanned range (ports {0}..{1})" + .format(DEFAULT_PORT, DEFAULT_PORT + args.span - 1)) + print("(none)") + return 1 + for entry in found: + refused = " [REFUSED - live, protected]" if entry["refused"] else "" + print("{0} {1}{2}".format(entry["port"], entry["model"] or "", refused)) + return 0 + + +def cmd_use(args): + """Resolve a model-title substring OR an explicit port to a live bridge.""" + found = discover_bridges(span=args.span, timeout=args.timeout) + if args.target.isdigit(): + want = int(args.target) + hits = [e for e in found if e["port"] == want] + if not hits: + die("no live bridge on port {0} (scanned ports {1}..{2})" + .format(want, DEFAULT_PORT, DEFAULT_PORT + args.span - 1)) + port, model = hits[0]["port"], hits[0]["model"] + else: + needle = args.target.lower() + matches = [e for e in found + if e["model"] and needle in str(e["model"]).lower()] + if not matches: + live = ", ".join("{0} ({1})".format(e["port"], e["model"]) + for e in found) or "(none)" + die("no live bridge matches {0!r}.\n live bridges: {1}" + .format(args.target, live)) + if len(matches) > 1: + listed = ", ".join("{0} ({1})".format(e["port"], e["model"]) + for e in matches) + die("ambiguous: {0} live bridges match {1!r}: {2}.\n" + " use a longer substring, or the port number." + .format(len(matches), args.target, listed)) + port, model = matches[0]["port"], matches[0]["model"] + if args.json: + print(json.dumps({"port": port, "model": model}, ensure_ascii=False)) + elif args.export: + print("export {0}={1}".format(PORT_ENV, port)) + else: + print(port) + warn("bridge {0} -> {1}".format(port, model)) + return 0 + + +def cmd_reload(_): + die( + "Reloading over HTTP is not supported - it crashes Revit.\n" + " Reloading rebuilds the pyRevit session, including the routes server\n" + " that would be serving this request; the half-dead server then throws\n" + " on a background thread and takes Revit with it.\n\n" + " Use the ribbon instead: Vibe > System > Reload pyRevit\n" + " (only needed after ADDING or RENAMING a button - editing an existing\n" + " script.py is picked up on the next click with no reload.)" + ) + + +def main(argv=None): + ap = argparse.ArgumentParser(prog="rvt", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + # Before the subcommand: `rvt.py --strict status`. Also settable as + # WILLTOOLS_BRIDGE_STRICT=1, which is how it reaches the tools that shell + # out to this one (sweep.py, snapshot_cycle.py) without threading a flag + # through every command line they build. + ap.add_argument("--strict", action="store_true", + help="refuse an answer that carries no correlation id " + "(an in-Revit half from before this Revit restart) " + "instead of warning and returning it") + sub = ap.add_subparsers(dest="cmd", required=True) + + sub.add_parser("status", help="Revit + open model info").set_defaults(fn=cmd_status) + + p = sub.add_parser("exec", help="run a Python snippet inside Revit") + p.add_argument("file", nargs="?", help="path to a .py file, or - for stdin") + p.add_argument("-c", "--code", help="inline code") + p.add_argument("--tx", metavar="NAME", + help="wrap in a Revit Transaction with this name") + p.set_defaults(fn=cmd_exec) + + sub.add_parser("sel", help="current selection").set_defaults(fn=cmd_sel) + + p = sub.add_parser("collect", help="collect elements by BuiltInCategory") + p.add_argument("category", help="e.g. OST_Walls") + p.add_argument("--limit", type=int, default=100) + p.add_argument("--types", action="store_true", help="element types, not instances") + p.set_defaults(fn=cmd_collect) + + p = sub.add_parser("params", help="dump all parameters of one element") + p.add_argument("id", help="element id") + p.set_defaults(fn=cmd_params) + + sub.add_parser("reload", help="reload pyRevit (picks up new buttons)").set_defaults(fn=cmd_reload) + + p = sub.add_parser("bridges", help="list live bridge sessions") + p.add_argument("--span", type=int, default=8, + help="how many ports to scan up from the base") + p.add_argument("--timeout", type=int, default=3, + help="seconds per port probe") + p.add_argument("--json", action="store_true", help="print the list as JSON") + p.set_defaults(fn=cmd_bridges) + + p = sub.add_parser("use", help="resolve a model title (or port) to a port") + p.add_argument("target", help="a model-title substring, or a port number") + p.add_argument("--span", type=int, default=8, + help="how many ports to scan up from the base") + p.add_argument("--timeout", type=int, default=3, + help="seconds per port probe") + p.add_argument("--json", action="store_true", + help="print {\"port\": ..., \"model\": ...} as JSON") + p.add_argument("--export", action="store_true", + help="print 'export WILLTOOLS_BRIDGE_PORT='") + p.set_defaults(fn=cmd_use) + + args = ap.parse_args(argv) + global _STRICT + _STRICT = bool(args.strict) + return args.fn(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/scrub.py b/tools/scrub.py new file mode 100644 index 0000000..f2e7616 --- /dev/null +++ b/tools/scrub.py @@ -0,0 +1,839 @@ +#!/usr/bin/env python3 +"""Refuse to publish anything carrying a client's name. + + python tools/scrub.py # working tree + full git history + python tools/scrub.py --worktree # working tree only, tracked and not + python tools/scrub.py --staged # what the pre-commit hook runs + python tools/scrub.py --private # the gitignored tree, on its own terms + +Exits non-zero on a hit, so it can gate a commit, a package build, or a push. + +--worktree and --staged look at different sets on purpose. --worktree is an +audit: every file git can see, tracked or not, and a hit anywhere is a hit. +--staged is a gate on one commit: only what is staged can block it, because +only what is staged is about to enter history. Untracked hits are still +printed, loudly, every time -- they are one `git add -A` from being the other +kind -- but they do not stop a commit they have nothing to do with. A gate +that blocks unrelated work is a gate people learn to pass with --no-verify. + +--private is the third set, and it is an inventory rather than a gate: it +always exits 0. Every mode above honours .gitignore, so none of them has ever +opened `QNA/`, `knowledge/plant/` or `knowledge/job/` -- the other +repository's territory, where a name is legitimate, and also the largest body +of quoted source material in the checkout. Nothing looking at all is worse +than looking on the right terms, so --private looks, and asks a different +question: not "is there a name here", which is answered yes by design, but +"is there a name here in a shape somebody will copy" -- a fenced block, a +proposed key, a quoted literal, a path, a name glued into a longer +identifier. Those are aggregated by the distinct string and printed; names in +prose and in data values are counted and never listed. See `scan_private`. + +It exits 0 because it has no verdict of its own to give. Every string it +prints contains either a denylist term or a structural pattern, so if one of +them had already been copied into a tracked file, --worktree would be failing +on that file right now. --private cannot find a leak that --worktree misses; +what it finds is the material a leak would be made out of, weeks before. +A mode that returned non-zero on that would return non-zero every single run, +and a check that always fails is a check that gets wrapped in `|| true`. + +The terms to look for live in `.scrub-terms`, one per line, which is itself +gitignored -- the list of client names must never become the thing that leaks +them. `.scrub-terms.example` shows the format. + +History matters as much as the working tree. Editing a file does not remove a +name from the commit that introduced it, and `git push` sends the commits, not +the checkout. A repo that has never been pushed can still be rewritten +cleanly; one that has been pushed cannot. + +Structural patterns below are always checked, term list or not: a model +filename or an absolute user path identifies a job whether or not anyone +remembered to add it to the list. + +The one exemption: the sanctioned placeholder names CLAUDE.md's "No client or +job information" table tells everyone to write instead of a real one -- the +job placeholder plus `.rvt` and friends -- are read out of that table (see +`load_placeholders`) and excused from the model/family-filename patterns, +whole-name only. Gluing anything else onto the placeholder still fails, on +purpose -- see `_is_placeholder_filename`. +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TERMS_FILE = os.path.join(ROOT, '.scrub-terms') +CLAUDE_MD = os.path.join(ROOT, 'CLAUDE.md') +# CLAUDE.md does not ship in a colleague distribution (tools/make_dist.py) -- +# CONTRIBUTING.md is the fallback there, carrying the identical heading and +# table verbatim so `load_placeholders()` still finds it. Checked in this +# order; CLAUDE.md wins where both exist, which is every owner checkout. +CONTRIBUTING_MD = os.path.join(ROOT, 'CONTRIBUTING.md') + +# Always suspicious, whatever the term list says. +STRUCTURAL = [ + (r'[A-Za-z]:\\Users\\[A-Za-z0-9._-]+', 'absolute Windows user path'), + (r'\b[\w -]+\.rvt\b', 'Revit model filename'), + (r'\b[\w -]+\.rfa\b', 'Revit family filename'), + (r'\b[\w.+-]+@[\w-]+\.[\w.]+\b', 'email address'), +] + +# The two structural categories above whose hit is a whole filename, and so +# can be checked against the sanctioned placeholders rather than a real name. +# The absolute-path and email patterns get no such exemption: there is no +# placeholder for those, and `sample_client`/`sample_job` never look like one. +FILENAME_CATEGORIES = {'Revit model filename', 'Revit family filename'} + +# Files whose hits are noise rather than leaks. +SKIP = {'.scrub-terms', '.scrub-terms.example'} +SKIP_DIRS = {'.git', 'dist', '__pycache__', '_ticketqc'} + + +def load_terms(path=None) -> list[tuple[str, bool]]: + """(term, case_sensitive) pairs. A leading '=' means case-sensitive. + + Names are matched case-insensitively by default, because a client name is + a client name however it is typed. Office parameter names are the + exception: SCREAMING_SNAKE literals must not appear, but the lowercase + role identifiers that replaced them (panel.design_number) are the whole + point of the refactor and must not be flagged. Prefix those with '='. + """ + path = path or TERMS_FILE + if not os.path.isfile(path): + return [] + terms = [] + with open(path, encoding='utf-8') as fh: + for line in fh: + line = line.split('#', 1)[0].strip() + if not line: + continue + if line.startswith('='): + terms.append((line[1:].strip(), True)) + else: + terms.append((line, False)) + return [t for t in terms if t[0]] + + +def load_placeholders(path=None) -> list[str]: + """The sanctioned stand-ins for a real client/job name -- read out of + CLAUDE.md's own table rather than retyped here. + + CLAUDE.md Section 3 ("No client or job information in the repo. Ever.") + is the one place that defines what a placeholder is allowed to be: + + | a client / owner | `sample_client` | + | a job / project / model | `sample_job` | + + A second, hardcoded list in this file would drift the day someone adds or + renames a row there -- silently, because nothing would fail, the + exemption below would just stop covering the current placeholder. Reading + the table instead means there is exactly one place to edit and this file + always agrees with it. + + Only the table between that heading and the following "Product codes + are..." paragraph is read, and only backtick-quoted tokens on table rows + (lines starting with "|") are taken -- the product-code examples two + paragraphs down (`PNL1.0`, `PNL101`, `ANCHOR_FACE_4T`) are prose, not a + table row, and are deliberately NOT placeholders: CLAUDE.md says outright + they "stay as they are", i.e. they must still be scrubbable text, not + exempted from anything. + + If neither heading can be found -- both files missing, moved, or + reworded -- this returns [] rather than guessing at a fallback list. An + empty result only makes the filename check stricter (nothing is exempt), + never looser, which is the safe direction for a scrub gate to fail in. + + NEITHER FILE, on a colleague distribution: CLAUDE.md is the owner's own + agent operating manual and does not ship (see tools/make_dist.py) -- + CONTRIBUTING.md carries the identical heading and table there instead, + and is checked second when `path` is not given explicitly. Measured + 2026-09-02: without this fallback, CI on a stripped checkout failed on + the sanctioned placeholder itself (`sample_job.rvt` in the test suite), + which is exactly the "nothing is exempt" failure mode above -- safe, but + a false positive on a totally clean fixture, not a leak. + """ + paths = [path] if path else [CLAUDE_MD, CONTRIBUTING_MD] + marker = 'No client or job information in the repo. Ever.' + for candidate in paths: + try: + with open(candidate, encoding='utf-8') as fh: + text = fh.read() + except OSError: + continue + start = text.find(marker) + if start == -1: + continue + end = text.find('Product codes are', start) + block = text[start:end] if end != -1 else text[start:start + 2000] + names = [] + for line in block.splitlines(): + line = line.strip() + if not line.startswith('|'): + continue + for m in re.finditer(r'`([^`]+)`', line): + names.append(m.group(1).strip().lower()) + if names: + return names + return [] + + +def _is_placeholder_filename(matched_text: str, placeholders: list[str]) -> bool: + """True only if `matched_text` IS a placeholder name plus extension -- + never if a placeholder merely appears somewhere inside it. + + `os.path.splitext` peels off exactly the trailing `.rvt` / `.rfa`, and + the stem that is left is compared to the placeholder list whole. That + whole-name comparison is the point: a placeholder with anything else + appended to its stem is a DIFFERENT stem, absent from the list, so it is + still flagged. Only the placeholder alone, plus the extension, passes. + """ + if not placeholders: + return False + stem, ext = os.path.splitext(matched_text.strip()) + if ext.lower() not in ('.rvt', '.rfa'): + return False + return stem.lower() in placeholders + + + +# `\b` treats an underscore as a word character, the same as a letter, so it +# does not sit between them: a term does not match where it is immediately +# followed by "_SOMETHING" -- there is no boundary at the position between +# the term's last letter and the underscore. A term glued to a role +# identifier, a piece mark, or a version number this way slips the scan +# silently -- the exact shape of a name that is very much still in the file. +# +# What should end a match is a LETTER continuing the same word on either +# side -- that is the one case that means "this is a substring of a longer +# ordinary word, not the term" (a fake example: "Northgate" must not match +# inside "Northgateway" or "preNorthgate"). An underscore, a digit, +# punctuation, or the edge of the line all end the term just as surely as +# whitespace does, so none of those may appear immediately adjacent to the +# match either -- which is what these lookarounds check instead of `\b`. +_TERM_BOUNDARY_BEFORE = r'(? list[tuple[re.Pattern, str]]: + out = [(re.compile(p, re.I), why) for p, why in STRUCTURAL] + for term, cased in terms: + flags = 0 if cased else re.I + out.append((re.compile(_TERM_BOUNDARY_BEFORE + re.escape(term) + + _TERM_BOUNDARY_AFTER, flags), + 'term {0!r}'.format(term))) + return out + + +def git(*args: str) -> str: + return subprocess.run(['git'] + list(args), cwd=ROOT, check=False, + capture_output=True, text=True, + errors='replace').stdout + + +def tracked_files() -> list[str]: + return [r for r in git('ls-files').splitlines() if r] + + +def untracked_files() -> list[str]: + """Files git can see but is not tracking, .gitignore honoured. + + These are where a name arrives: a scratch script somebody pasted a model + path into an hour ago is invisible to plain 'git ls-files', which is what + this scan used to run on. Ignored files stay out, so the real denylist and + the local job folders are not scanned. + """ + return [r for r in git('ls-files', '--others', + '--exclude-standard').splitlines() if r] + + +def staged_files() -> list[str]: + """What this commit is actually about to write into history.""" + out = git('diff', '--cached', '--name-only', '--diff-filter=ACMR') + return [r for r in out.splitlines() if r] + + +def scan_worktree(pats, listed=None, placeholders=None) -> list[str]: + hits = [] + if listed is None: + listed = tracked_files() + untracked_files() + for rel in listed: + if os.path.basename(rel) in SKIP: + continue + path = os.path.join(ROOT, rel) + if not os.path.isfile(path): + continue + try: + with open(path, encoding='utf-8', errors='replace') as fh: + lines = fh.readlines() + except OSError: + continue + for n, line in enumerate(lines, 1): + for pat, why in pats: + m = pat.search(line) + if not m: + continue + if why in FILENAME_CATEGORIES and \ + _is_placeholder_filename(m.group(0), placeholders): + continue + hits.append('{0}:{1}: {2} -- {3}'.format( + rel, n, why, m.group(0)[:60])) + return hits + + +def scan_history(pats, placeholders=None) -> list[str]: + """Every version of every file that has ever been committed.""" + if not git('rev-parse', '--git-dir').strip(): + return [] + log = git('log', '--all', '--patch', '--no-color', '--format=%H%x00%s') + hits, commit, subject = [], '?', '?' + seen = set() + for line in log.splitlines(): + if '\x00' in line and len(line.split('\x00')[0]) == 40: + commit, subject = line.split('\x00', 1) + continue + if not line.startswith('+'): + continue + for pat, why in pats: + m = pat.search(line) + if not m: + continue + if why in FILENAME_CATEGORIES and \ + _is_placeholder_filename(m.group(0), placeholders): + continue + key = (commit, why, m.group(0)) + if key in seen: + continue + seen.add(key) + hits.append('{0} {1!r}: {2} -- {3}'.format( + commit[:8], subject[:40], why, m.group(0)[:60])) + return hits + + +# ---------------------------------------------------------------- private tree +# +# Everything above honours .gitignore, on purpose and for good reasons -- and +# the consequence is that nothing in this file has ever opened `QNA/`, +# `knowledge/plant/` or `knowledge/job/`. Those are the other repository's +# territory (see the .gitignore comment that says so), a name is legitimate +# in them, and they are also the largest body of prose in this working tree +# and the only part of it whose job is to quote source material verbatim. A +# name is not merely possible there, it is certain. +# +# So this is deliberately NOT a gate and must never be grown into one. If it +# flagged every producer name in a plant file it would print a wall of +# expected hits, and a wall of expected hits gets read exactly once. That is +# the same failure --staged avoids by not blocking a commit on untracked +# files, one directory further out. +# +# The question --private asks is therefore not "is there a name in here" -- +# answered yes, obviously, that is what the file is -- but "is there a name +# in here in a shape that somebody will COPY". A sentence of prose about a +# producer's practice stays in the private repo when the note is read. A +# fenced block, a proposed YAML key, a quoted literal, a path: those are the +# strings that get lifted out of an extraction note and pasted into a module, +# a docs page or a test, and they cross the repo boundary with the name still +# inside them. That is the distinction this mode is built on, and it is what +# keeps the report short enough to still be read next month. (`_scratch/` and +# `sample data/` are gitignored too and get the same treatment, for the same +# reason: nothing was looking at them either.) +# +# Be honest about what it cannot do, because the shape of the blind spot is +# the useful part: +# +# * It cannot judge prose. A name in a sentence is counted, never read, so +# a producer's name inside a verbatim quotation that somebody later +# pastes whole into a docs page is resident here and invisible. What +# catches that is --worktree, on the day it lands in a tracked file. +# * It cannot read a PDF, and the exported tickets carry the job name in +# the title block. Those are in the "not read" tally, deliberately +# visible as a number. +# * "Travelling" is a guess about a shape, not a proof about a future. +# `key: value` in prose and a Markdown fence that is really a diagram +# both look like code to it, and a name written into a sentence that is +# about to become a docstring does not. +# * It only knows names that are on the term list, plus the structural +# patterns. A producer nobody has added to `.scrub-terms` is as +# invisible here as it is everywhere else. + +PRIVATE_SKIP_DIRS = {'__pycache__', 'dist', 'build', 'node_modules', + 'site-packages'} + +# Opaque to a line scanner: binary, or an export whose text this cannot read +# anyway. They are counted and reported as a number rather than passed over +# silently -- "not scanned" and "scanned, clean" are different answers and a +# scrub report may not collapse them. A ticket PDF is the standing example: +# it carries the job name in its title block and nothing here can see it. +PRIVATE_SKIP_EXT = {'.pdf', '.rvt', '.rfa', '.png', '.jpg', '.jpeg', '.gif', + '.bmp', '.ico', '.zip', '.7z', '.gz', '.tar', '.xlsx', + '.xls', '.docx', '.doc', '.pptx', '.pyc', '.pyo', '.dll', + '.exe', '.so', '.dat', '.db', '.sqlite', '.bin'} + +PRIVATE_MAX_BYTES = 2000000 + +# What kind of file this is decides which contexts count as travelling below. +PRIVATE_DOC_EXT = {'', '.md', '.markdown', '.txt', '.text', '.rst'} +PRIVATE_DATA_EXT = {'.yaml', '.yml', '.json', '.csv', '.tsv', '.toml', '.ini', + '.cfg', '.xml'} + +_FENCE = re.compile(r'^\s{0,3}(```|~~~)') +_INLINE_CODE = re.compile(r'`[^`]+`') +_QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"") +# A YAML-shaped line: a space-free lowercase key, a colon, a value. Prose +# rarely looks like this -- "Note: the producer says" is capitalised and +# "the plant says: x" has a space in the key, and the key pattern excludes +# both. What this is for is the proposed shape written INTO a note -- +# `plant_default: 4` in the middle of a paragraph -- which is a line somebody +# is about to paste into knowledge.DEFAULTS. +_YAML_LINE = re.compile(r'^\s*(-\s+)?[a-z_][a-z0-9_.\-]*:\s*\S') +# A whitespace-and-punctuation-delimited token: the unit a name gets glued +# into, and the unit somebody copies. Angle brackets and `=` are delimiters +# too, or a name inside a generated HTML report comes back as sixty +# characters of markup with the interesting part buried in the middle. +_TOKEN = re.compile(r'[^\s`\'"(),;<>=]+') + + +def ignored_files() -> list[str]: + """Files git can see and is deliberately ignoring. + + The mirror image of `untracked_files()`: the same walk, the other side of + .gitignore. Between this, `untracked_files()` and `tracked_files()` there + is no longer any part of the working tree that no mode ever opens. + """ + out = git('ls-files', '--others', '--ignored', '--exclude-standard') + return [r for r in out.splitlines() if r] + + +def private_files(listed=None): + """The gitignored files worth reading, and a tally of what was dropped. + + The tally is returned rather than discarded because the count is part of + the answer. A report that says "480 files read" while silently passing + over 200 PDFs is claiming a coverage it does not have -- the same reason + a check that could not run may not render as OK. + + Dot-prefixed path segments go in the tally, not the scan: one agent's + browser profile cache under `QNA/` is 58,000 files on its own, and + dot-names in a working tree are tool state rather than documents. + """ + if listed is None: + listed = ignored_files() + keep = [] + dropped = {'tool state': 0, 'build output': 0, 'unreadable': 0, + 'over size cap': 0, 'gone': 0} + for rel in listed: + parts = rel.replace('\\', '/').split('/') + if any(p.startswith('.') for p in parts): + dropped['tool state'] += 1 + continue + if os.path.basename(rel) in SKIP: + dropped['tool state'] += 1 + continue + if any(p in PRIVATE_SKIP_DIRS for p in parts): + dropped['build output'] += 1 + continue + if os.path.splitext(rel)[1].lower() in PRIVATE_SKIP_EXT: + dropped['unreadable'] += 1 + continue + try: + size = os.path.getsize(os.path.join(ROOT, rel)) + except OSError: + dropped['gone'] += 1 + continue + if size > PRIVATE_MAX_BYTES: + dropped['over size cap'] += 1 + continue + keep.append(rel) + return keep, dropped + + +def _file_kind(rel: str) -> str: + ext = os.path.splitext(rel)[1].lower() + if ext in PRIVATE_DOC_EXT: + return 'doc' + if ext in PRIVATE_DATA_EXT: + return 'data' + return 'code' + + +def _token_at(line: str, pos: int) -> str: + for tok in _TOKEN.finditer(line): + if tok.start() <= pos < tok.end(): + return tok.group(0) + return '' + + +def _inside(pos: int, spans) -> bool: + """True if `pos` is strictly inside one of these delimited spans, i.e. + between the delimiters rather than on one.""" + return any(s.start() < pos < s.end() - 1 for s in spans) + + +def _travels(kind: str, line: str, start: int, why: str, in_fence: bool): + """Where this hit sits, when that place is one a string travels out of -- + otherwise None, meaning the name is *resident*: this file's own subject + matter, sitting in the repository where it is allowed to be. + + Read the branches as a claim about copy-and-paste, not about danger. + Nothing here is unsafe where it is. The only question is which of these + strings has a plausible future in a tracked file. + """ + if not why.startswith('term '): + # Structural. A machine's user path, an email address, a model or + # family filename: none of those is a plant convention or a + # producer's vocabulary. Each identifies one person, one machine or + # one job, and reads identically wherever it is pasted, so there is + # no "doing its job" reading of it to weigh against travelling. + return 'structural' + tok = _token_at(line, start) + if '/' in tok or '\\' in tok: + return 'in a path' + if kind == 'code': + # A private script is all instruction. There is no prose half of it + # for a name to be resident in, and a scratch script is the exact + # thing the untracked-file scan already exists for -- this one just + # happens to sit under an ignored directory. + return 'in code' + if kind == 'data': + # Values in a plant or job file ARE the file. A comment is not: it + # is written for a reader, in sentences, and it is what gets carried + # across when the shape of the file is proposed somewhere else. + hash_at = line.find('#') + if hash_at != -1 and hash_at < start: + return 'in a comment' + return None + if in_fence: + return 'in a fenced code block' + if _inside(start, _INLINE_CODE.finditer(line)): + return 'in inline code' + if _inside(start, _QUOTED.finditer(line)): + return 'in a quoted literal' + if _YAML_LINE.match(line): + return 'in a key: value line' + return None + + +def _safe(text: str) -> str: + """Whatever of `text` this console can actually print. + + The private tree is notes, and notes are written in whatever language the + note-taker was thinking in. A Windows console defaulting to cp1252 raises + UnicodeEncodeError on the first character it cannot render, which kills + the process halfway down the report -- a scrub that stops printing looks + exactly like a scrub that found nothing more. + """ + enc = getattr(sys.stdout, 'encoding', None) or 'ascii' + return text.encode(enc, 'replace').decode(enc, 'replace') + + +def scan_private(pats, listed=None, placeholders=None): + """Read the private tree and sort what is found into three piles. + + Returns `(travelling, named_dirs, resident)`: + + travelling {token: [count, example, why, glued, where]} -- names in + a shape that gets copied. Keyed on the whole delimited + TOKEN the name sits in, not on the line, because the unit + that travels is a string, not an occurrence: forty + mentions of one parameter name are one thing to decide + about, and forty lines of it is the wall this mode exists + to avoid printing. `glued` is True when the token is + larger than the name itself -- the name buried inside an + identifier, a filename, a piece mark. That is the shape a + human reader skims past, so it sorts to the top. + named_dirs {(why, text): [count, example]} -- names in the tree's own + file and folder names, aggregated by the name. A job + folder named after the job produces one of these for every + file underneath it; 380 identical lines would drown + everything above. + resident {path: count} -- everything else, reported as numbers. + Never listed. A producer's name in a sentence about that + producer, in the repository that documents them, is the + file working correctly. + """ + if listed is None: + listed = private_files()[0] + travelling, named_dirs, resident = {}, {}, {} + for rel in listed: + slashed = rel.replace('\\', '/') + for pat, why in pats: + m = pat.search(slashed) + if not m: + continue + if why in FILENAME_CATEGORIES and \ + _is_placeholder_filename(m.group(0), placeholders): + continue + entry = named_dirs.setdefault((why, m.group(0)[:40]), [0, slashed]) + entry[0] += 1 + try: + with open(os.path.join(ROOT, rel), encoding='utf-8', + errors='replace') as fh: + lines = fh.readlines() + except OSError: + continue + kind = _file_kind(rel) + in_fence = False + for n, line in enumerate(lines, 1): + if kind == 'doc' and _FENCE.match(line): + in_fence = not in_fence + continue + for pat, why in pats: + m = pat.search(line) + if not m: + continue + if why in FILENAME_CATEGORIES and \ + _is_placeholder_filename(m.group(0), placeholders): + continue + where = _travels(kind, line, m.start(), why, in_fence) + if where is None: + resident[slashed] = resident.get(slashed, 0) + 1 + continue + if where == 'structural': + # The pattern already matched the whole string that + # identifies somebody -- a user path, an address, a model + # filename. Widening that to the surrounding token only + # chops it at the first space and prints half of it. + token = m.group(0) + else: + token = _token_at(line, m.start()) \ + .strip('`\'"*,.:;()[]{}') or m.group(0) + glued = token.lower() != m.group(0).lower() + # Keyed on the token alone, not on (where, token): one string + # is one thing to decide about, and the same identifier + # quoted in a fence in one note, inline in another and in a + # scratch script in a third is still that one string. The + # first `where` seen labels it, and structural patterns run + # before terms, so the more specific label wins. + entry = travelling.setdefault( + token[:70], + [0, '{0}:{1}'.format(slashed, n), why, glued, where]) + entry[0] += 1 + return travelling, named_dirs, resident + + +def report_private(listed, dropped, travelling, named_dirs, resident, + show=30) -> None: + """Print the three piles: the decidable one first, the expected ones as + numbers. Every section is capped, because a section that can grow without + limit is a section that stops being read.""" + skipped = sum(dropped.values()) + print('\nprivate tree -- {0} gitignored file(s) read, {1} not read' + .format(len(listed), skipped)) + if skipped: + print(' not read: ' + ', '.join( + '{0} {1}'.format(v, k) for k, v in sorted(dropped.items()) if v)) + + if travelling: + # Glued first: the name buried inside a longer identifier is the one + # a human eye slides over. A bare mention in a code span is still a + # word somebody can see is a name. + ordered = sorted(travelling.items(), + key=lambda kv: (not kv[1][3], -kv[1][0], kv[0])) + glued_n = sum(1 for _, v in ordered if v[3]) + print('\nTRAVELLING -- {0} distinct string(s) in a shape that gets ' + 'copied, {1} of them with the name glued into a longer token. ' + 'Legitimate where they are; the question is only whether one ' + 'has already been pasted somewhere tracked:' + .format(len(ordered), glued_n)) + for token, (count, example, why, glued, where) in ordered[:show]: + print(_safe(' {0}{1}: {2} -- {3} x{4}, e.g. {5}'.format( + '*' if glued else ' ', where, token, why, count, example))) + if len(ordered) > show: + print(' ... and {0} more distinct string(s)' + .format(len(ordered) - show)) + + if named_dirs: + ordered = sorted(named_dirs.items(), key=lambda kv: (-kv[1][0], kv[0])) + print('\nnamed paths -- {0} name(s) in the private tree\'s own file ' + 'and folder names. Nothing to fix here, but a tracked file that ' + 'quotes one of these paths publishes it:'.format(len(ordered))) + for (why, text), (count, example) in ordered[:show]: + print(_safe(' {0}: {1} -- in {2} path(s), e.g. {3}' + .format(why, text, count, example))) + if len(ordered) > show: + print(' ... and {0} more'.format(len(ordered) - show)) + + if resident: + total = sum(resident.values()) + ordered = sorted(resident.items(), key=lambda kv: (-kv[1], kv[0])) + print('\nresident -- {0} match(es) in {1} file(s), not listed. A name ' + 'in prose, or in a data value, is these files doing their job:' + .format(total, len(ordered))) + for rel, count in ordered[:show]: + print(_safe(' {0:>6} {1}'.format(count, rel))) + if len(ordered) > show: + print(' ... and {0} more file(s)'.format(len(ordered) - show)) + + # This mode has no verdict and deliberately returns 0 whatever it finds: + # see the paragraph above `main`'s --private branch. + print('\nThis is an inventory, not a gate. Nothing above blocks a commit, ' + 'and nothing above is wrong for being where it is -- the question ' + 'each row asks is whether that exact string has already been ' + 'copied into a tracked file. Check one with:\n' + ' git grep -i -- ""') + + +# The interpreter is baked in rather than left as bare `python`, and that is +# not fussiness. On Windows `python` usually resolves to the Microsoft Store +# alias in WindowsApps: a stub that prints "Python was not found" and exits +# non-zero. The hook then blocks every commit on this repository with a +# message about the Store, which looks nothing like a scrub failure and sends +# you looking in the wrong place entirely. +# +# A hook is per-clone and not part of the repository, so pinning the +# interpreter that installed it is exactly the right scope. The fallback chain +# is there for a clone whose Python later moves. +HOOK = """#!/bin/sh +# Installed by tools/scrub.py --install-hook. Blocks a commit carrying a +# client name. Bypass with: git commit --no-verify +for py in "{python}" python3 python; do + case "$py" in *WindowsApps*|"") continue;; esac + command -v "$py" >/dev/null 2>&1 || [ -x "$py" ] || continue + exec "$py" tools/scrub.py --staged +done +echo "scrub: no Python found. Reinstall the hook with:" >&2 +echo " python tools/scrub.py --install-hook" >&2 +exit 1 +""" + + +def install_hook() -> int: + """Put the worktree scan in front of every commit on this clone. + + Hooks are not part of a repository, so this has to be run once per clone + -- including on the office machine after cloning. + """ + hooks = os.path.join(git('rev-parse', '--git-dir').strip() or '.git', + 'hooks') + if not os.path.isdir(hooks): + os.makedirs(hooks) + path = os.path.join(hooks, 'pre-commit') + with open(path, 'w', encoding='utf-8', newline='\n') as fh: + fh.write(HOOK.format(python=sys.executable.replace('\\', '/'))) + os.chmod(path, 0o755) + print('installed pre-commit hook: {0}\n using {1}' + .format(path, sys.executable)) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--worktree', action='store_true', + help='skip the history scan') + ap.add_argument('--staged', action='store_true', + help='what the pre-commit hook runs: hits in STAGED files ' + 'block the commit, hits in untracked files are ' + 'reported but do not. An untracked file cannot reach ' + 'history without being staged first, and blocking on ' + 'one stops work that has nothing to do with the ' + 'commit -- which is how a gate ends up bypassed with ' + '--no-verify, and then it guards nothing.') + ap.add_argument('--private', action='store_true', + help='audit the gitignored tree (QNA/, knowledge/plant/, ' + 'knowledge/job/) -- the other repository, where a ' + 'name is allowed to be. An inventory, not a gate: ' + 'never run by the hook and it always exits 0. It ' + 'lists only names sitting in a shape that travels ' + '(a fenced block, a proposed key, a quoted literal, ' + 'a path, a name glued into a longer identifier), ' + 'aggregated by the distinct string, and counts the ' + 'rest without listing them -- a wall of expected ' + 'hits is a report nobody reads twice.') + ap.add_argument('--terms', default=None, + help="terms file to scan for (default .scrub-terms). CI " + "passes .scrub-terms.example, the committed generic " + "sample file, so it never needs the real denylist.") + ap.add_argument('--install-hook', action='store_true', + help='install as a pre-commit hook on this clone') + args = ap.parse_args() + + if args.install_hook: + return install_hook() + + terms = load_terms(args.terms) + if not terms: + print('note: no .scrub-terms file, only structural patterns checked.\n' + ' copy .scrub-terms.example and add your client names.', + file=sys.stderr) + pats = patterns(terms) + + placeholders = load_placeholders() + if not placeholders: + print("note: could not find CLAUDE.md's placeholder table -- the " + 'model/family-filename patterns will flag the sanctioned ' + 'placeholder name too, with nothing exempted.', + file=sys.stderr) + + if args.private: + listed, dropped = private_files() + travelling, named_paths, resident = scan_private( + pats, listed, placeholders) + report_private(listed, dropped, travelling, named_paths, resident) + # Always 0, deliberately -- see the paragraph in the module docstring. + # Every string this prints carries a denylist term or a structural + # pattern, so a copy of one into a tracked file is already a --worktree + # failure; there is no leak here for an exit code to catch that is not + # caught one directory out. What is here is the raw material, and a + # mode that failed on the raw material would fail on every run. + return 0 + + loose = [] + if args.staged: + worktree = scan_worktree(pats, staged_files(), placeholders) + loose = scan_worktree(pats, untracked_files(), placeholders) + history = [] + else: + worktree = scan_worktree(pats, placeholders=placeholders) + history = [] if args.worktree else scan_history(pats, placeholders) + + label_now = 'staged for this commit' if args.staged else 'working tree' + for label, hits in ((label_now, worktree), ('git history', history)): + if hits: + print('\n{0} -- {1} hit(s):'.format(label, len(hits))) + for hit in hits: + print(' ' + hit) + + if loose: + # Not fatal, and said every time until it is dealt with. These are one + # 'git add -A' away from being the blocking kind. + print('\nWARNING -- {0} hit(s) in untracked files. Not committed, and ' + 'not blocking this commit, but one "git add -A" away from ' + 'being:'.format(len(loose))) + for hit in loose: + print(' ' + hit) + + if not args.staged: + # The blind spot, named out loud in every audit run rather than left + # to be rediscovered. These files are not scanned here and should not + # be -- a hit in them is not a leak -- but "nothing looks at all" is + # how a name sits in a note for six weeks and then gets pasted into a + # module. --private is what looks. + n_private = len(private_files()[0]) + print('\nnote: {0} readable gitignored file(s) were not scanned here ' + '-- QNA/, knowledge/plant/, knowledge/job/, _scratch/. A name ' + 'is allowed in those, so they are not part of this verdict, ' + 'but nothing else looks at them either:\n' + ' python tools/scrub.py --private'.format(n_private)) + + if not worktree and not history: + if args.staged: + print('clean: {0} staged file(s) checked against {1} term(s) + {2} ' + 'structural pattern(s)' + .format(len(staged_files()), len(terms), len(STRUCTURAL))) + return 0 + scope = 'working tree' if args.worktree else 'working tree and history' + print('clean: {0} checked against {1} term(s) + {2} structural pattern(s)' + .format(scope, len(terms), len(STRUCTURAL))) + return 0 + + if history: + print('\nA hit in history is not fixed by editing the file. If nothing ' + 'has been pushed yet, rewrite the history; if it has, the name ' + 'is public and rotating the repo is the only real fix.') + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/setup_machine.py b/tools/setup_machine.py new file mode 100644 index 0000000..10e8319 --- /dev/null +++ b/tools/setup_machine.py @@ -0,0 +1,1075 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Wire this checkout to a private KNOWLEDGE repo, install dependencies, set +environment variables, and PROVE the result with real answers, not exit codes. + + python tools/setup_machine.py --knowledge + python tools/setup_machine.py --check # doctor: verify only + python tools/setup_machine.py --knowledge --force + +WHY THIS EXISTS (2026-09-01) +----------------------------- +"I pulled everything down on another machine and everything I ran errored." +Reproduced from a clean clone with every WILLTOOLS_* variable unset and +APPDATA/LOCALAPPDATA redirected to empty folders -- a genuinely fresh +machine, not just an unconfigured shell on an already-set-up one. Every +failure found that way is described in machine-setup.md's troubleshooting +table (in the private KNOWLEDGE repo) and reproduced here as a check. + +WHAT "VERIFIED" MEANS IN THIS FILE +----------------------------------- +`tools/knowledgelint.py` and `tools/knowledge_drift.py` are correct to +degrade gracefully when the plant/job layers are empty -- "nothing here" is +the honest answer for a lint tool running on a checkout that was never wired. +An INSTALLER asking the same question needs the opposite instinct: an empty +knowledge/plant/ after wiring is supposed to have happened is not "nothing to +report", it is the thing this script exists to catch. So verification here +does not stop at "the command didn't crash" -- it reads the plant layer's own +declared paths and the panellift CLI's own computed numbers, and fails loudly +if they are not there or not right. + +WHAT THIS SCRIPT DOES NOT DO +----------------------------- +Touch `/sessions/` -- that folder is intentional (Claude Code +session transcripts, kept as knowledge on purpose, per periodic backup +commits from the owner), not scratch to be cleaned up. This script only +reads it indirectly, by cloning the repo it lives in; it is verified as +present and correctly checked out, never modified. + +Runs on plain CPython 3.9+, no Revit, no pyRevit, no Claude Code CLI -- +exactly the owner's situation on a machine at home. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REQUIREMENTS = os.path.join(ROOT, 'requirements.txt') +PANELLIFT_DIR = os.path.join(ROOT, 'panellift') + +# The public catalogue every owner checkout carries tracked in git (see the +# .gitignore comment: "catalogue.yaml is the exception and is deliberately +# tracked here"). tools/make_dist.py strips it from a colleague distribution +# and ships this template in its place -- so "the template is here and the +# real file is not" is a clean, checkable marker for "this checkout is a +# stripped trial install", distinct from "this is a dev checkout that simply +# has not been wired to the private repo yet" (which still has the real +# catalogue.yaml, tracked, always). +CATALOGUE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml') +CATALOGUE_TEMPLATE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml.template') + +# A place to record a DELIBERATE install choice, so this script can tell +# "not installed because the machine is broken" from "not installed on +# purpose" -- the same distinction the knowledge layer draws between a bare +# empty key (a value went missing, refused) and an explicit `{}` (looked, and +# none, on purpose, passes). Beside the repo root because it is a property of +# THIS checkout's install, same tier as requirements.txt, not of one knowledge +# layer or one tool. +# +# Format: one `: ` per line, `#` comments and blank lines ignored. +# The only step opt-able today: +# +# panellift: dev-mode +# +# meaning "not pip-installed on purpose while the package is still being +# shaped -- PYTHONPATH=src is the supported route here, not a workaround." +# See .setup-opts.example beside this file's repo root for the annotated +# template; that file is never read, only this exact name is. +OPTS_FILE = os.path.join(ROOT, '.setup-opts') + +# step name -> the modes it accepts. An opt-file line naming a step not in +# this dict, or a mode that step does not accept, is reported as a failure +# rather than silently ignored -- an unrecognised opt is a blindfold nobody +# is checking any more (the same rule tools/knowledge_drift_allow.txt applies +# to its own stale suppression lines), and a script that PROVES its answers +# does not get to shrug at an opt it does not understand. +KNOWN_OPTS = { + 'panellift': ('dev-mode',), +} + +# local relative path (under ROOT) -> private-repo relative path. +# NOT one-to-one: no "knowledge/" prefix on the private side, and qna -> QNA +# changes case. This mapping is the whole reason machine-setup.md's old +# "or point the env vars there instead" advice was incomplete -- the env vars +# only reach two of these four, and tools/knowledge_drift.py reaches neither +# (it walks ROOT/knowledge/plant and ROOT/knowledge/job directly, hardcoded). +LINKS = [ + (os.path.join('knowledge', 'plant'), 'plant'), + (os.path.join('knowledge', 'job'), 'job'), + (os.path.join('knowledge', 'library'), 'library'), + ('QNA', 'qna'), +] + +ENV_VARS = { + # name -> (relative-to-ROOT template, description) + 'WILLTOOLS_KNOWLEDGE': (os.path.join('knowledge', 'plant', 'knowledge.yaml'), + 'plant layer'), + 'WILLTOOLS_JOBS': (os.path.join('knowledge', 'job'), + 'job layer root'), + 'PYTHONIOENCODING': ('utf-8', 'console default is cp1252; this repo prints ' + 'non-ASCII plant names and comments'), +} + +DEPENDENCIES = ['PyYAML>=6.0', 'pytest>=7.0', 'pymupdf>=1.23'] + +REQUIREMENTS_HEADER = """\ +# WillTools host-side (CPython) dependencies -- tools/*.py and both pytest +# suites. NOT the Revit extension: that runs under pyRevit's own IronPython +# and never imports from a site-packages directory. +# +# Written by tools/setup_machine.py (2026-09-01) because nothing in this repo +# named these before that, and a script should not be the only place a +# dependency list lives. Install with: +# python -m pip install -r requirements.txt +# or let tools/setup_machine.py do it. +""" + +# The published ASPIRE Winter 2019 worked example (Brown, "Calculations for +# Handling and Shipping Precast Concrete Deck Panels"), the same fixture +# panellift's own test suite checks itself against. Used here, not to test +# panellift's arithmetic (its own suite does that), but to prove the +# INSTALLED CLI -- the thing a user actually runs -- reaches that arithmetic +# with no PYTHONPATH tricks. +ASPIRE_JOB = { + 'schema': 'panellift/1', + 'panel': { + 'mark': 'ASPIRE-1', 'length_ft': 32.0, 'width_ft': 10.0, + 'thickness_in': 8.75, 'unit_weight_pcf': 150.0, + 'fci_psi': 4000.0, 'fc_psi': 5000.0, + 'lifters': [ + {'mark': 'L1', 'family': 'TEST_LIFTER', 'x': 6.627416997969522, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L2', 'family': 'TEST_LIFTER', 'x': 6.627416997969522, + 'y': 7.5, 'capacity_lb': 12000.0}, + {'mark': 'L3', 'family': 'TEST_LIFTER', 'x': 25.372583002030478, + 'y': 2.5, 'capacity_lb': 12000.0}, + {'mark': 'L4', 'family': 'TEST_LIFTER', 'x': 25.372583002030478, + 'y': 7.5, 'capacity_lb': 12000.0}, + ], + }, + 'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0}, + 'stages': [{'name': 'stripping', 'multiplier': 1.3, 'fc_psi': 4000.0}], +} +ASPIRE_PUBLISHED_PSI = 244.0 +ASPIRE_TOLERANCE_PSI = 1.5 + + +# --------------------------------------------------------------- reporting + +class Step: + """One line of PASS/FAIL/SKIP output, with enough in it to act on. + + SKIP is a third state, not a shade of PASS or FAIL: it means "this step + was deliberately not run, and here is exactly why" (per a declared + .setup-opts choice), as opposed to PASS ("ran, and it's right") or FAIL + ("ran, or should have, and it's wrong"). A permanently-FAILing step gets + ignored -- the day a real failure lands on that line, nobody looks -- and + an ignored check is the same disease as a check that always passes. + Collapsing SKIP into either PASS or FAIL would recreate that problem, so + it is tracked and printed separately, and the summary line always counts + it, the same discipline knowledge_drift_allow.txt applies to its own + suppression count: a shrinking report must never be mistaken for a + healthy one. + """ + + def __init__(self, out): + self._out = out + self.failures = [] + self.passed = [] + self.skipped = [] + + def ok(self, name, detail=''): + self._out('PASS {0}{1}'.format(name, ' -- ' + detail if detail else '')) + self.passed.append(name) + + def fail(self, name, error, fix): + self._out('FAIL {0}'.format(name)) + self._out(' error: {0}'.format(error)) + self._out(' fix: {0}'.format(fix)) + self.failures.append(name) + + def skip(self, name, reason): + self._out('SKIP {0} -- {1}'.format(name, reason)) + self.skipped.append(name) + + def info(self, msg): + self._out(' ' + msg) + + +def _run(cmd, **kw): + kw.setdefault('capture_output', True) + kw.setdefault('text', True) + kw.setdefault('encoding', 'utf-8') + kw.setdefault('errors', 'replace') + return subprocess.run(cmd, **kw) + + +def _ps(script): + """Run a PowerShell snippet, return CompletedProcess. Avoids cmd.exe's + quoting rules entirely -- every path is passed through as a PowerShell + single-quoted literal, doubling embedded quotes, which is the one escape + rule that needs applying instead of guessing at cmd's.""" + return _run(['powershell', '-NoProfile', '-NonInteractive', '-Command', script]) + + +def _ps_quote(s): + return "'" + s.replace("'", "''") + "'" + + +# ------------------------------------------------------------------- opt-file + +def load_opts(step): + """Read `.setup-opts` -- deliberate install-choice overrides -- and + return `{step_name: mode}`. Absence is not an error: no file means no + opts declared, which is today's behaviour, unchanged; every step runs + for real and is judged for real. + + Every line that IS present is checked against KNOWN_OPTS. A step name + this script does not recognise, or a mode that step does not accept, is + reported as a FAILURE here (not silently dropped) -- the blindfold-left- + behind rule tools/knowledge_drift_allow.txt applies to its own stale + suppression lines: an opt file only stays trustworthy if a line that no + longer matches anything is loud, not quiet. An empty value (`panellift:` + with nothing after the colon) is refused the same way a bare empty + knowledge key is -- it cannot be told apart from "I meant to write a + mode and forgot", which is a mistake, not a deliberate absence. + """ + opts = {} + if not os.path.isfile(OPTS_FILE): + return opts + name = os.path.basename(OPTS_FILE) + with open(OPTS_FILE, encoding='utf-8') as fh: + text = fh.read() + for num, raw in enumerate(text.splitlines(), 1): + line = raw.split('#', 1)[0].strip() + if not line: + continue + if ':' not in line: + step.fail('{0}:{1}'.format(name, num), + 'unreadable line {0!r} -- expected ": "' + .format(raw.strip()), + 'fix or remove that line in {0}'.format(OPTS_FILE)) + continue + key, _, value = line.partition(':') + key = key.strip() + value = value.strip() + if not value: + step.fail('{0}:{1}'.format(name, num), + '"{0}:" has no value -- an unstated choice is not a ' + 'deliberate one'.format(key), + 'write "{0}: ", or delete the line if it was ' + 'not meant to be there'.format(key)) + continue + if key not in KNOWN_OPTS: + step.fail('{0} entry: {1}'.format(name, key), + 'stale -- {0!r} names no step this script knows ' + 'about (known: {1})' + .format(key, ', '.join(sorted(KNOWN_OPTS))), + 'delete the line from {0}, or fix the step name -- a ' + 'stale opt is a blindfold nobody is checking any ' + 'more'.format(OPTS_FILE)) + continue + if value not in KNOWN_OPTS[key]: + step.fail('{0} entry: {1}'.format(name, key), + '{0!r} is not a mode {1!r} understands (known: {2})' + .format(value, key, ', '.join(KNOWN_OPTS[key])), + 'use one of: {0}'.format(', '.join(KNOWN_OPTS[key]))) + continue + opts[key] = value + return opts + + +# ----------------------------------------------------------------- private repo + +def _looks_like_url(s): + return s.startswith(('http://', 'https://', 'git@', 'ssh://', 'file://')) + + +def dist_layout_detected(): + """True on a stripped colleague distribution: the public catalogue every + owner checkout carries is absent AND the template that stands in for it + on a trial install is present. Both conditions, not just the first -- + an owner checkout mid-repair with a temporarily missing file should not + silently reclassify itself as a trial install.""" + return os.path.isfile(CATALOGUE_TEMPLATE) and not os.path.isfile(CATALOGUE) + + +# `git status --porcelain` (v1) lines are always `XY path`, or +# `XY orig -> dest` for a rename -- a fixed two-character code, one space, +# then the path. X is the index (staged) state, Y is the worktree state +# relative to the index. Per `git status`'s own long-format table: +# +# X Y meaning +# ------------------------------------------------- +# [ MARC] index matches HEAD, or a staged change +# [ MARC] M work tree changed since index +# [ MARC] D deleted in work tree <-- TRUNCATION EVIDENCE +# ? ? untracked -- git never tracked this path +# D deleted from index (staged deletion, e.g. `git rm`) +# [DAU] [UDA] unmerged / conflict markers -- cannot arise on a +# plain clone+status path with no merge in progress +# +# Only `Y == 'D'` with `X` one of `' ', 'M', 'A', 'R', 'C'` means what the +# long-path truncation bug actually produces: git's index still names the +# path (however that entry got there), but the file never made it onto +# disk. That is a TRACKED file missing from the working tree, and it is the +# only shape of porcelain output this completeness guard may fail on. +# +# Everything else is ordinary uncommitted work, not corruption: +# '??' untracked -- nothing was ever checked out here, +# so it cannot be evidence of a checkout that lost +# something. +# 'D ' (X='D', Y=' ') a DELIBERATE staged deletion (`git rm`, or +# `git add` after deleting by hand) -- index and +# worktree already agree the file is gone on +# purpose. +# 'M ', ' M', 'MM', 'A ', 'R ', 'C ', ... (no 'D' in Y) +# ordinary modified/staged/renamed work. +# any code containing 'U', or 'DD'/'AA' +# merge-conflict markers -- not reachable from a +# plain `git status` outside a merge, so treated +# as ordinary "other" rather than guessed at. +_TRUNCATION_X = (' ', 'M', 'A', 'R', 'C') + + +def _parse_porcelain_status(porcelain_output): + """Classify `git status --porcelain` output into what the private-repo + completeness guard cares about vs. what is none of its business. + + Returns `(missing_tracked, untracked, other)`, three lists of path + strings (a rename reports its destination path). `missing_tracked` is + the only one that should ever fail the completeness check -- see the + code table above. + """ + missing_tracked = [] + untracked = [] + other = [] + for line in porcelain_output.splitlines(): + if not line: + continue + code = line[:2] + path = line[3:] + if ' -> ' in path: + path = path.split(' -> ', 1)[1] + x, y = code[0], code[1] + if code == '??': + untracked.append(path) + elif x == 'U' or y == 'U' or code in ('DD', 'AA'): + other.append(path) + elif y == 'D' and x in _TRUNCATION_X: + missing_tracked.append(path) + else: + other.append(path) + return missing_tracked, untracked, other + + +def _report_incidental_status(step, missing, untracked, other, where): + """Log the untracked/modified paths a completeness check found but did + not fail on -- informational only, so a reader of the PASS line is not + left wondering whether they were silently ignored.""" + if untracked or other: + step.info('{0} untracked / {1} modified path(s) in {2}, not the ' + "installer's business".format(len(untracked), len(other), where)) + + +def resolve_private_repo(step, arg, dest_hint=None, force=False): + """Return an absolute path to a usable, COMPLETE checkout of KNOWLEDGE, + or None (a failure already recorded on `step`). + + Cloning is verified, not assumed: a partial checkout that git reported an + error on is exactly the failure this project keeps getting bitten by -- + the KNOWLEDGE repo carries a sessions/ folder of Claude Code transcripts + (intentional, not a mistake -- see module docstring) whose filenames + encode long absolute paths, and a plain `git clone` to a moderately deep + directory fails partway through with 'Filename too long', leaving a + checkout git itself calls broken: 'Clone succeeded, but checkout + failed.' `git status --porcelain` on a truly complete fresh clone prints + nothing at all -- but the truncation this guards against is not the only + thing that can put output there. An untracked job folder or a locally + modified tracked file are ordinary uncommitted work and never indicate a + truncated checkout; only a TRACKED file missing from the working tree + (porcelain worktree status `D`) does. See `_parse_porcelain_status`. + """ + if not arg: + step.fail('private repo location', + 'no --knowledge argument and nothing already wired', + 'pass --knowledge ') + return None + + if not _looks_like_url(arg): + path = os.path.abspath(arg) + if not os.path.isdir(path): + step.fail('private repo location', + '{0} does not exist and is not a git URL'.format(path), + 'pass an existing checkout, or a URL to clone') + return None + rc = _run(['git', '-C', path, 'status', '--porcelain']) + if rc.returncode != 0: + step.fail('private repo location', + '{0} is not a git working tree ({1})'.format(path, rc.stderr.strip()), + 'pass the KNOWLEDGE repo checkout itself') + return None + missing, untracked, other = _parse_porcelain_status(rc.stdout) + if missing: + step.fail('private repo checkout is complete', + '{0} has {1} tracked path(s) missing from the working ' + 'tree per git status'.format(path, len(missing)), + "this usually means an earlier clone was interrupted " + "(long-path failure or otherwise) -- run " + "'git -C \"{0}\" status' to see what is missing, then " + "either 're-clone with git -c core.longpaths=true' or " + "'git -C \"{0}\" restore --source=HEAD :/' to retry the " + "checkout in place".format(path)) + return None + step.ok('private repo location', path) + _report_incidental_status(step, missing, untracked, other, path) + return path + + dest = dest_hint or os.path.join(os.path.dirname(ROOT), 'KNOWLEDGE') + if os.path.isdir(dest) and not force: + rc = _run(['git', '-C', dest, 'status', '--porcelain']) + if rc.returncode == 0: + missing, untracked, other = _parse_porcelain_status(rc.stdout) + if not missing: + step.ok('private repo location', dest + ' (already cloned, verified complete)') + _report_incidental_status(step, missing, untracked, other, dest) + return dest + if force: + shutil.rmtree(dest) + else: + step.fail('private repo location', + '{0} exists and is not a clean complete checkout'.format(dest), + 'pass --force to re-clone, or point --knowledge at a ' + 'different destination') + return None + + clone = _run(['git', '-c', 'core.longpaths=true', 'clone', arg, dest]) + if clone.returncode != 0: + step.fail('private repo clone', + clone.stderr.strip() or 'git clone failed, exit {0}'.format(clone.returncode), + 'check the URL and credentials (Windows Credential Manager ' + 'must have a token cached); this already used ' + '-c core.longpaths=true, which is the fix for the long-path ' + 'failure this repo is known to hit') + return None + + status = _run(['git', '-C', dest, 'status', '--porcelain']) + missing, untracked, other = _parse_porcelain_status(status.stdout) + if missing: + step.fail('private repo checkout is complete', + 'git reported success but {0} tracked path(s) are missing ' + 'from the working tree per git status -- this is the ' + 'exact silent-partial-clone failure mode this script ' + 'exists to catch'.format(len(missing)), + "run: git -C \"{0}\" status to see what's missing, then " + "git -C \"{0}\" restore --source=HEAD :/ to complete the " + "checkout in place (already cloned with core.longpaths=true, " + "so a short destination path is the next thing to try if " + "restore also fails)".format(dest)) + return None + + step.ok('private repo clone', '{0} <- {1} (verified complete: git status clean)'.format(dest, arg)) + _report_incidental_status(step, missing, untracked, other, dest) + return dest + + +# ----------------------------------------------------------------------- wiring + +def _junction_target(path): + """The resolved target of a junction/symlink at `path`, or None if `path` + is not a reparse point (including: does not exist, or is an ordinary + directory). os.path.islink() returns False for NTFS junctions, so this + goes through PowerShell rather than trusting the os module here.""" + if not os.path.exists(path): + return None + rc = _ps('(Get-Item -LiteralPath {0} -Force).Target'.format(_ps_quote(path))) + target = rc.stdout.strip() + return target or None + + +def _same_path(a, b): + return os.path.normcase(os.path.normpath(a)) == os.path.normcase(os.path.normpath(b)) + + +def wire_links(step, private_root, force=False): + for local_rel, private_rel in LINKS: + local = os.path.join(ROOT, local_rel) + target = os.path.join(private_root, private_rel) + name = 'link {0} -> {1}'.format(local_rel, private_rel) + + if not os.path.isdir(target): + step.fail(name, '{0} does not exist in the private repo'.format(target), + 'the private repo checkout is missing this folder -- ' + 'confirm it is the right repo/branch') + continue + + existing = _junction_target(local) + if existing: + if _same_path(existing, target): + step.ok(name, 'already linked, unchanged') + continue + if not force: + step.fail(name, + '{0} is already a junction, pointing at {1} instead' + .format(local, existing), + 'pass --force to relink, or remove the existing ' + 'junction by hand if it points somewhere deliberate') + continue + _ps('Remove-Item -LiteralPath {0} -Force'.format(_ps_quote(local))) + + elif os.path.isdir(local): + # A real directory sits here already. knowledge/plant, knowledge/job + # and knowledge/library are entirely gitignored so this is always + # safe to replace. QNA/ is the one exception: QNA/README.md is + # TRACKED (the gitignore carves out exactly that one file) -- after + # linking, QNA/README.md resolves through the junction to the + # private repo's own qna/README.md, which is expected and fine + # (both exist and both are meant to explain the same convention), + # but `git status` may show it as modified if the wording differs. + # That is not a bug; it is two copies of one sentence, and this is + # the tradeoff of not vendoring the private repo's README a second + # time. + entries = os.listdir(local) + trivial = set(entries) <= {'README.md'} + if not trivial and not force: + step.fail(name, + '{0} already exists with real content: {1}' + .format(local, ', '.join(entries[:5])), + 'pass --force to replace it (only do this if you ' + 'are sure nothing here is unique -- it will be ' + 'deleted, not merged)') + continue + shutil.rmtree(local) + if local_rel == 'QNA' and trivial and 'README.md' in entries: + step.info('QNA/README.md was tracked; after linking it now ' + 'reads through to the private repo\'s qna/README.md ' + '-- git status may show it as modified, which is ' + 'expected, not a wiring failure') + + rc = _ps('New-Item -ItemType Junction -Path {0} -Target {1} -Force' + .format(_ps_quote(local), _ps_quote(target))) + if rc.returncode != 0: + step.fail(name, rc.stderr.strip() or 'New-Item failed', + 'run as the same user that owns both directories; ' + 'junctions need no admin rights but do need both ' + 'paths on a local (non-network) volume') + continue + step.ok(name, 'junction created') + + +# ------------------------------------------------------------------- env vars + +_NO_KNOWLEDGE_PATH_VARS = ('WILLTOOLS_KNOWLEDGE', 'WILLTOOLS_JOBS') + + +def set_env_vars(step, no_knowledge=False): + for name, (rel_or_value, desc) in ENV_VARS.items(): + if no_knowledge and name in _NO_KNOWLEDGE_PATH_VARS: + # Do NOT point these at a path that will never exist. Pointing + # WILLTOOLS_KNOWLEDGE at a missing file is not the same state as + # never setting it at all -- knowledge.load()'s own comment says + # so: "if somebody has explicitly set WILLTOOLS_KNOWLEDGE and the + # file it names is not there, that is a definite finding" and + # sets `_plant_env_missing`. On a genuinely knowledge-less trial + # install that finding would be noise on every single run, for a + # state that is expected rather than wrong -- so this script + # leaves the variable unset, which is the "ordinary case" branch + # the same comment describes as quiet on purpose. + step.skip('env {0}'.format(name), + 'no knowledge layers declared -- left unset so ' + 'knowledge.load() treats this as the ordinary ' + '"nothing configured" case rather than "pointed ' + 'somewhere and found nothing"') + continue + value = rel_or_value if name == 'PYTHONIOENCODING' else os.path.join(ROOT, rel_or_value) + current = _run(['powershell', '-NoProfile', '-NonInteractive', '-Command', + '[Environment]::GetEnvironmentVariable({0},"User")'.format(_ps_quote(name))]) + already = current.stdout.strip() == value + rc = _ps('[Environment]::SetEnvironmentVariable({0},{1},"User")' + .format(_ps_quote(name), _ps_quote(value))) + os.environ[name] = value # so this same process's verification sees it too + if rc.returncode != 0: + step.fail('env {0}'.format(name), rc.stderr.strip(), + 'set it by hand: setx {0} "{1}"'.format(name, value)) + continue + step.ok('env {0}'.format(name), + '{0}{1}'.format(value, ' (unchanged)' if already else ' (set)')) + step.info('{0} -- {1}'.format(name, desc)) + step.info('durable for NEW shells; this process has them too for the ' + 'checks below, no restart needed to finish this run') + + +# --------------------------------------------------------------- dependencies + +def write_requirements(step): + if os.path.isfile(REQUIREMENTS): + step.ok('requirements.txt', 'already present, not overwritten') + return + with open(REQUIREMENTS, 'w', encoding='utf-8') as fh: + fh.write(REQUIREMENTS_HEADER) + for dep in DEPENDENCIES: + fh.write(dep + '\n') + step.ok('requirements.txt', 'written: ' + ', '.join(DEPENDENCIES)) + + +def _panellift_pythonpath_route(step, verb): + """Prove `PYTHONPATH=src` reaches panellift's CLI -- the supported route + for a checkout that has deliberately not `pip install -e`d it. `verb` is + 'runs' (full setup, actually installing nothing) so the PASS line reads + naturally either way this is called from.""" + src = os.path.join(PANELLIFT_DIR, 'src') + env = dict(os.environ) + env['PYTHONPATH'] = src + rc = _run([sys.executable, '-m', 'panellift', '--help'], env=env) + if rc.returncode != 0: + step.fail('panellift {0} via PYTHONPATH=src (dev mode)'.format(verb), + (rc.stdout + rc.stderr).strip()[-2000:], + 'confirm {0} contains a panellift package importable this ' + 'way, or drop the "panellift: dev-mode" line in {1} to ' + 'install normally instead'.format(src, OPTS_FILE)) + return False + step.ok('panellift {0} via PYTHONPATH=src (dev mode)'.format(verb), + 'verified route: PYTHONPATH={0} python -m panellift --help' + .format(src)) + return True + + +def install_dependencies(step, opts=None): + opts = opts or {} + write_requirements(step) + rc = _run([sys.executable, '-m', 'pip', 'install', '-r', REQUIREMENTS]) + if rc.returncode != 0: + step.fail('pip install -r requirements.txt', rc.stderr.strip()[-2000:], + 'check network access / pip index; retry by hand: ' + 'python -m pip install -r requirements.txt') + return False + step.ok('pip install -r requirements.txt') + + if opts.get('panellift') == 'dev-mode': + step.info('panellift: dev-mode declared in {0} -- skipping pip ' + 'install -e panellift, verifying the PYTHONPATH route ' + 'instead'.format(OPTS_FILE)) + return _panellift_pythonpath_route(step, 'runs') + + rc = _run([sys.executable, '-m', 'pip', 'install', '-e', PANELLIFT_DIR]) + if rc.returncode != 0: + step.fail('pip install -e panellift', rc.stderr.strip()[-2000:], + 'retry by hand: python -m pip install -e "{0}"'.format(PANELLIFT_DIR)) + return False + step.ok('pip install -e panellift') + return True + + +# ------------------------------------------------------------------ verification + +def _populated(path, min_bytes=64): + """`path` is more than an empty (or link-to-empty) directory: at least one + non-backup file with real content underneath it. A junction that resolves + but resolves to nothing is exactly the failure mode a lint tool is right + to shrug at and an installer is wrong to.""" + if not os.path.isdir(path): + return False, 0, 0 + total = 0 + n = 0 + for dirpath, dirnames, filenames in os.walk(path): + dirnames[:] = [d for d in dirnames if not d.startswith('.bak')] + for fn in filenames: + if '.bak-' in fn or fn.endswith(('.bak',)): + continue + fp = os.path.join(dirpath, fn) + try: + size = os.path.getsize(fp) + except OSError: + continue + total += size + if size > 0: + n += 1 + return (n > 0 and total >= min_bytes), n, total + + +def verify_plant_layer_populated(step, no_knowledge=False): + if no_knowledge: + step.skip('plant layer is populated', + 'no knowledge layers declared -- checks that need them ' + 'stand down (expected for a trial install). Ask the ' + 'owner for the plant/job layers and re-run with ' + '--knowledge to check them for real.') + step.skip('job layer is populated', + 'no knowledge layers declared -- same as the plant layer above') + return True + + plant = os.path.join(ROOT, 'knowledge', 'plant') + ok, n, total = _populated(plant) + if not ok: + step.fail('plant layer is populated', + '{0} is linked/present but has {1} file(s), {2} byte(s) of ' + 'real content'.format(plant, n, total), + 'the link target is empty, or the private repo checkout at ' + 'that path never had plant data -- confirm --knowledge ' + 'pointed at the right checkout') + return False + step.ok('plant layer is populated', '{0} file(s), {1} bytes'.format(n, total)) + + job = os.path.join(ROOT, 'knowledge', 'job') + ok, n, total = _populated(job) + if not ok: + step.fail('job layer is populated', + '{0} has {1} file(s), {2} byte(s)'.format(job, n, total), + 'same as above -- link target is empty or wrong') + return False + step.ok('job layer is populated', '{0} file(s), {1} bytes'.format(n, total)) + return True + + +def verify_knowledgelint(step, no_knowledge=False): + rc = _run([sys.executable, os.path.join(ROOT, 'tools', 'knowledgelint.py')]) + if rc.returncode != 0: + step.fail('knowledgelint.py', (rc.stdout + rc.stderr).strip()[-2000:], + 'a FAIL line names the broken YAML file; a PyYAML import ' + 'error means dependencies did not install -- rerun this ' + 'script, or: python -m pip install -r requirements.txt') + return False + if no_knowledge and 'no knowledge layers configured' in rc.stdout: + step.skip('knowledgelint.py', + 'no knowledge layers -- nothing to lint (expected for a ' + 'trial install)') + return True + n = None + for line in rc.stdout.splitlines(): + if line.strip().endswith('layer file(s) parse.'): + try: + n = int(line.strip().split()[0]) + except ValueError: + pass + if n is None: + step.fail('knowledgelint.py', 'ran clean but printed no summary line -- unexpected output', + 'run it by hand and read the output: python tools/knowledgelint.py') + return False + if n < 3: + if no_knowledge: + step.skip('knowledgelint.py', + '{0} layer file(s) parse -- fewer than the full ' + 'catalogue+plant+job set, expected with no knowledge ' + 'layers declared'.format(n)) + return True + step.fail('knowledgelint.py', + 'only {0} layer file(s) parsed -- expected the catalogue plus ' + 'at least a plant and a job layer'.format(n), + 'the wiring step likely failed or linked an empty folder; ' + 're-run this script without --check') + return False + step.ok('knowledgelint.py', '{0} layer file(s) parse, 0 FAIL'.format(n)) + return True + + +def verify_libindex(step): + script_path = os.path.join(ROOT, 'tools', 'libindex.py') + if not os.path.isfile(script_path): + # tools/libindex.py manages the owner's private PDF library and + # never ships in a colleague distribution (see tools/make_dist.py's + # manifest) -- absence here is the tool not being part of this + # checkout at all, not a broken install. Without this guard, + # `python ` fails with an unhelpful interpreter error + # that this step would otherwise report as a mysterious FAIL. + step.skip('libindex.py status', + 'tools/libindex.py is not part of this distribution') + return True + rc = _run([sys.executable, script_path, 'status']) + out = (rc.stdout + rc.stderr).strip() + # Two coherent outcomes: no index built yet (fine -- building needs the + # PDF drop folder populated, which is not part of a bare install), or a + # real status report. Anything else -- a traceback -- is a failure. + if 'no index built yet' in out or 'ledger:' in out or out.startswith('index:'): + step.ok('libindex.py status', out.splitlines()[0] if out else '(empty)') + return True + step.fail('libindex.py status', out[-2000:], + 'unexpected output shape -- run by hand: python tools/libindex.py status') + return False + + +def verify_panellift(step, tmp_dir, opts=None): + opts = opts or {} + if opts.get('panellift') == 'dev-mode': + # The installed-CLI question does not apply here on purpose -- dev + # mode means panellift was never pip-installed, so asking "does the + # installed CLI run with no PYTHONPATH" would FAIL every single time, + # for a reason that has nothing to do with today's checkout. That is + # exactly the permanently-failing check the whole opt-file exists to + # retire: reported once as SKIP, with the file that explains it named + # in the line itself, rather than as a FAIL nobody has to read twice + # to learn to ignore. The route this checkout actually uses IS + # verified -- during `install_dependencies` on a real run (see + # `_panellift_pythonpath_route`); `--check` verifies nothing and + # changes nothing by design, so it reports the skip alone. + step.skip('panellift CLI runs installed (no PYTHONPATH)', + 'dev mode, per {0}'.format(os.path.basename(OPTS_FILE))) + return True + + job_path = os.path.join(tmp_dir, 'aspire_job.json') + report_path = os.path.join(tmp_dir, 'aspire_report.html') + with open(job_path, 'w', encoding='utf-8') as fh: + json.dump(ASPIRE_JOB, fh) + + # Deliberately NOT setting PYTHONPATH here: this is the same command a + # user would type, and it must work off the `pip install -e` above alone. + rc = _run([sys.executable, '-m', 'panellift', job_path, '-o', report_path, '--json']) + if rc.returncode != 0: + step.fail('panellift CLI runs installed (no PYTHONPATH)', + (rc.stdout + rc.stderr).strip()[-2000:], + '"No module named panellift" means the editable install did ' + 'not take -- retry: python -m pip install -e "{0}"' + .format(PANELLIFT_DIR)) + return False + + try: + result = json.loads(rc.stdout) + peak = result['stages'][0]['peak_tension_psi'] + verdict = result['verdict'] + except Exception as exc: + step.fail('panellift CLI runs installed (no PYTHONPATH)', + 'ran, but output did not parse as expected: {0}'.format(exc), + 'run by hand and inspect: python -m panellift "{0}" -o out.html --json' + .format(job_path)) + return False + + step.ok('panellift CLI runs installed (no PYTHONPATH)') + + if abs(peak - ASPIRE_PUBLISHED_PSI) > ASPIRE_TOLERANCE_PSI: + step.fail('panellift computes the published ASPIRE stress', + 'peak_tension_psi={0:.2f}, expected {1} +/- {2}' + .format(peak, ASPIRE_PUBLISHED_PSI, ASPIRE_TOLERANCE_PSI), + 'the install reaches panellift but the arithmetic disagrees ' + 'with the published worked example -- this is a code ' + 'regression, not a setup problem; do not "fix" it by ' + 'loosening the tolerance') + return False + step.ok('panellift computes the published ASPIRE stress', + 'peak_tension_psi={0:.2f} (published {1}), verdict={2}' + .format(peak, ASPIRE_PUBLISHED_PSI, verdict)) + if not os.path.isfile(report_path) or os.path.getsize(report_path) < 1000: + step.fail('panellift writes a report', 'aspire_report.html missing or tiny', + 'run by hand and inspect the -o path') + return False + step.ok('panellift writes a report', '{0} bytes'.format(os.path.getsize(report_path))) + return True + + +def verify_dependencies_importable(step): + rc = _run([sys.executable, '-c', 'import yaml, pytest, fitz']) + if rc.returncode != 0: + step.fail('yaml/pytest/fitz importable', rc.stderr.strip()[-1000:], + 'python -m pip install -r requirements.txt') + return False + step.ok('yaml/pytest/fitz importable') + return True + + +# What Panel Analysis' CPython side imports at module scope (tools/deckcheck.py +# and tools/panelanalysis.py both sys.exit(2) without it). Kept in step with +# PA_NEEDS in the PanelAnalysis button by hand; the test names both. +BUTTON_PYTHON_NEEDS = ('yaml',) + + +def _load_liftexport(): + """`vibe.liftexport` on host CPython, or None. Same stub `tools/deckcheck.py` + uses: `vibe/__init__.py` imports the Revit API at module scope, so a bare + package object pointed at the directory keeps it from ever running.""" + import types + lib = os.path.join(ROOT, 'WillTools.extension', 'lib') + if lib not in sys.path: + sys.path.insert(0, lib) + if 'vibe' not in sys.modules: + stub = types.ModuleType('vibe') + stub.__path__ = [os.path.join(lib, 'vibe')] + sys.modules['vibe'] = stub + try: + from vibe import liftexport + except Exception: + return None + return liftexport + + +def verify_button_python(step, liftexport=None): + """Ask the extension's OWN interpreter chooser which CPython the ribbon + buttons will start, and require that one to import what they need. + + `verify_dependencies_importable` checks `sys.executable` -- whichever + python ran this script -- and on 2026-09-05 that passed on a machine + where every Panel Analysis mark went NOT RUN: PATH had a Python 3.11 with + PyYAML, `liftexport.find_python()` chose a bare Python 3.13 by folder + name, and no check had ever asked the chooser for its answer (Gitea #16). + This one does. It runs the same `find_python_report` the buttons call, + in the same environment, and prints every candidate it turned down. + + One gap, stated: this runs with no open model, so a `handling.python_exe` + set in the knowledge layers is not read here -- WILLTOOLS_PYTHON is. + """ + liftexport = liftexport or _load_liftexport() + if liftexport is None: + step.fail('the buttons\' CPython imports {0}'.format( + ', '.join(BUTTON_PYTHON_NEEDS)), + 'vibe.liftexport could not be imported on this host', + 'python -m pytest WillTools.extension/tests -q -k liftexport') + return False + exe, tried = liftexport.find_python_report(None, BUTTON_PYTHON_NEEDS) + name = 'the buttons\' CPython imports {0}'.format( + ', '.join(BUTTON_PYTHON_NEEDS)) + if exe: + step.ok(name, '{0} -- {1}'.format(exe, tried[-1][1])) + return True + step.fail(name, liftexport.describe_python_search(tried, BUTTON_PYTHON_NEEDS), + 'pip install into the interpreter Revit will pick (the first ' + 'one listed), or set handling.python_exe / WILLTOOLS_PYTHON ' + 'to the one that has the packages') + return False + + +def run_verification(step, opts=None, no_knowledge=False): + """Returns True iff every check passed (a SKIP counts as passed -- it is + a deliberate non-answer, not a wrong one). Order matters: dependency + problems should surface before knowledge-layer problems, which should + surface before anything that depends on both (panellift's install check + depends on neither the plant layer nor pyyaml, so it can run regardless + and often gives the clearest single answer).""" + opts = opts or {} + ok = True + ok &= verify_dependencies_importable(step) + ok &= verify_button_python(step) + ok &= verify_plant_layer_populated(step, no_knowledge) + ok &= verify_knowledgelint(step, no_knowledge) + ok &= verify_libindex(step) + with tempfile.TemporaryDirectory(prefix='setup_machine_verify_') as tmp: + ok &= verify_panellift(step, tmp, opts) + return ok + + +# --------------------------------------------------------------------- main + +def _verdict(step): + """Turn the accumulated Step state into the VERDICT line(s) and an exit + code. Split out from main() so the summary shape -- how the passed and + skipped counts get worded, and when the skip clause appears at all -- can + be tested without shelling out to pip and PowerShell. + + The skipped-count clause is appended only when `step.skipped` is + non-empty, so a checkout with no `.setup-opts` and knowledge declared + (today's owner-machine default: no file, nothing skipped) prints exactly + the old unadorned VERDICT line -- absence of both changes nothing about + this shape, only presence of either does. It no longer names + `.setup-opts` specifically in the tail: a skip can now come from either + that file or a knowledge-less install being declared/auto-detected (see + `dist_layout_detected`), and attributing every skip to the opt-file + would be wrong on a trial install where none exists at all. + """ + tail = '' + if step.skipped: + tail = ', {0} skipped (see SKIP lines above for why each one)'.format( + len(step.skipped)) + if step.failures: + lines = ['VERDICT: FAIL -- {0} step(s) failed: {1}' + .format(len(step.failures), ', '.join(step.failures))] + if step.skipped: + lines.append('({0} passed{1})'.format(len(step.passed), tail)) + lines.append('Fix the first one and re-run this script (or --check ' + 'to just re-verify) -- failures often cascade from ' + 'one root cause.') + return 1, lines + return 0, ['VERDICT: PASS -- {0} passed{1} -- everything above is a ' + 'real, checked answer, not just an exit code.' + .format(len(step.passed), tail)] + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0], + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--knowledge', metavar='PATH_OR_URL', + help='the private KNOWLEDGE repo: an existing checkout, ' + 'or a git URL to clone. Not required with --check, ' + 'or on a re-run where the links already exist.') + ap.add_argument('--check', action='store_true', + help='doctor mode: run only the verification checks ' + 'against whatever is already set up, change nothing') + ap.add_argument('--force', action='store_true', + help='replace an existing link that points somewhere ' + 'else, or a non-empty directory sitting where a ' + 'link needs to go') + ap.add_argument('--no-knowledge', action='store_true', + help='declare a knowledge-less install explicitly: no ' + 'private KNOWLEDGE repo, no plant/job layers. ' + 'Auto-detected on a colleague distribution built ' + 'by tools/make_dist.py (knowledge/' + 'catalogue.yaml.template present, the real ' + 'catalogue.yaml absent); pass this by hand to force ' + 'the same behaviour on any other checkout.') + args = ap.parse_args(argv) + + no_knowledge = args.no_knowledge or dist_layout_detected() + + step = Step(print) + + print('=' * 72) + print('WillTools machine setup' + (' -- CHECK ONLY' if args.check else '')) + print('=' * 72) + print('repo root: {0}'.format(ROOT)) + if no_knowledge: + print('no knowledge layers declared{0} -- this is a trial install; ' + 'checks that need a plant/job layer will report SKIP, not ' + 'FAIL. Ask the owner for real layers when you have a job to ' + 'run for real.'.format( + ' (auto-detected)' if not args.no_knowledge else '')) + print() + + # Loaded before anything else, in BOTH modes: --check needs to know + # panellift is in dev mode before it reaches verify_panellift, and a + # malformed or stale opt-file entry is itself a failure worth surfacing + # up front rather than only discovering it mid-run. + opts = load_opts(step) + if opts: + print() + + if not args.check: + if no_knowledge and not args.knowledge: + # No --knowledge argument and no private repo is not a failure + # here the way resolve_private_repo() would ordinarily treat it + # -- it is the declared or auto-detected state of a colleague + # trial install, which has nowhere to point one. Skip wiring + # entirely rather than failing on "no --knowledge argument and + # nothing already wired". + step.skip('private repo wiring', + 'no --knowledge and no private repo declared -- ' + 'knowledge-less trial install (pass --knowledge ' + ' once you have a private KNOWLEDGE ' + 'repo to wire in)') + print() + else: + private_root = resolve_private_repo(step, args.knowledge, force=args.force) + print() + if private_root: + wire_links(step, private_root, force=args.force) + print() + set_env_vars(step, no_knowledge) + print() + install_dependencies(step, opts) + print() + + print('-' * 72) + print('verification') + print('-' * 72) + all_ok = run_verification(step, opts, no_knowledge) + + print() + print('=' * 72) + code, lines = _verdict(step) + for line in lines: + print(line) + return code + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/snapshot_cycle.py b/tools/snapshot_cycle.py new file mode 100644 index 0000000..6e972e3 --- /dev/null +++ b/tools/snapshot_cycle.py @@ -0,0 +1,1158 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Make a timestamped detached copy, cycle a Revit onto it, run a batch, close. + + python tools/snapshot_cycle.py --fresh-detach --run "python tools/sweep.py" + python tools/snapshot_cycle.py --use ".rvt" + python tools/snapshot_cycle.py --fresh-detach --dry-run + +The commission, 2026-09-01, verbatim: "snapshot功能重构。功能变成保存一份本地 +detach模型带时间戳,然后关闭detach模型的revit session,重开这个新的detach模型, +做你需要的所有操作,然后自动关闭。" + +Five steps, and each one is a thing that can fail on its own, so each one +journals: + + 1 DETACH write `/_<stamp>/<title>.rvt` + 2 CYCLE close the sandbox Revit, launch a new one on that file + 3 CONFIRM find the bridge port that is actually the sandbox + 4 BATCH run each --run command against it, tee'd into the snapshot + 5 CLOSE close the sandbox again, journal the run + + +WHICH DETACH MECHANISM SHIPPED, AND WHY IT IS NOT THE OBVIOUS ONE +------------------------------------------------------------------ +The obvious route -- and the one this job was commissioned expecting -- is to +open the cloud model a second time INSIDE a session that is already running, +as a background document, with + + OpenOptions().DetachFromCentralOption = DetachAndPreserveWorksets + +and `SaveAs` it. **That route does not exist for a cloud model**, and this is +not a version guard or a maybe: + +* Setting any `DetachFromCentralOption` on a cloud `ModelPath` raises + *"Detach option is not valid for cloud model."* +* Autodesk's answer on the API forum is that the Revit team does not allow + cloud models to be detached, that **SaveAs is the recommended way** to get a + detached copy, and that the API is aligned to the UI here. +* The UI agrees: the "Detach from Central" checkbox is greyed out for + anything opened from Autodesk Docs, because cloud worksharing is not the + worksharing that option belongs to. + + forums.autodesk.com/t5/revit-api-forum/how-to-open-and-detach-a-cloud- + workshared-model/td-p/10371092 + autodesk.com/support/technical/article/caas/sfdcarticles/sfdcarticles/ + Detach-from-Central-grayed-out-when-opening-from-BIM-360-shared-file-in- + Revit.html (both read 2026-09-01) + +And the live model on this job IS a cloud model. Two further facts finish the +argument even if that one were worked around: + +* Revit holds one Document per model per session. The live session already + has that model open, so it cannot open it again. +* The one call that would make a copy out of the document already in memory + is `doc.SaveAs`, and `SaveAs` RE-POINTS the document it is called on. Run + in the owner's session it would leave HIS Revit sitting in the local copy, + disconnected from the cloud. There is no SaveCopyAs in the API. That is the + single outcome this whole feature exists not to cause. + +**So the copy is made by a Revit that is not his**: `pyrevit run` starts a +headless session, `runners/detach_snapshot.py` opens the cloud model plainly +in it, `SaveAs`s to a new LOCAL CENTRAL (which is what severs it), and quits. +That runner also handles the file-path case, where a real open-detached is +legal and is still used. + +What is left of "the button does it in-session" is the part that genuinely +needs the live session and nothing else: the model's IDENTITY. A cloud model +is found by its region and two GUIDs, and nothing on this machine knows those +except `doc.GetCloudModelPath()` inside a session that has the model open. So +the button reads them, writes the request, spawns this pipeline, and gets out +of the way -- and both of them write the identity to +`<detached_dir>/source.json`, which is what lets every LATER run start from a +terminal with no Revit anywhere. One button press is the whole bootstrap. + + +MEMORY: NEVER THREE +-------------------- +The old design waited for every Revit to exit, on the premise that the +machine cannot hold two copies of a 300 MB model. That premise is retired -- +live and sandbox ran side by side all morning on 2026-09-01 -- but the +untested number is three, so this SEQUENCES instead of waiting: + + close the sandbox -> run the detach Revit -> it exits -> open the + new sandbox + +At no instant are there more than the owner's live session and one other. +That ordering is also why the detach comes before the relaunch and not after: +it is the only arrangement where the transient Revit and the sandbox are +never resident together. + + +WHAT IT WILL NOT KILL +---------------------- +`taskkill` is only ever aimed at a pid this pipeline can PROVE is a sandbox: + + * one it launched itself in this run; + * one whose OWN BRIDGE reported a document whose file is under a configured + snapshot root -- the same proof `vibe/bridgeguard.py` accepts, made by + the document rather than by a human. The live cloud model has no local + file and can never satisfy it; + * one named with `--sandbox-pid` that is a live `Revit.exe`. + +Anything else is refused with a sentence saying so. And a pid that a bridge +port reports as a REFUSED session -- i.e. the live model -- is refused even +when it was named explicitly, because the flag is the caller's assertion and +the refusal is the model's own. + +Host CPython 3.11. `vibe` modules are loaded by path, the same way +`tools/snapshot_defer.py` does it, because importing the package runs +`vibe/__init__.py`, which pulls in the Revit API and dies. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +import types + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TOOLS = os.path.join(ROOT, 'tools') +VIBE = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe') +EXT = os.path.join(ROOT, 'WillTools.extension') +RVT = os.path.join(TOOLS, 'rvt.py') +RUNNER = os.path.join(EXT, 'runners', 'detach_snapshot.py') + +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [VIBE] + _stub.__file__ = os.path.join(VIBE, '__init__.py') + sys.modules['vibe'] = _stub +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +from vibe import bridgeguard as G # noqa: E402 +from vibe import joblocate as JL # noqa: E402 +from vibe import knowledge as K # noqa: E402 +from vibe import snapshotmeta as S # noqa: E402 +from vibe import yamlkit # noqa: E402 + +import snapshot_defer as DEFER # noqa: E402 + +PORT_ENV = 'WILLTOOLS_BRIDGE_PORT' +DEFAULT_PORT = 48884 + +# How many ports up from the configured one to look for the sandbox. pyRevit +# walks upward one at a time per running Revit, and nobody on this machine +# runs four at once; a wider scan only makes a failure slower to report. +PORT_SPAN = 4 + +# How long Revit gets to start, load pyRevit, open a 300 MB model and answer +# a request. Measured nowhere -- generous on purpose, and reported as a +# timeout rather than assumed away. +SANDBOX_WAIT_MINUTES = 15.0 +POLL_SECONDS = 10.0 + +# Revit does not release a model the instant its window closes. Same reason +# as `snapshot_defer.SETTLE_SECONDS`, shorter because nothing here is about +# to open the file that was just closed. +CLOSE_SETTLE_SECONDS = 10.0 + + +# ------------------------------------------------------------------ settings + +def read_knowledge(path): + """A job/plant knowledge YAML merged onto `knowledge.DEFAULTS`. + + Read with `vibe.yamlkit`, not PyYAML: yamlkit ships with the extension, + has no third-party dependency, and is the same reader the extension uses + under IronPython -- so a file this pipeline can read is a file the buttons + can read, and a file it cannot is a file worth hearing about. + + A file that will not parse is NOT silently ignored. It comes back as an + error string, and `main` refuses on it, because "there is no denylist" and + "I could not read the denylist" are the same two answers the bridge guard + keeps apart and this is the other end of the same wire. + """ + merged = K._deep_copy(K.DEFAULTS) + if not path: + return merged, None + if not os.path.isfile(path): + return merged, 'no such knowledge file: {0}'.format(path) + try: + with open(path, 'r', encoding='utf-8') as handle: + text = handle.read() + data = yamlkit.parse(text) + except Exception as exc: + return merged, 'could not read {0}: {1}'.format(path, exc) + if not isinstance(data, dict): + return merged, '{0} did not parse as a mapping'.format(path) + if not data and _has_content(text): + # yamlkit returns an empty tree for a document it cannot make sense + # of -- a list at the top level, prose, a shape outside its subset -- + # rather than raising. An empty result from a file with lines in it + # is therefore "I could not read this", and reading it as "this file + # configures nothing" would run the pipeline on defaults while the + # file plainly says otherwise. + return merged, ('{0} has content but yielded no settings -- it is ' + 'probably not in the YAML subset vibe.yamlkit reads ' + '(block mappings, block and flow lists, comments)' + .format(path)) + K._deep_update(merged, data) + return merged, None + + +def resolve_job_layer(args): + """Which job knowledge.yaml this run reads, off-Revit. A `joblocate.JobLocateResult`. + + `args.knowledge` (the `--knowledge` flag, defaulting to + `WILLTOOLS_JOB_KNOWLEDGE`) is the explicit case and always wins. Failing + that, `WILLTOOLS_JOBS` is asked -- the same variable `designspec.root_dir` + honours inside Revit, read here because off-Revit there is no `doc.Title` + to pick a job folder by. See `vibe.joblocate` for the full order and why + an ambiguous WILLTOOLS_JOBS refuses rather than guesses. + """ + return JL.resolve_job_knowledge( + explicit=args.knowledge, + jobs_root=os.environ.get(JL.JOBS_ENV), + job_name=getattr(args, 'job', None)) + + +def _layer_clause(job_result): + """Why a setting that should come from the job layer might be unset. + + Two different facts, two different sentences -- collapsing them is + exactly the bug this module exists to fix (see the header). A layer that + was READ and simply does not set this key is not the same event as no + layer having been read at all, and a blocker message that cannot tell + them apart sends the reader to edit a file that was never opened, or to + hunt for a setting that was never looked for. + """ + if job_result is not None and job_result.path: + return ' (found at {0}, but it does not set this)'.format( + job_result.path) + if job_result is not None and job_result.reason: + return ' (none was read: {0})'.format(job_result.reason) + return ' (none was read)' + + +def _has_content(text): + """True when a document has any line that is not blank or a comment.""" + for line in (text or '').splitlines(): + line = line.strip() + if line and not line.startswith('#'): + return True + return False + + +def resolve(args, know): + """Every setting this run uses, from flags then knowledge then DEFAULTS. + + Returned as one dict rather than read at the point of use, so `--dry-run` + prints exactly what the real run would do and the two cannot drift. + """ + # Every key is read with a LITERAL path through `knowledge.get`, not + # through a local `cfg()` wrapper. A wrapper reads better by two + # characters and makes every one of these keys invisible to + # `tools/knowledge_drift.py`, which reports a key nothing reads -- and a + # setting the file offers that the tool ignores is the exact drift that + # detector exists to catch. + detached_dir = args.detached_dir or K.get(know, 'snapshot.detached_dir') + revit_year = args.revit or K.get(know, 'snapshot.revit_year') + revit_exe = args.revit_exe or K.get(know, 'snapshot.revit_exe') \ + or S.revit_exe_guess(revit_year) + if not revit_year: + # `pyrevit run` (the detach step, below) takes an optional + # `--revit=<year>` and this job layer is exactly the shape that + # leaves it unset: `snapshot.revit_exe` configured, + # `snapshot.revit_year` never separately stated. On a machine with + # more than one Revit version attached that omission is not neutral + # -- see `vibe.snapshotmeta.revit_year_from_exe` for the incident. + # `revit_exe`'s own path almost always carries the year, so recover + # it from there before falling back to "let pyRevit choose". + revit_year = S.revit_year_from_exe(revit_exe) + batch = list(args.run or []) or \ + list(K.get(know, 'snapshot.batch', []) or []) + titles, roots = G.config_from(know, K.get) + # `config_from` reads the detached dir out of the knowledge tree, which + # misses it when it arrived on the command line instead. Without this, a + # `--detached-dir` run cannot recognise its own snapshots as sandboxes and + # would refuse to close the one it opened last time. + if detached_dir and not any(G.is_under(detached_dir, r) for r in roots): + roots.append(detached_dir) + return { + 'detached_dir': detached_dir, + 'layout': K.get(know, 'snapshot.layout', S.FOLDER), + 'worksets': K.get(know, 'snapshot.worksets', 'preserve'), + 'revit_year': revit_year, + 'revit_exe': revit_exe, + 'pyrevit_exe': args.pyrevit or K.get(know, 'snapshot.pyrevit_exe') + or _pyrevit_guess(), + 'batch': batch, + 'refuse_titles': titles, + 'sandbox_roots': roots, + 'wait_minutes': float( + args.wait_minutes if args.wait_minutes is not None + else K.get(know, 'snapshot.sandbox_wait_minutes', + SANDBOX_WAIT_MINUTES)), + 'port': int(args.port if args.port is not None + else K.get(know, 'bridge.port', DEFAULT_PORT)), + 'port_span': int(K.get(know, 'bridge.port_span', PORT_SPAN)), + 'log': args.log or (S.log_path(detached_dir) if detached_dir else None), + # Only a live session knows a cloud model's GUIDs. Stating them in the + # job layer is what lets a host-only run find the model with no Revit + # anywhere -- see `source_from`. + 'source_cloud': K.get(know, 'snapshot.source_cloud'), + 'source_path': K.get(know, 'snapshot.source_path'), + 'source_title': K.get(know, 'snapshot.source_title'), + } + + +def _pyrevit_guess(): + """Where pyRevit installs its CLI, when nobody configured one. A GUESS. + + Delegates to `vibe.snapshotmeta.pyrevit_exe_guess` -- this used to be its + own copy of the same path, and `tools/snapshot_defer.py` used to have no + fallback at all, which is what let a run started with `--pyrevit` + forgotten die with no journal line. One guess, three callers now. + """ + return S.pyrevit_exe_guess() + + +def blockers(settings, args, source=None, job_result=None): + """Every reason this run cannot start, as sentences. Empty means go. + + All of them, not the first one: a pipeline that reports one missing + setting per attempt is a pipeline configured over four runs. + + `job_result` (a `vibe.joblocate.JobLocateResult`) is what says, for each + setting that could have come from the job layer, whether that layer was + read at all and did not set it, or was never read in the first place -- + see `_layer_clause`. `None` (every existing caller that predates it) + reads as "no information", which prints the same "no job knowledge layer + was read" clause as an explicit not-found result. + """ + out = [] + if args.fresh_detach: + if not settings['detached_dir']: + out.append('snapshot.detached_dir is not set, so there is ' + 'nowhere to write the copy (--detached-dir, or ' + 'snapshot.detached_dir in the job knowledge layer{0})' + .format(_layer_clause(job_result))) + if not settings['pyrevit_exe']: + out.append('the pyRevit CLI was not found -- it is what starts ' + 'the Revit that writes the copy (--pyrevit, or ' + 'snapshot.pyrevit_exe in the job knowledge layer{0})' + .format(_layer_clause(job_result))) + if not has_source(source): + out.append('a fresh detach needs to know WHICH model to copy, ' + 'and a cloud model is only identifiable from a ' + 'session that has it open. Press the Snapshot button ' + 'once (it writes the identity and caches it), or pass ' + '--request / --source, or state ' + 'snapshot.source_cloud in the job knowledge layer' + '{0}.'.format(_layer_clause(job_result))) + if not args.use and not args.fresh_detach: + out.append('say --fresh-detach or --use <a snapshot>') + if args.use and not os.path.isfile(args.use): + out.append('--use names no file: {0}'.format(args.use)) + if not settings['revit_exe']: + out.append('no Revit.exe to open the sandbox with (--revit-exe, or ' + 'snapshot.revit_exe, or snapshot.revit_year in the job ' + 'knowledge layer{0})'.format(_layer_clause(job_result))) + elif not os.path.isfile(settings['revit_exe']): + out.append('Revit.exe is not at {0} (set snapshot.revit_exe)' + .format(settings['revit_exe'])) + if not settings['batch']: + out.append('no batch to run -- pass --run "<command>" or set ' + 'snapshot.batch in the job knowledge layer{0}' + .format(_layer_clause(job_result))) + return out + + +# ------------------------------------------------------- what may be killed + +def image_of(pid, run=None): + """The executable name behind a pid, or None when it cannot be read. + + None is a real answer here and is treated as one: `may_kill` refuses on + it rather than assuming. Being wrong in that direction costs a message; + being wrong the other way ends the owner's Revit session. + """ + if not pid: + return None + run = run or (lambda argv: subprocess.run( + argv, capture_output=True, text=True, timeout=30).stdout) + try: + out = run(['tasklist', '/FI', 'PID eq {0}'.format(int(pid)), '/NH', + '/FO', 'CSV']) + except Exception: + return None + for line in (out or '').splitlines(): + line = line.strip() + if not line or line.startswith('INFO:'): + continue + if line.startswith('"'): + name = line.split('","')[0].strip('"') + if name: + return name + return None + + +def sandbox_pids_from(seen, roots): + """Pids that PROVED they are a sandbox, out of a port survey. + + The proof is the same one `vibe/bridgeguard.py` accepts from the other + side of the wire: the session said, over its own bridge, that its open + document's file lives under a configured snapshot root. A live cloud + model has no local file at all and can never satisfy this, and neither + can a Revit somebody opened on something else. + + This is what lets the button spawn the pipeline without knowing a pid -- + and therefore what stops a stale sandbox being left resident beside the + live session and the transient detach Revit, which would be three. + """ + out = [] + for row in (seen or ()): + if row.get('verdict') == 'refused' or not row.get('pid'): + continue + path = row.get('doc_path') + if not path: + continue + for root in (roots or ()): + if root and G.is_under(path, root): + try: + out.append(int(row['pid'])) + except (TypeError, ValueError): + pass + break + return out + + +def may_kill(pid, launched=(), explicit=None, image=None, live_pids=(), + proven=()): + """None when `pid` may be closed, else the one-line refusal. + + Four ways a pid can be allowed, in order of how much they prove: + + launched here this run started it. Nothing to argue about. + proven its own bridge said its model is under a snapshot + root -- the document's statement, not a human's. + --sandbox-pid the caller asserts it, AND it is a live Revit.exe. + (nothing else) refused. + + And one way it can be forbidden even so: `live_pids` is every pid a + bridge port answered a REFUSAL from, which is the model's own statement + that it is the live one. That beats the flag, because a flag is a human + remembering a number and the refusal is the document itself. + """ + try: + pid = int(pid or 0) + except (TypeError, ValueError): + pid = 0 + if not pid: + return ('no sandbox pid to close -- nothing was launched by this run ' + 'and --sandbox-pid was not given') + if pid in set(int(p) for p in live_pids or ()): + return ('REFUSING to close pid {0}: a bridge on this machine answers ' + 'from that process with a live-model refusal, so it is the ' + 'session the owner is working in.'.format(pid)) + if pid in set(int(p) for p in launched or ()): + return None + if pid in set(int(p) for p in proven or ()): + return None + try: + explicit = int(explicit) if explicit else 0 + except (TypeError, ValueError): + explicit = 0 + if pid != explicit: + return ('REFUSING to close pid {0}: this run did not launch it and it ' + 'was not named with --sandbox-pid, so nothing here can prove ' + 'it is the sandbox rather than the live model.'.format(pid)) + if not image: + return ('REFUSING to close pid {0}: it was named with --sandbox-pid ' + 'but its process image could not be read, so it cannot be ' + 'confirmed to be a Revit at all.'.format(pid)) + if image.strip().lower() != 'revit.exe': + return ('REFUSING to close pid {0}: --sandbox-pid named it but it is ' + '{1}, not Revit.exe.'.format(pid, image)) + return None + + +# ---------------------------------------------------------------- the bridge + +def bridge_env(port, extra=None): + """The environment a bridge call (or a batch command) runs with. + + The port travels in the environment rather than on a command line because + the batch commands are the owner's own strings -- `python tools/sweep.py` + -- and they call `rvt.py` themselves. An environment variable reaches + through that; a flag would have to be threaded through every one of them. + """ + env = dict(os.environ) + env[PORT_ENV] = str(int(port)) + if extra: + env.update(extra) + return env + + +def ask_status(port, python=None, run=None): + """`rvt.py status` on one port, parsed. None when nothing answered.""" + python = python or sys.executable + run = run or (lambda argv, env: subprocess.run( + argv, cwd=ROOT, env=env, capture_output=True, text=True, timeout=120)) + try: + result = run([python, RVT, 'status'], bridge_env(port)) + except Exception: + return None + for text in ((result.stdout or '').strip(), (result.stderr or '').strip()): + if not text: + continue + try: + body = json.loads(text) + except Exception: + continue + if isinstance(body, dict): + return body + return None + + +def status_verdict(body, want_title): + """What one `/status` answer means for us. + + ('up' | 'refused' | 'other-model' | 'silent', sentence) + + `refused` is the one worth the extra branch. It means this port belongs to + a session whose document is on `bridge.refuse_titles` -- the live model. + It is NOT "not up yet", and retrying it forever is how a pipeline waits + out a timeout while the answer was there in the first second. + """ + if not isinstance(body, dict): + return 'silent', 'nothing answered on this port' + if body.get('refused'): + return 'refused', (body.get('error') or + 'the bridge refused: live model') + if not body.get('ok'): + return 'silent', str(body.get('error') or 'the bridge answered not-ok') + here = G.normalize_title(body.get('doc_title')) + if want_title and here != G.normalize_title(want_title): + return 'other-model', ('this Revit has "{0}" open, not "{1}"' + .format(body.get('doc_title'), want_title)) + return 'up', 'ready' + + +def find_sandbox(port, span, want_title, want_path=None, ask=None, + stop_at_first=True): + """(port, seen) -- the session holding our snapshot, plus what was seen. + + Returns `(None, seen)` when no port has it. `seen` is every port that + answered anything, with its verdict, so a failure can print the whole + picture instead of "timed out" -- which is the difference between "the + sandbox is slow" and "the live Revit took the port and yours is on 48885 + but has the wrong model open". + + `stop_at_first=False` is the survey: it visits every port even after a + match, which is what the pre-flight scan wants -- its whole job is to + find the LIVE session's pid, and that session may be sitting on a port + above one that answers. + """ + ask = ask or ask_status + seen = [] + found = None + for candidate in range(int(port), int(port) + int(span)): + body = ask(candidate) + verdict, why = status_verdict(body, want_title) + seen.append({'port': candidate, 'verdict': verdict, 'why': why, + 'pid': (body or {}).get('pid'), + 'doc_path': (body or {}).get('doc_path'), + 'doc_title': (body or {}).get('doc_title')}) + if verdict != 'up': + continue + if want_path: + here = (body or {}).get('doc_path') + # A MISSING path is a mismatch, not an excuse to skip the check. + # That was a real hole: the live cloud session reports no local + # `PathName` at all, and its title is identical to the snapshot's + # -- so "no path, so nothing to compare" accepted the live model + # as the sandbox and would have pointed the whole batch at it. + # This is the same asymmetry `bridgeguard` uses from the other + # side: no file means no proof, and no proof is a no. + if not here: + seen[-1]['verdict'] = 'other-model' + seen[-1]['why'] = ('right title, but it reports no local file ' + '-- that is a cloud session, not the ' + 'snapshot at {0}'.format(want_path)) + continue + if not G.same_model(here, want_path): + seen[-1]['verdict'] = 'other-model' + seen[-1]['why'] = ('right title, but the file is {0}, not {1}' + .format(here, want_path)) + continue + if found is None: + found = candidate + if stop_at_first: + return found, seen + return found, seen + + +def live_pids_from(seen): + """The pids of every session that answered a live-model refusal.""" + out = [] + for row in seen or (): + if row.get('verdict') == 'refused' and row.get('pid'): + try: + out.append(int(row['pid'])) + except (TypeError, ValueError): + continue + return out + + +# ------------------------------------------------------------ doing the work + +def journal(log, event, run_id, **fields): + if not log: + return + row = {'event': event, 'run': run_id} + row.update(fields) + S.append(log, row) + + +def kill(pid, timeout=60): + """`taskkill /PID <pid>` -- graceful first, then forced. (ok, note) + + Graceful first because Revit flushes journals and drops file locks on the + way out and a forced kill skips all of it; forced afterwards because a + Revit sitting on "save changes?" will not take the polite one, and a + sandbox nobody is watching has nothing worth saving -- it is a copy, and + the pipeline never wrote to it. + """ + try: + subprocess.run(['taskkill', '/PID', str(int(pid))], + capture_output=True, text=True, timeout=timeout) + except Exception as exc: + return False, 'graceful taskkill failed: {0}'.format(exc) + deadline = time.time() + timeout + while time.time() < deadline: + if not DEFER.process_alive(pid): + return True, 'closed' + time.sleep(2) + try: + subprocess.run(['taskkill', '/F', '/T', '/PID', str(int(pid))], + capture_output=True, text=True, timeout=timeout) + except Exception as exc: + return False, 'forced taskkill failed: {0}'.format(exc) + time.sleep(2) + return (not DEFER.process_alive(pid)), 'forced' + + +def launch_sandbox(revit_exe, model_path): + """Start Revit on one model. Returns the pid. + + `WILLTOOLS_SANDBOX_MODEL` goes into that process's environment and is the + sandbox's proof of identity: `vibe/bridgeguard.py` lets a document whose + title is on the denylist through when its file is the one named there. + A Revit started from the taskbar has no such variable, which is exactly + the distinction that has to be drawn -- the snapshot copy carries the + same title as the live model on purpose. + """ + argv = S.revit_exe_argv(revit_exe, model_path) + env = dict(os.environ) + env[G.ENV_SANDBOX] = model_path + proc = subprocess.Popen(argv, env=env, cwd=os.path.dirname(revit_exe)) + return proc.pid + + +def batch_popen(command, log_path, env, cwd=None): + """Run one batch command, tee'd into `log_path`. Returns the exit code. + + Tee, not redirect: the operator watching this run and the file that has + to survive it both need the output, and a run whose only record is a + console nobody kept is a run that did not report. + """ + shell = not isinstance(command, (list, tuple)) + with open(log_path, 'w', encoding='utf-8', errors='replace') as handle: + handle.write('$ {0}\n\n'.format( + command if shell else S.quote(command))) + handle.flush() + proc = subprocess.Popen( + command, shell=shell, cwd=cwd or ROOT, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + errors='replace') + for line in iter(proc.stdout.readline, ''): + sys.stdout.write(line) + handle.write(line) + handle.flush() + proc.stdout.close() + return proc.wait() + + +SOURCE_CACHE = 'source.json' + + +def source_cache_path(detached_dir): + return os.path.join(detached_dir, SOURCE_CACHE) if detached_dir else None + + +def source_from(args, settings=None): + """The `source` block the detach runner needs. (source, request-or-None) + + Four places, in order of how directly they were asked: + + --request the button's handoff, written by the live session + --source a model on disk, stated on the command line + the job layer `snapshot.source_cloud` / `snapshot.source_path` + the cache `<detached_dir>/source.json`, left by the last run + + THE CACHE IS WHAT MAKES THIS RUNNABLE WITHOUT A REVIT AT ALL, and it is + worth being explicit about why one is needed. A cloud model is found by + its region and its two GUIDs, and those live nowhere on this machine + except inside a session that has the model open -- `doc.GetCloudModelPath()` + is the only thing that knows them. So the FIRST cycle has to come from + the button, or from a hand-written `snapshot.source_cloud`; after that + the identity is on disk and the pipeline needs nobody. + + The cache is identity, not state: it says which model to copy, never + anything about a copy that was made. + """ + if args.request: + with open(args.request, 'r', encoding='utf-8') as handle: + request = json.loads(handle.read()) + return request.get('source') or {}, request + if args.source: + return {'path': args.source, 'cloud': None, + 'title': os.path.splitext( + os.path.basename(args.source))[0]}, None + settings = settings or {} + cloud = settings.get('source_cloud') + path = settings.get('source_path') + if cloud or path: + return {'path': path, 'cloud': cloud, + 'title': settings.get('source_title')}, None + cache = source_cache_path(settings.get('detached_dir')) + if cache and os.path.isfile(cache): + try: + with open(cache, 'r', encoding='utf-8') as handle: + return json.loads(handle.read()) or {}, None + except Exception: + return {}, None + return {}, None + + +def remember_source(detached_dir, source): + """Write the identity down so the next run needs no Revit. Never raises. + + A failure to cache must not become a failure to make the copy -- the run + that is happening matters more than the run that might. + """ + path = source_cache_path(detached_dir) + if not path or not source or not (source.get('cloud') or + source.get('path')): + return None + try: + if not os.path.isdir(detached_dir): + os.makedirs(detached_dir) + with open(path, 'w', encoding='utf-8') as handle: + handle.write(json.dumps(source, indent=2, sort_keys=True)) + return path + except Exception: + return None + + +def has_source(source): + """True when this block names a model something could actually reopen.""" + return bool((source or {}).get('cloud') or (source or {}).get('path')) + + +def base_title(source, args): + """The name the copy is saved under -- the SaveAs base.""" + if args.base: + return args.base + title = (source or {}).get('title') + if title: + return title + path = (source or {}).get('path') + if path: + return os.path.splitext(os.path.basename(path))[0] + return 'untitled' + + +# ------------------------------------------------------------------- the run + +def _early_log(args): + """Best-effort `<detached_dir>/snapshots.jsonl`, before `resolve()` has + run and therefore before `settings['log']` exists. + + Three of this run's failure branches -- an ambiguous job layer, a job + knowledge file that will not parse, and the ordinary `blockers()` list -- + all happen before `settings` is built, and until 2026-09-02 all three + only ever reached `print(..., file=sys.stderr)`. That is a real journal + gap and not a small one: the button spawns this whole pipeline with + `CreateNoWindow = True` and no output redirection at all + (`SnapshotClose.pushbutton/script.py`'s `_spawn`), so stderr from a + button-launched run is not merely easy to miss, it is not connected to + anything that could show it. `--detached-dir` is what the button always + passes explicitly, so it is tried first; a bare `--request` (a run + started from the request file alone) falls back to the same + folder-structure recovery `tools/snapshot_defer.py`'s + `fallback_log_for` uses. + """ + if args.detached_dir: + return S.log_path(args.detached_dir) + if args.request: + try: + requests_dir = os.path.dirname(os.path.abspath(args.request)) + except Exception: + return None + if os.path.basename(requests_dir) == '_requests': + return S.log_path(os.path.dirname(requests_dir)) + return None + + +def _journal_early_failure(args, message, log=None): + """`print` the refusal (as every caller already did) AND, when a log can + be found at all, journal it too -- see `_early_log`. + + `log` lets a caller that already has `settings['log']` (the `blockers()` + branch, which runs after `resolve()`) hand over the real, resolved path + instead of `_early_log`'s cruder guess from `--detached-dir` alone. + """ + print('cannot start: {0}'.format(message), file=sys.stderr) + log = log or _early_log(args) + if log: + journal(log, S.CYCLE_FAILED, + 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S')), + error=message) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--fresh-detach', action='store_true', + help='write a new timestamped copy first') + ap.add_argument('--use', metavar='MODEL', + help='skip the detach and cycle onto this file') + ap.add_argument('--run', action='append', metavar='COMMAND', + help='a host command to run inside the sandbox; repeat ' + 'for more. Default: snapshot.batch') + ap.add_argument('--request', help='the request file the button wrote ' + '(carries the cloud GUIDs)') + ap.add_argument('--source', help='a model path to copy, when there is no ' + 'request file') + ap.add_argument('--base', help='the SaveAs base name for the copy') + ap.add_argument('--sandbox-pid', type=int, default=0, + help='close this Revit before opening the new one. Only ' + 'a pid this pipeline launched, or one named here, ' + 'is ever closed.') + ap.add_argument('--knowledge', metavar='YAML', + default=os.environ.get('WILLTOOLS_JOB_KNOWLEDGE'), + help='a job knowledge file to read settings from') + ap.add_argument('--job', metavar='NAME', + help='which job folder under WILLTOOLS_JOBS to read ' + '(only needed when more than one exists there and ' + '--knowledge was not given)') + ap.add_argument('--detached-dir') + ap.add_argument('--revit', help='Revit year, e.g. 2024') + ap.add_argument('--revit-exe') + ap.add_argument('--pyrevit', help='path to pyrevit.exe') + ap.add_argument('--log', help='snapshots.jsonl to journal into') + ap.add_argument('--port', type=int, default=None) + ap.add_argument('--wait-minutes', type=float, default=None) + ap.add_argument('--keep-open', action='store_true', + help='leave the sandbox Revit running after the batch') + ap.add_argument('--dry-run', action='store_true', + help='print the whole plan and do nothing at all') + args = ap.parse_args(argv) + + job_result = resolve_job_layer(args) + if job_result.path is None and job_result.blocking: + # Ambiguous WILLTOOLS_JOBS, a --job that names nothing, or + # WILLTOOLS_JOBS pointed at something that is not a folder: all + # three are real misconfigurations, not "nothing to read yet", and + # guessing which job was meant is worse than stopping here to ask. + _journal_early_failure(args, job_result.reason) + return 2 + know, know_error = read_knowledge(job_result.path) + if know_error: + _journal_early_failure(args, know_error) + return 2 + settings = resolve(args, know) + source, _request = source_from(args, settings) + stops = blockers(settings, args, source, job_result) + if stops: + for line in stops: + _journal_early_failure(args, line, log=settings.get('log')) + return 2 + + run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S')) + stamp = S.stamp() + if args.use: + target = os.path.abspath(args.use) + folder = os.path.dirname(target) + title = os.path.splitext(os.path.basename(target))[0] + plan = {'path': target, 'folder': folder, 'title': title, + 'stamp': S.split_stamped(os.path.basename(folder))[1] or stamp, + 'layout': None, 'warning': None} + else: + plan = S.plan(settings['detached_dir'], base_title(source, args), + stamp, layout=settings['layout']) + target = plan['path'] + folder = plan['folder'] + + log = settings['log'] + + if args.dry_run: + print('run id {0}'.format(run_id)) + print('job knowledge {0}'.format( + '{0} ({1})'.format(job_result.path, job_result.how) + if job_result.path else '(none read -- {0})'.format( + job_result.reason or 'defaults only'))) + print('detach {0}'.format( + 'fresh, from {0}'.format( + 'cloud GUIDs' if (source or {}).get('cloud') else + (source or {}).get('path') or '(no source)') + if args.fresh_detach else 'no -- reusing {0}'.format(args.use))) + print('target {0}'.format(target)) + print('sandbox Revit {0}'.format(settings['revit_exe'])) + print('close first pid {0}'.format(args.sandbox_pid or 'none')) + print('bridge ports {0}..{1}'.format( + settings['port'], settings['port'] + settings['port_span'] - 1)) + print('refuse titles {0}'.format( + ', '.join(settings['refuse_titles']) or '(none configured)')) + print('sandbox roots {0}'.format( + ', '.join(settings['sandbox_roots']) or '(none configured)')) + print('log {0}'.format(log)) + print('batch:') + for i, command in enumerate(settings['batch'], 1): + print(' {0} {1}'.format( + S.batch_log_name(i, command), command)) + if args.fresh_detach: + print('\ndetach command:\n {0}'.format(S.quote( + S.pyrevit_run_argv(settings['pyrevit_exe'], RUNNER, + settings['revit_year'])))) + print('\nsandbox command:\n {0}'.format(S.quote( + S.revit_exe_argv(settings['revit_exe'], target)))) + print('\nNothing was launched, closed or written.') + return 0 + + started = time.time() + journal(log, S.CYCLE_START, run_id, path=target, + sandbox_pid=args.sandbox_pid or None, + batch_count=len(settings['batch'])) + + launched = [] + exit_code = 0 + crash = None + try: + # ---- see who is on the bridge BEFORE anything is closed. This is the + # only chance to learn that a pid named with --sandbox-pid is in fact + # the live model, and it is worth the two seconds. + _found, seen = find_sandbox(settings['port'], settings['port_span'], + None, stop_at_first=False) + live = live_pids_from(seen) + proven = sandbox_pids_from(seen, settings['sandbox_roots']) + for row in seen: + print('port {0}: {1} -- {2}'.format(row['port'], row['verdict'], + row['why'])) + + # ---- 2a. close the current sandbox FIRST, so the transient detach + # Revit and the sandbox are never resident together. The pid comes + # from the flag when one was given and from the survey otherwise -- + # the button that spawns this cannot know a pid, and a stale sandbox + # left running is how the third Revit gets there. + closing = [args.sandbox_pid] if args.sandbox_pid else list(proven) + for victim in closing: + refusal = may_kill(victim, launched, args.sandbox_pid, + image_of(victim), live, proven) + if refusal: + journal(log, S.CYCLE_FAILED, run_id, error=refusal) + print(refusal, file=sys.stderr) + return 3 + ok, note = kill(victim) + journal(log, S.SANDBOX_CLOSED, run_id, pid=victim, + ok=ok, note=note, phase='before-detach', + proof='--sandbox-pid' if args.sandbox_pid + else 'bridge said its model is under a snapshot root') + print('closed sandbox pid {0}: {1}'.format(victim, note)) + if not ok: + error = ('the existing sandbox (pid {0}) would not close, so ' + 'nothing further was started -- three Revits is the ' + 'state this pipeline will not create.'.format(victim)) + journal(log, S.CYCLE_FAILED, run_id, error=error) + print(error, file=sys.stderr) + return 3 + if closing: + time.sleep(CLOSE_SETTLE_SECONDS) + + # ---- 1. the copy. + if args.fresh_detach: + remember_source(settings['detached_dir'], source) + req_path = write_detach_request(settings, source, plan, log, + run_id) + print('detaching -> {0}'.format(target)) + code = DEFER.main(['--request', req_path, '--runner', RUNNER, + '--pyrevit', settings['pyrevit_exe'], + '--machine-free', 'ignore'] + + (['--revit', str(settings['revit_year'])] + if settings['revit_year'] else [])) + if code != 0 or not os.path.isfile(target): + error = ('the detach step did not produce {0} (exit {1}). ' + 'Its own reason is in this log, from the runner.' + .format(target, code)) + journal(log, S.CYCLE_FAILED, run_id, error=error) + print(error, file=sys.stderr) + return 4 + + if not os.path.isfile(target): + error = 'there is no model at {0} to open'.format(target) + journal(log, S.CYCLE_FAILED, run_id, error=error) + print(error, file=sys.stderr) + return 4 + + # ---- 2b. the new sandbox. + pid = launch_sandbox(settings['revit_exe'], target) + launched.append(pid) + journal(log, S.SANDBOX_OPENED, run_id, pid=pid, path=target) + print('sandbox Revit started, pid {0}'.format(pid)) + + # ---- 3. wait for it to be the thing we asked for. + port, seen = wait_for_sandbox(settings, plan.get('title') or + os.path.splitext( + os.path.basename(target))[0], + target, pid) + if port is None: + error = ('the sandbox never answered with {0} open. Ports seen: ' + '{1}'.format(os.path.basename(target), + '; '.join('{0}={1}'.format(r['port'], + r['verdict']) + for r in seen) or 'none')) + journal(log, S.CYCLE_FAILED, run_id, error=error, ports=seen) + print(error, file=sys.stderr) + exit_code = 5 + else: + print('sandbox bridge confirmed on port {0}'.format(port)) + exit_code = run_batch(settings, folder, port, log, run_id) + except Exception as exc: + # A crash here must NOT skip the close below, and it must not skip + # the journal either: a CYCLE_START with nothing after it is read as + # "a Revit may still be open", and that reading has to be reserved + # for when it is actually true. + import traceback + crash = traceback.format_exc() + exit_code = 6 + print(crash, file=sys.stderr) + finally: + # ---- 5. close what we opened, whatever happened above. + for pid in launched: + if args.keep_open: + print('leaving sandbox pid {0} open (--keep-open)'.format(pid)) + journal(log, S.SANDBOX_CLOSED, run_id, pid=pid, ok=False, + note='left open by --keep-open') + continue + refusal = may_kill(pid, launched) + if refusal: + journal(log, S.SANDBOX_CLOSED, run_id, pid=pid, ok=False, + note=refusal) + continue + ok, note = kill(pid) + journal(log, S.SANDBOX_CLOSED, run_id, pid=pid, ok=ok, note=note, + phase='after-batch') + print('closed sandbox pid {0}: {1}'.format(pid, note)) + + seconds = round(time.time() - started, 1) + if exit_code == 0: + journal(log, S.CYCLE_DONE, run_id, path=target, seconds=seconds, + batch_count=len(settings['batch'])) + elif crash: + journal(log, S.CYCLE_FAILED, run_id, path=target, seconds=seconds, + error=crash.splitlines()[-1], traceback=crash) + else: + journal(log, S.CYCLE_FAILED, run_id, path=target, seconds=seconds, + error='the batch or the confirmation failed (exit {0}); the ' + 'sandbox was closed anyway'.format(exit_code)) + return exit_code + + +def write_detach_request(settings, source, plan, log, run_id): + """The request file `runners/detach_snapshot.py` reads. Same schema as the + button's, because it is the same runner and two schemas is one too many.""" + request = { + 'run': run_id, + 'base': plan.get('title'), + 'layout': plan.get('layout'), + 'worksets': settings['worksets'], + 'detached_dir': settings['detached_dir'], + 'plan': plan, + 'log': log, + 'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'), + 'requested_by': 'tools/snapshot_cycle.py', + 'source': source, + } + folder = os.path.join(settings['detached_dir'], '_requests') + if not os.path.isdir(folder): + os.makedirs(folder) + path = os.path.join(folder, run_id + '.json') + with open(path, 'w', encoding='utf-8') as handle: + handle.write(json.dumps(request, indent=2)) + return path + + +def wait_for_sandbox(settings, want_title, want_path, pid, sleep=None, + clock=None, ask=None): + """Poll until the sandbox answers with our model open. (port, seen) + + Gives up rather than raising, for the same reason `snapshot_defer`'s wait + does: a timeout is a reportable outcome. It also gives up EARLY when the + process it is waiting for has died -- polling a dead Revit for fifteen + minutes tells nobody anything. + """ + sleep = sleep or time.sleep + clock = clock or time.time + deadline = clock() + settings['wait_minutes'] * 60.0 + seen = [] + while True: + port, seen = find_sandbox(settings['port'], settings['port_span'], + want_title, want_path, ask=ask) + if port is not None: + return port, seen + if pid and not DEFER.process_alive(pid): + return None, seen + if clock() >= deadline: + return None, seen + sleep(POLL_SECONDS) + + +def run_batch(settings, folder, port, log, run_id): + """Every --run command, in order, tee'd into the snapshot folder. + + Stops at the first failure. A sweep that reports problems is meant to be + followed by something that reads them, and running step two over step + one's wreckage produces a report nobody can trust. + """ + env = bridge_env(port) + worst = 0 + for i, command in enumerate(settings['batch'], 1): + name = S.batch_log_name(i, command) + path = os.path.join(folder, name) + print('\n--- {0}\n$ {1}'.format(name, command)) + started = time.time() + try: + code = batch_popen(command, path, env) + except Exception as exc: + code = -1 + journal(log, S.BATCH_DONE, run_id, command=command, exit_code=code, + log=path, error='{0}: {1}'.format(type(exc).__name__, exc)) + else: + journal(log, S.BATCH_DONE, run_id, command=command, + exit_code=code, log=path, + seconds=round(time.time() - started, 1)) + print('--- exit {0}, log {1}'.format(code, path)) + if code != 0: + worst = code + break + return worst + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/snapshot_defer.py b/tools/snapshot_defer.py new file mode 100644 index 0000000..66165d0 --- /dev/null +++ b/tools/snapshot_defer.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Phase C's host half: wait for Revit to exit, then make the detached copy. + + python tools/snapshot_defer.py --request <req.json> --runner <run.py> \ + [--pyrevit <pyrevit.exe>] --wait-pid <revit pid> [--revit 2025] + +`--pyrevit` is auto-discovered at the usual install path when left off +(`vibe.snapshotmeta.pyrevit_exe_guess`) -- the same fallback the two callers +already made for themselves; passing it explicitly still wins. `--revit` is +also worth passing explicitly on any machine with more than one Revit +version attached: see WHAT IT GUARANTEES below for why omitting it is now a +loud warning rather than a silent maybe. + + python tools/snapshot_defer.py --request <req.json> ... --dry-run + python tools/snapshot_defer.py --newest <folder> + +WHY A SEPARATE PROCESS EXISTS AT ALL +------------------------------------- +The machine cannot hold two copies of this model. 300+ MB on disk, 10+ GB +open; the engineer's session and a scanning session cannot both be resident, +which is what killed the original concurrent design. So the copy is made +AFTER his Revit has exited, and something has to still be running to notice +that it has. That something is this: a host process, started by the button, +which outlives the Revit that started it, sleeps until the pid is gone, and +then launches exactly one Revit of its own. + +THE PARAGRAPH ABOVE IS NO LONGER TRUE, AND `--machine-free` IS THE SWITCH +-------------------------------------------------------------------------- +Measured 2026-09-01: the live cloud session and a detached sandbox session +ran side by side on this machine for a whole morning. Two Revits are fine; +the untested number is three. So the machine-wide gate -- "wait until NO +Revit at all is running" -- is now OPT-IN: + + --machine-free ignore (default) only the watched pid is waited for + --machine-free require the old behaviour, for the close-model ritual + +`tools/snapshot_cycle.py` calls this with the default and sequences instead: +it closes the sandbox Revit BEFORE launching the one that writes the copy, so +two is the most that are ever resident. The `require` mode is kept, not +deleted, because the close-model ritual on a slower machine is a real use for +it and the give-up-and-journal behaviour underneath it is the tested part. + +It is deliberately small. Everything it decides -- what the copy is called, +which snapshot is newest, what goes in the log -- lives in +`vibe.snapshotmeta`, where it is unit-tested without Revit. What is left here +is waiting, launching, and reporting, and the two of those that can be tested +without a licence (`wait_for_exit`, `command_for`) are pure functions with +their clock and their liveness check injected. + +WHAT IT GUARANTEES +------------------- +* It never starts Revit while the watched pid is alive. +* Under `--machine-free require` it never starts Revit while ANY Revit is + running -- not just that pid. He can close Revit and open it again while + this is sleeping, and under the old one-model premise launching into that + would have put two 10 GB models in memory. +* It gives up after `--wait-minutes` and JOURNALS THE GIVE-UP. A Revit sitting + on an unanswered save prompt is the ordinary reason, and a day when the copy + silently did not happen must not look like a day when it did. +* It checks afterwards that the file it was promised actually exists, and + journals a failure if not -- the in-Revit runner can die in ways that take + its own logging with it. + +Host CPython 3.12. The extension side is IronPython 2.7; the shared module is +written to both, and loaded here by path for the same reason the test suite +does it -- importing `vibe` as a package runs `vibe/__init__.py`, which pulls +in the Revit API and dies. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +import traceback +import types + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VIBE = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe') + +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [VIBE] + _stub.__file__ = os.path.join(VIBE, '__init__.py') + sys.modules['vibe'] = _stub + +from vibe import snapshotmeta as S # noqa: E402 + +# Revit does not release the model the instant its window closes: the process +# lingers while it flushes journals and drops file locks. Starting a new Revit +# into that window is how you get a licence check-out race and a half-freed +# 10 GB. Measured nowhere -- this is a guess, deliberately generous, and it is +# the first number to turn down if the wait feels long. +SETTLE_SECONDS = 30 + +POLL_SECONDS = 5 + + +# ------------------------------------------------------------- is it gone yet + +def process_alive(pid): + """True while `pid` is a live process. Windows, no third-party module. + + An unknown answer is reported as ALIVE, never as gone: being wrong in that + direction costs a wait, and being wrong the other way starts a second + Revit next to a live one -- the exact thing this whole design exists to + prevent. + """ + if not pid: + return False + try: + import ctypes + from ctypes import wintypes + SYNCHRONIZE = 0x00100000 + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess( + SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)) + if not handle: + return False # no such process, or already exited + try: + code = wintypes.DWORD() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return True # could not tell -> assume alive + return code.value == STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + except Exception: + pass + try: + out = subprocess.run( + ['tasklist', '/FI', 'PID eq {0}'.format(pid), '/NH'], + capture_output=True, text=True, timeout=30).stdout + return str(pid) in out + except Exception: + return True # could not tell -> assume alive + + +def any_revit_running(run=None): + """True while ANY Revit is running on this machine, whoever started it. + + Waiting for the button's own pid is not enough. The engineer can close + Revit, get a coffee, and open it again inside the window this tool is + sleeping through -- and then launching Revit would put a second 10 GB + model next to his live one, which is the exact failure the whole deferred + design exists to avoid. So the last check before launching is not "has + that process gone" but "is the machine free". + + Unknown answers count as RUNNING, for the same reason as in + `process_alive`: the cost of being wrong is a wait in one direction and a + thrashed workstation in the other. + """ + run = run or (lambda argv: subprocess.run( + argv, capture_output=True, text=True, timeout=30).stdout) + try: + out = run(['tasklist', '/FI', 'IMAGENAME eq Revit.exe', '/NH']) + except Exception: + return True + return 'Revit.exe' in (out or '') + + +def wait_for_exit(pid, timeout_s, alive=None, sleep=None, clock=None, + poll=POLL_SECONDS): + """Block until `pid` is gone. True if it went, False on timeout. + + Every dependency is injected so the loop can be tested with no process and + no wall clock. It returns rather than raising on timeout because the + timeout is a REPORTABLE OUTCOME, not an error: somebody left a save prompt + open, and the next press of the button needs to say so. + """ + if not pid: + # Nothing to wait for. Guarded here as well as in `process_alive` + # because this loop's only exits are "the process went" and "the clock + # advanced past the timeout" -- a caller that passes no pid and a + # liveness check that always says yes would otherwise spin forever, + # which is exactly how this was first written and exactly what the + # test suite caught. + return True + alive = alive or process_alive + sleep = sleep or time.sleep + clock = clock or time.time + started = clock() + while alive(pid): + if clock() - started >= timeout_s: + return False + sleep(poll) + return True + + +# ----------------------------------------------------------- the command line + +def command_for(pyrevit_exe, runner, revit_year=None): + """The exact `pyrevit run` argv. One source for it, so the line printed by + `--dry-run` and the line actually executed cannot drift apart.""" + return S.pyrevit_run_argv(pyrevit_exe, runner, revit_year) + + +# ------------------------------------------------------------------- the run + +def load_request(path): + with open(path, 'rb') as handle: + return json.loads(handle.read().decode('utf-8')) + + +def fallback_log_for(request_path): + """`<detached_dir>/snapshots.jsonl`, recovered from the REQUEST'S OWN + PATH rather than its contents. + + For the one failure `journal()` cannot report: the request file could not + even be read, so there is no `log` field to send the line to. But every + request this tool is ever handed lives at + `<detached_dir>/_requests/<run>.json` -- the button writes it there, and + so does `tools/snapshot_cycle.py`'s own `write_detach_request` -- so the + well-known log path can be recovered from the FOLDER STRUCTURE alone, + with no JSON parse required. None when the path does not have that + shape (a caller pointed `--request` at something else entirely), because + a log path guessed at random is worse than no journal line at all. + """ + try: + requests_dir = os.path.dirname(os.path.abspath(request_path)) + if os.path.basename(requests_dir) != '_requests': + return None + return S.log_path(os.path.dirname(requests_dir)) + except Exception: + return None + + +def journal(request, event, **fields): + path = (request or {}).get('log') + if not path: + return + row = {'event': event, 'run': (request or {}).get('run')} + row.update(fields) + S.append(path, row) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--request', help='the request file the button wrote') + ap.add_argument('--runner', help='the script pyrevit run executes') + ap.add_argument('--pyrevit', help='path to pyrevit.exe') + ap.add_argument('--revit', help='Revit year, e.g. 2025') + ap.add_argument('--wait-pid', type=int, default=0, + help='wait for this process to exit first') + ap.add_argument('--wait-minutes', type=float, default=20.0) + ap.add_argument('--settle', type=float, default=SETTLE_SECONDS, + help='seconds to wait after the pid is gone') + ap.add_argument('--machine-free', choices=('ignore', 'require'), + default='ignore', + help='require: also wait until no Revit at all is running ' + '(the old close-model behaviour). Default ignore -- ' + 'two Revits are measured to be fine, three are not, ' + 'and the caller sequences for that.') + ap.add_argument('--dry-run', action='store_true', + help='print what would happen and exit, launching nothing') + ap.add_argument('--newest', metavar='FOLDER', + help='print the newest snapshot in FOLDER and exit') + args = ap.parse_args(argv) + + if args.newest: + rows = S.snapshots(args.newest) + if not rows: + print('no snapshots in {0}'.format(args.newest)) + return 1 + for row in rows: + mark = '*' if row is rows[-1] else ' ' + print('{0} {1} {2:8} {3}'.format(mark, row['stamp'], + row['layout'], row['path'])) + print('\nnewest: {0}'.format(rows[-1]['path'])) + return 0 + + if not (args.request and args.runner): + ap.error('--request and --runner are required') + + # A missing or unreadable --request has nowhere of its own to be + # journalled -- the request IS where the log path lives. But its own + # location still says where the detached_dir is (see + # `fallback_log_for`), so a crash here is not silent even so; only a + # `--request` that does not even have a `_requests` parent gives up on + # a journal line entirely, and that is loud on stderr instead. + try: + request = load_request(args.request) + except Exception: + tb = traceback.format_exc() + print(tb, file=sys.stderr) + fallback = fallback_log_for(args.request) + if fallback: + S.append(fallback, { + 'event': S.WATCHER_CRASHED, 'run': None, + 'error': 'could not read --request {0}: {1}' + .format(args.request, tb.splitlines()[-1]), + 'traceback': tb}) + return 2 + + target = (request.get('plan') or {}).get('path') + + # `--pyrevit` used to be required by `ap.error()` above, with no journal + # possible at that point (the request was not loaded yet) -- a run + # started with the flag simply missing died silently. It is optional now: + # discovered the same way the two callers that already had this guess + # discover it (`tools/snapshot_cycle.py`'s `_pyrevit_guess`, the + # SnapshotClose button's `_pyrevit_cli`), so a caller that forgets it no + # longer has to be the third copy of the same fallback. + pyrevit_exe = args.pyrevit or S.pyrevit_exe_guess() + if not pyrevit_exe: + error = ('the pyRevit CLI was not found -- pass --pyrevit, or set ' + 'snapshot.pyrevit_exe, or install pyRevit at the usual ' + 'path ({0}\\pyRevit-Master\\bin\\pyrevit.exe)' + .format(os.environ.get('APPDATA', '%APPDATA%'))) + journal(request, S.FAILED, path=target, error=error) + print(error, file=sys.stderr) + return 2 + + command = command_for(pyrevit_exe, args.runner, args.revit) + + if args.dry_run: + print('request {0}'.format(args.request)) + print('run id {0}'.format(request.get('run'))) + print('source {0}'.format( + (request.get('source') or {}).get('path') or + 'cloud GUIDs in the request')) + print('target {0}'.format(target)) + print('log {0}'.format(request.get('log'))) + print('wait for pid {0} (up to {1} min, then {2} s to settle),' + .format(args.wait_pid, args.wait_minutes, args.settle)) + if args.machine_free == 'require': + print(' then until NO Revit at all is running on this ' + 'machine') + else: + print(' and NOT for other Revits (--machine-free ' + 'ignore)') + if not args.revit: + print('\nWARNING: no --revit=<year> was given. On a machine ' + 'with more than one Revit version attached, pyRevit has ' + 'to pick one on its own -- confirm this is intended ' + '(see snapshot.revit_year / the Revit year folded into ' + 'snapshot.revit_exe).') + print('\nthen runs:\n {0}'.format(S.quote(command))) + print('\nwith {0}={1}'.format('WILLTOOLS_SNAPSHOT_REQUEST', + args.request)) + return 0 + + # From here on, this process is the whole reason the "requested and + # never reported" question (`vibe.snapshotmeta.outstanding`) can be + # answered instead of guessed at: a REQUESTED with no WATCHER_LAUNCHED + # after it means this process never made it this far at all (never + # spawned, or crashed on the request load above); one with + # WATCHER_LAUNCHED but nothing terminal after it means it started and + # then something below killed it hard enough to skip even the `except` + # net a few lines down -- a signal, a forced kill, the machine itself + # going away. Both are now distinguishable from "still running" and + # from each other, which the ledger could not do before this line + # existed. + journal(request, S.WATCHER_LAUNCHED, path=target, + wait_pid=args.wait_pid or None, wait_minutes=args.wait_minutes, + machine_free=args.machine_free, pyrevit_exe=pyrevit_exe, + revit_year=args.revit or None) + + try: + deadline = args.wait_minutes * 60.0 + if args.wait_pid: + gone = wait_for_exit(args.wait_pid, deadline) + if not gone: + journal(request, S.FAILED, path=target, + error='Revit (pid {0}) was still running after {1} ' + 'minutes; no copy was made. A save prompt left ' + 'unanswered is the usual reason.' + .format(args.wait_pid, args.wait_minutes)) + return 2 + time.sleep(args.settle) + + # THE MACHINE, not just that one process -- but only when asked for. + # He may have opened Revit again while this was sleeping; under the + # retired one-model premise that was fatal, and under + # `--machine-free require` it still stands down. `wait_for_exit` is + # reused with a machine-wide predicate; the pid is a placeholder it + # never inspects. + if args.machine_free == 'require' and \ + not wait_for_exit(1, deadline, + alive=lambda _pid: any_revit_running()): + journal(request, S.FAILED, path=target, + error='a Revit is running again on this machine, so no ' + 'copy was made -- --machine-free require was ' + 'asked for. Press the button again at the end of ' + 'the next session, or run with --machine-free ' + 'ignore.') + return 2 + + env = dict(os.environ) + env['WILLTOOLS_SNAPSHOT_REQUEST'] = args.request + try: + result = subprocess.run(command, env=env, capture_output=True, + text=True) + code = result.returncode + tail = (result.stderr or result.stdout or '').strip().splitlines() + except Exception as exc: + journal(request, S.FAILED, path=target, + error='could not run the pyRevit CLI: {0}'.format(exc)) + return 2 + + # The runner journals its own DONE. This is the backstop for the + # case where it could not -- Revit died, the journal never played, + # the CLI refused. An outcome nobody wrote down is the failure this + # design is least able to survive. + if target and os.path.isfile(target): + # The file is the only evidence that counts. `pyrevit run` + # returns non-zero for journal-playback noise that has nothing + # to do with whether the copy was written, so the exit code is + # recorded when it is odd and is not allowed to overrule the + # filesystem. + if code != 0: + journal(request, 'phase-c-note', path=target, exit_code=code, + note='the copy was written; the pyRevit CLI still ' + 'exited {0}'.format(code)) + return 0 + hint = '' + if not args.revit: + hint = (' No --revit=<year> was passed for this run -- on a ' + 'machine with more than one Revit version attached, ' + 'that is the first thing to try.') + journal(request, S.FAILED, path=target, exit_code=code, + error=('the pyRevit run finished but the copy is not there. ' + 'Last line: {0}{1}' + .format(tail[-1] if tail else '(nothing)', hint))) + return 2 + except Exception: + # The backstop for anything above that was not already one of the + # named, journalled failures -- so "requested, watcher launched, + # then nothing" can never again mean "go look at a console nobody + # kept" (the button spawns this with no window and no output + # redirection at all; an unjournalled traceback here is provably + # unread by anything). + tb = traceback.format_exc() + journal(request, S.WATCHER_CRASHED, path=target, + error=tb.splitlines()[-1], traceback=tb) + print(tb, file=sys.stderr) + return 2 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/sweep.py b/tools/sweep.py new file mode 100644 index 0000000..0f43457 --- /dev/null +++ b/tools/sweep.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +"""Check every WP assembly in the open model, and rank what is wrong. + + python tools/sweep.py measure in Revit, then check + python tools/sweep.py --check re-check the saved job files only + python tools/sweep.py --out DIR somewhere other than beside the model + +Two halves, deliberately separable: + +**Measure** drives `tools/sweep_in_revit.py` through the bridge, ten pieces a +call, until every assembly has been measured. It only reads the model. It is +resumable -- stop it, run it again, it carries on. + +**Check** runs `panellift` over the job files that came out. That half needs +no Revit at all, because a job file is the complete input to the check. So a +change to the mechanics can be re-checked against the whole model in a couple +of minutes, and that is the point of keeping the job files rather than only the +reports. + +That promise had quietly stopped being true. The position search grew until it +was 93% of a run and this sweep took **42 minutes**, which is long enough that +nobody re-checks anything. It passes `--no-recommend` now -- it never read a +recommendation in the first place -- and is back to about a minute of compute. + +That separability has a trap in it, and this refuses on it now: a job file is +the complete input to the check, so a sweep folder can be re-checked against a +model it was never measured from and every report will still render, still +reconcile, and still be about the wrong piece. Each job file records +`source.model`; if that names a model other than the open document, nothing is +checked and the message names both. `--any-model` proceeds anyway and says so +at the top of the output; `--model TITLE` states the model when Revit is not +running. With neither, the guard reports that it could not judge -- which is +not the same answer as agreement, and is printed rather than assumed. + +Writes `problems.txt` and `problems.json` next to the jobs. + +What this does NOT do is choose the inserts. The stages come from +`liftexport.default_stages`, which is what the grouping rules imply with +nobody having clicked anything -- good enough to find what breaks across a +whole model, not good enough to sign. See docs/handoff.md. +""" +from __future__ import print_function + +import argparse +import io +import json +import ntpath +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, os.pardir)) +RVT = os.path.join(HERE, 'rvt.py') +IN_REVIT = os.path.join(HERE, 'sweep_in_revit.py') +PANELLIFT = os.path.join(REPO, 'panellift') +SRC = os.path.join(PANELLIFT, 'src') + + +# ------------------------------------------------------------------- files + +def read(path): + with io.open(path, 'r', encoding='utf-8') as fh: + return json.loads(fh.read()) + + +def write(path, obj): + with io.open(path, 'w', encoding='utf-8') as fh: + fh.write(json.dumps(obj, indent=1, sort_keys=True, default=str)) + + +# ------------------------------------------------------------------- Revit + +def bridge(*args): + """One rvt.py call. Returns (ok, parsed stdout or raw text).""" + r = subprocess.run([sys.executable, RVT] + list(args), + cwd=REPO, capture_output=True, text=True) + text = (r.stdout or '').strip() + try: + return r.returncode == 0, json.loads(text) + except ValueError: + return False, text or (r.stderr or '').strip() + + +def sweep_dir(): + """Ask the open model where its sweep folder is.""" + ok, out = bridge('exec', '-c', + "from vibe import designspec; " + "result = designspec.sub_dir(doc, 'sweep')") + if not ok or not isinstance(out, str): + raise SystemExit( + 'Could not reach Revit: {0}\n\n' + 'Revit closed, or a modal dialog is open in it -- handlers run in ' + "Revit's API context and cannot fire behind a dialog.".format(out)) + return out + + +def open_model_title(): + """`doc.Title` of the open model, or None if Revit is not reachable. + + None is "could not ask", never "it matches" -- see `check_source_model`. + """ + ok, out = bridge('exec', '-c', 'result = doc.Title') + if not ok or not isinstance(out, str) or not out.strip(): + return None + return out.strip() + + +# ------------------------------------------------- which model is this OF? +# +# A job file is the complete input to the check, which is what makes the +# offline half possible -- and it is also how a whole sweep can be re-checked +# against a model it was never measured from without anything looking wrong. +# Measured 2026-09-01: every one of the reference sweep's job files carries +# `source.model` naming one model, while the document open in Revit was a +# detached copy that had since DIVERGED from it -- one notch 3.5 in in one +# and 2.5 in in the other. Every report still rendered, every number still +# reconciled against the job file it came from, and the two models were only +# told apart by hand. `source.model` is the field that would have caught it +# immediately, and nothing was reading it. + +def model_stem(name): + """'sample_job.rvt' and 'sample_job' are the same model. + + `source.model` is written as `os.path.basename(doc.PathName)`, so it + carries the extension; `doc.Title` does not. Compared case-insensitively + because Windows does. Returns None for anything empty -- and None is a + question, not a match. + """ + if not name: + return None + # `doc.PathName` -- and so `source.model` -- is always a WINDOWS path, + # drive letter and backslashes, even when this runs on a Linux dev box. + # `os.path.basename` only splits on the host's own separator, so it is + # a no-op for a Windows path on POSIX; `ntpath.basename` is correct on + # both. + stem = ntpath.basename(str(name).strip()) + if stem.lower().endswith('.rvt'): + stem = stem[:-len('.rvt')] + return stem.strip().lower() or None + + +def check_source_model(jobs, doc_title): + """Which of these job files were exported from a different model. + + `jobs` is [(mark, job dict)]. Returns (mismatched, unstamped, reason): + + mismatched [(mark, the model the job file names)] -- exported from a + DIFFERENT model from the one open. This is the refusal. + unstamped [mark] for job files carrying no `source.model` at all. + Written before the field existed, or by hand: they cannot + be judged, and they are NOT thereby cleared. + reason a sentence, when nothing could be judged at all because + there is no open document to compare against. `mismatched` + is then empty and that emptiness means "did not ask" -- + which is why the caller has to print the reason rather than + read an empty list as agreement. + """ + want = model_stem(doc_title) + if want is None: + return [], [mark for mark, _job in jobs], ( + 'no open document to compare against, so nothing here says ' + 'whether these job files were measured from the model you mean') + mismatched = [] + unstamped = [] + for mark, job in jobs: + said = (job.get('source') or {}).get('model') + stem = model_stem(said) + if stem is None: + unstamped.append(mark) + elif stem != want: + mismatched.append((mark, said)) + return mismatched, unstamped, None + + +def model_refusal(mismatched, doc_title, total): + """The message the mismatch refuses with. Names BOTH models, always.""" + said = sorted(set(name for _mark, name in mismatched)) + return ( + 'These job files were exported from a different model.\n\n' + ' the open document says {open}\n' + ' the job files say {said}\n\n' + '{n} of {total} job file(s) name a model that is not the one open, ' + 'and the first of them is {first}.\n\n' + 'Nothing was checked. A job file is the complete input to the check, ' + 'so this would have run to completion and produced a full set of ' + 'plausible reports about a model nobody is looking at -- which is ' + 'exactly what happened before this refusal existed, on two copies of ' + 'one model that had since diverged.\n\n' + 'Re-export the sweep from the open model, point --out at the sweep ' + 'folder that belongs to it, or pass --any-model if you really do mean ' + 'to check these files against this document.'.format( + open=doc_title, said=', '.join(said), + n=len(mismatched), total=total, + first=sorted(mark for mark, _name in mismatched)[0])) + + +def measure(): + """Drive the in-Revit half until every assembly has a meta file.""" + root = sweep_dir() + print('sweep folder: %s' % root) + meta_dir = os.path.join(root, 'meta') + todo_path = os.path.join(root, 'todo.json') + + stuck = 0 + while True: + total = len(read(todo_path)) if os.path.isfile(todo_path) else 0 + done = len(os.listdir(meta_dir)) if os.path.isdir(meta_dir) else 0 + if total and done >= total: + print('measured %d/%d' % (done, total)) + return root + + ok, out = bridge('exec', IN_REVIT) + print('[%d/%d] %s' % (done, total, str(out)[:200])) + sys.stdout.flush() + if not ok or 'did 0 ' in str(out): + stuck += 1 + if stuck >= 3: + print('STOPPING: three calls in a row got nowhere. Revit is ' + 'busy, or a dialog is open in it.') + return root + time.sleep(10) + else: + stuck = 0 + + +# --------------------------------------------------------------- the check + +def check(root, doc_title=None, any_model=False): + jobs_dir = os.path.join(root, 'jobs') + meta_dir = os.path.join(root, 'meta') + reports = os.path.join(root, 'reports') + if not os.path.isdir(reports): + os.makedirs(reports) + + env = dict(os.environ, PYTHONPATH=SRC) + rows = [] + names = sorted(n for n in os.listdir(jobs_dir) if n.endswith('.job.json')) + + # WHICH MODEL, before a single number is computed. See check_source_model. + jobs = [(n[:-len('.job.json')], read(os.path.join(jobs_dir, n))) + for n in names] + mismatched, unstamped, reason = check_source_model(jobs, doc_title) + notes = [] + if mismatched and not any_model: + raise SystemExit(model_refusal(mismatched, doc_title, len(jobs))) + if mismatched: + notes.append( + '!! --any-model: %d of %d job file(s) name a model that is not ' + '%s. These reports are about a different model.' + % (len(mismatched), len(jobs), doc_title)) + if reason: + notes.append('!! model NOT verified: %s.' % reason) + if unstamped and not reason: + notes.append( + '!! %d of %d job file(s) carry no source.model, so which model ' + 'they came from was not checked.' % (len(unstamped), len(jobs))) + for note in notes: + print(note) + + for i, name in enumerate(names, 1): + mark = name[:-len('.job.json')] + meta = read(os.path.join(meta_dir, mark + '.json')) + row = dict((k, meta.get(k)) for k in + ('mark', 'design', 'axis', 'length_ft', 'width_ft', + 'thickness_in', 'lifters', 'strands')) + # --no-recommend, because this sweep never reads a recommendation: + # `row` below takes the verdict, the per-stage ratios and the findings + # and nothing else. The position search is 93% of a run -- 41 minutes + # of this sweep's 42 -- and every second of it was being computed, + # written to an HTML file and thrown away. The verdicts, ratios and + # findings are untouched by the flag; the reports say on the page that + # the positions were not computed, so nobody reads one as a piece with + # nothing to recommend. Re-run a single piece through the button, or + # `python -m panellift <job> -o <html>` by hand, to get positions. + r = subprocess.run( + [sys.executable, '-m', 'panellift', os.path.join(jobs_dir, name), + '-o', os.path.join(reports, mark + '.html'), '--json', + '--no-recommend'], + cwd=PANELLIFT, env=env, capture_output=True, text=True) + text = (r.stdout or '').strip() + # A FAIL verdict exits non-zero, so the exit code says nothing about + # whether the check ran. The JSON does. + if not text.startswith('{'): + row['verdict'] = 'CHECK CRASHED' + row['error'] = (r.stderr or r.stdout or '')[-1200:] + else: + res = json.loads(text) + row['verdict'] = res.get('verdict') + row['stages'] = [ + {'name': s.get('name'), 'ratio': s.get('ratio'), + 'verdict': s.get('verdict'), + 'findings': [(f.get('severity'), f.get('code'), + f.get('message')) + for f in s.get('findings') or []]} + for s in res.get('stages') or []] + if i % 25 == 0 or i == len(names): + print('checked %d/%d' % (i, len(names))) + sys.stdout.flush() + rows.append(row) + return rows, notes + + +def refused(root): + """The pieces that never produced a job file, and why.""" + meta_dir = os.path.join(root, 'meta') + out = [] + for name in sorted(os.listdir(meta_dir)): + meta = read(os.path.join(meta_dir, name)) + if meta.get('status') != 'exported': + out.append(meta) + return out + + +# -------------------------------------------------------------- the report + +def worst(row): + return max([s.get('ratio') or 0.0 + for s in row.get('stages') or []] or [0.0]) + + +def render(rows, not_exported, notes=()): + lines = [] + + def say(s=''): + lines.append(s) + + # At the top, and inside problems.txt as well as on the terminal: a + # caveat that only ever scrolled past is a caveat nobody read. + for note in notes or (): + say(note) + if notes: + say() + + say('%d pieces checked, %d never got that far' + % (len(rows), len(not_exported))) + counts = {} + for r in rows: + counts[r.get('verdict')] = counts.get(r.get('verdict'), 0) + 1 + for k in sorted(counts, key=lambda k: -counts[k]): + say(' %-14s %d' % (k, counts[k])) + say() + + checked = [r for r in rows if r.get('stages')] + over = sorted([r for r in checked if worst(r) > 0.8], key=worst, + reverse=True) + say('== OVERSTRESSED: a stage above 0.80 of its allowable tension ==') + if not over: + say(' none') + for r in over: + say(' %-8s %6.1f x %5.1f ft %s' + % (r['mark'], r.get('length_ft') or 0, r.get('width_ft') or 0, + ' '.join('%s %.2f' % (s['name'][:4], s.get('ratio') or 0) + for s in r['stages']))) + say() + + for severity in ('FAIL', 'WARN'): + buckets = {} + for r in checked: + for stage in r['stages']: + for sev, code, message in stage['findings']: + if sev == severity: + buckets.setdefault(code, {})[r['mark']] = message + say('== %s findings, by code ==' % severity) + if not buckets: + say(' none') + for code in sorted(buckets, key=lambda c: -len(buckets[c])): + marks = sorted(buckets[code]) + say(' %-8s %3d %s' % (code, len(marks), ', '.join(marks[:10]))) + say(' %s' % list(buckets[code].values())[0][:150]) + say() + + crashed = [r for r in rows if r.get('verdict') == 'CHECK CRASHED'] + if crashed: + say('== CHECK CRASHED: %d ==' % len(crashed)) + for r in crashed: + say(' %-8s lifters=%s %s' + % (r['mark'], r.get('lifters'), + str(r.get('error'))[-200:].replace(chr(10), ' '))) + say() + + if not_exported: + reasons = {} + for meta in not_exported: + why = '; '.join(meta.get('problems') or []) or meta.get('status') + reasons.setdefault(str(why)[:200], []).append(meta['mark']) + say('== NEVER CHECKED: %d, by reason ==' % len(not_exported)) + for why in sorted(reasons, key=lambda w: -len(reasons[w])): + say(' %d x %s' % (len(reasons[why]), why)) + say(' %s' % ', '.join(sorted(reasons[why])[:8])) + return lines + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--check', action='store_true', + help='skip the Revit half and re-check saved job files') + ap.add_argument('--out', help='sweep folder (default: beside the model)') + ap.add_argument('--model', help='the model title the job files should ' + 'name; default is whatever Revit has open') + ap.add_argument('--any-model', action='store_true', + help='check these job files even though they were ' + 'exported from a different model') + args = ap.parse_args() + + if args.out: + root = args.out + elif args.check: + root = sweep_dir() + else: + root = measure() + + # --model lets the guard work with no Revit at all (an offline re-check of + # a sweep folder whose model is not open); with neither, the title is + # None and the guard says it could not judge instead of saying it agrees. + doc_title = args.model or open_model_title() + + rows, notes = check(root, doc_title, any_model=args.any_model) + write(os.path.join(root, 'problems.json'), rows) + text = chr(10).join(render(rows, refused(root), notes)) + with io.open(os.path.join(root, 'problems.txt'), 'w', + encoding='utf-8') as fh: + fh.write(text + chr(10)) + print() + print(text) + + +if __name__ == '__main__': + main() diff --git a/tools/sweep_in_revit.py b/tools/sweep_in_revit.py new file mode 100644 index 0000000..366a5d0 --- /dev/null +++ b/tools/sweep_in_revit.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +"""Measure every in-scope assembly in the open model. Runs INSIDE Revit. + +Not run directly -- `tools/sweep.py` feeds it to the bridge over and over: + + python tools/rvt.py exec tools/sweep_in_revit.py + +Read only. No transaction, no view change, no selection, nothing that would +show up in the user's undo stack. + +WHICH ASSEMBLIES, AND WHO SAYS SO +--------------------------------- +The DECLARED SCOPE (`vibe.scope`), read from the job knowledge layer, exactly +as Job Recon, Takeoff and Ticket QC read it. Until 2026-08-31 this file +filtered on a two-letter mark prefix written into the code, which was wrong +twice over: the declaration in the job file was not load-bearing here (delete +it and this still filtered; change it and this did not follow), and one job's +naming was baked into a repository that must not carry one. + +`run()` already loads the knowledge dict for the measurement itself, so the +scope costs nothing to read -- there was never a reason for the literal. + +WHAT IT SAYS ABOUT WHAT IT LEFT ALONE +-------------------------------------- +Out of scope is a third state, not an absence, so this writes the scope line +`scope.json` next to the other outputs AND returns it from the enumerating +call, which is the call `tools/sweep.py` prints. A sweep that silently +measured a subset would look exactly like a sweep that measured everything. + + todo.json every in-scope assembly, its element id and its design + scope.json the declared scope, the line, and what was left alone + jobs/<mark>.job.json panellift input, default stages, nothing picked + meta/<mark>.json what happened: status, axis, weight gate, audit + +One call does CHUNK pieces and returns how many are left. A piece with a meta +file is done, so this is resumable and safe to re-run: delete the sweep folder +to start over -- including after the declaration changes, because `todo.json` +is enumerated once and does not re-read the scope. +""" +import io +import json +import os +import traceback + +from Autodesk.Revit.DB import ElementId + +from vibe import designspec, knowledge, liftexport, ticketqc +from vibe import scope as scopelib +from vibe.compat import text_type + +CHUNK = 10 + + +def _dump(path, obj): + text = json.dumps(obj, indent=1, sort_keys=True, default=repr) + if not isinstance(text, text_type): + text = text.decode('utf-8') + with io.open(path, 'w', encoding='utf-8') as fh: + fh.write(text) + + +def _load(path): + with io.open(path, 'r', encoding='utf-8') as fh: + return json.loads(fh.read()) + + +def _folders(doc): + root = designspec.sub_dir(doc, 'sweep') + return root, designspec.sub_dir(doc, os.path.join('sweep', 'jobs')), \ + designspec.sub_dir(doc, os.path.join('sweep', 'meta')) + + +def enumerate_pieces(doc, know, todo_path, scope_path=None): + """Every in-scope assembly with the design it belongs to, written once. + + Returns (todo, scope_info). What the DECLARED scope left alone is + counted and named in `scope_info` and written to `scope.json` -- it is + never simply absent, which is the one thing `vibe.scope` exists to stop. + """ + the_scope = scopelib.of(know) + todo, left_alone, unreadable, total = [], [], [], 0 + for design, group in ticketqc.assemblies_by_design(doc, know).items(): + for asm in group: + name = str(asm.Name) + total += 1 + if the_scope.is_unreadable(name): + # No readable prefix: 'I could not tell' never removes work. + unreadable.append(name) + if not the_scope.includes(name): + left_alone.append(name) + continue + todo.append({'id': ticketqc.eid(asm), 'mark': name, + 'design': design}) + todo.sort(key=lambda r: (r['design'], r['mark'])) + _dump(todo_path, todo) + + scope_info = { + 'declared': the_scope.is_declared, + 'label': the_scope.label(), + 'declaration': the_scope.declaration_lines(), + 'line': the_scope.line(left_alone, total=total, unit='assembly', + unreadable_names=unreadable), + # The headline is written HERE, at enumeration, and read back + # verbatim on every later call. See `_scope_headline`. + 'headline': the_scope.short_line(left_alone, total=total, + unit='assembly'), + 'near_miss': the_scope.near_miss_line(left_alone, unit='assembly'), + 'left_alone': the_scope.tally(left_alone), + 'left_alone_count': len(left_alone), + 'measured_count': len(todo), + 'assemblies_seen': total, + } + if scope_path: + _dump(scope_path, scope_info) + return todo, scope_info + + +def measure(doc, know, sheets, item, jobs_dir): + """One assembly, as far as it gets. Never raises.""" + meta = {'mark': item['mark'], 'design': item['design'], + 'status': 'unknown'} + try: + asm = doc.GetElement(ElementId(item['id'])) + # Unattended: take the time and locate the inserts properly. + job, problems = liftexport.export(doc, asm, know, sheets, + item['design'], batch=True) + meta['problems'] = problems + if job is None: + meta['status'] = 'refused' + return meta + + src = job.get('source') or {} + panel = job.get('panel') or {} + meta['axis'] = src.get('erected_vertical_axis') + meta['length_ft'] = panel.get('length_ft') + meta['width_ft'] = panel.get('width_ft') + meta['thickness_in'] = panel.get('thickness_in') + meta['lifters'] = len(panel.get('lifters') or []) + meta['strands'] = len( + ((panel.get('prestress') or {}).get('strands')) or []) + meta['fc_psi'] = panel.get('fc_psi') + meta['fci_psi'] = panel.get('fci_psi') + + ok, message, _numbers = liftexport.weight_check(job, know) + meta['weight_ok'] = bool(ok) + if not ok: + meta['weight_msg'] = message + + stages, _rows, stage_problems = liftexport.default_stages(job, know) + if not stages: + meta['status'] = 'no stages' + return meta + if stage_problems: + meta['status'] = 'dunnage misconfigured' + meta['stage_problems'] = stage_problems + return meta + + # A stage carried on dunnage has no 'lifters' key at all -- it is + # dropped rather than emptied, because panellift refuses a stage that + # gives both a support line and an insert. + meta['stage_lifters'] = dict( + (s['name'], len(s.get('lifters') or [])) for s in stages) + meta['stage_span'] = dict( + (s['name'], s.get('span')) for s in stages) + meta['stage_supports'] = dict( + (s['name'], s.get('supports')) for s in stages + if s.get('supports')) + try: + meta['audit'] = repr(liftexport.stage_audit(stages)) + except Exception as exc: + meta['audit'] = 'audit failed: {0}'.format(exc) + + # The stages here are the DEFAULTS -- what the grouping rules imply, + # with nobody having clicked anything. That is the point of a sweep + # and it is also its limit: see docs/handoff.md. + missing = [s['name'] for s in stages if not s.get('fc_psi')] + if missing: + meta['status'] = 'no strength' + meta['missing_fc'] = missing + return meta + + job['stages'] = stages + _dump(os.path.join(jobs_dir, item['mark'] + '.job.json'), + liftexport.strip_private(job)) + meta['status'] = 'exported' + except Exception: + meta['status'] = 'crash' + meta['error'] = traceback.format_exc() + return meta + + +def _scope_headline(info): + """The recorded headline, for the FRONT of every return value. + + Short and first on purpose. `tools/sweep.py` prints `str(out)[:200]` -- + it truncates -- so the full sentence lives in `scope.json` and what a + reader sees at the console is the count, which is what tells him there is + a file to open. A scope line cut off the end of a progress line would be + a scope line nobody read. + + A sweep folder enumerated before this existed has no `scope.json`, and + that is reported rather than filled in: what such a run filtered on is + genuinely unknown to this code. + """ + if not info: + return ('SCOPE: NOT RECORDED -- this sweep folder was enumerated ' + 'before the scope was read, so what it left out is unknown. ' + 'Delete the sweep folder and re-run.') + return (info.get('headline') or 'SCOPE: recorded without a headline') \ + + ' (scope.json)' + + +def run(doc): + know = knowledge.load(doc) + root, jobs_dir, meta_dir = _folders(doc) + todo_path = os.path.join(root, 'todo.json') + scope_path = os.path.join(root, 'scope.json') + + if not os.path.isfile(todo_path): + todo, scope_info = enumerate_pieces(doc, know, todo_path, scope_path) + return '{0} | enumerated {1} assemblies into {2}'.format( + _scope_headline(scope_info), len(todo), root) + + # Re-read rather than re-derive: the enumeration is what the scope was + # applied to, and that happened on an earlier call. Saying it again from + # a knowledge file that may have been edited since would describe a + # filter this run did not use. + scope_info = _load(scope_path) if os.path.isfile(scope_path) else None + + todo = _load(todo_path) + _summaries, sheets = ticketqc.collect_context(doc) + + did, remaining = [], 0 + for item in todo: + meta_path = os.path.join(meta_dir, item['mark'] + '.json') + if os.path.isfile(meta_path): + continue + if len(did) >= CHUNK: + remaining += 1 + continue + meta = measure(doc, know, sheets, item, jobs_dir) + _dump(meta_path, meta) + did.append('{0}:{1}'.format(item['mark'], meta['status'])) + + return '{0} | did {1} [{2}] remaining {3}'.format( + _scope_headline(scope_info), len(did), ', '.join(did), remaining) + + +result = run(doc) # noqa: F821 -- doc is injected by tools/rvt.py diff --git a/tools/tests/test_pa_web.py b/tools/tests/test_pa_web.py new file mode 100644 index 0000000..e26e6ec --- /dev/null +++ b/tools/tests/test_pa_web.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +"""`tools/pa_web.py` end to end -- no Revit, no subprocess. + +The server is started IN-PROCESS (`pa_web.build_context` + `pa_web. +make_server`, `serve_forever()` on a background thread) so this runs +everywhere `python -m pytest` already does: no CPython subprocess launch, no +scraping `PA_WEB_URL=` off a pipe (that half is the Revit button's own job -- +see the button script's `_launch_pa_web`, covered by the manual dry run and +the live bridge smoke, not a host test). + +Two runs, same exercise: + + * BLANK path (no `--reads`) -- must always pass. This is the "no Revit at + all" contract `docs/pa-web-spec.md` and the button's own architecture + note both promise, and the one CI can always exercise. + * `_scratch/pa_reads_wpa655.json`, when present -- a real model's reads + (gitignored agent scratch space, per CLAUDE.md's "No client or job + information in the repo" -- it is not tracked). SKIPPED, never failed, + when the file is not on this checkout. + +Job root is a pytest `tmp_path` in both cases -- never the real +`%LOCALAPPDATA%\\WillTools\\panelanalysis` a live run would use, and never a +path inside this checkout (CLAUDE.md's "tests must not write into the working +directory"). +""" +import json +import os +import sys +import threading + +import pytest +from urllib.request import Request, urlopen + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = os.path.abspath(os.path.join(HERE, os.pardir)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +import pa_web # noqa: E402 + +REPO = os.path.abspath(os.path.join(TOOLS, os.pardir)) +WPA655_READS = os.path.join(REPO, '_scratch', 'pa_reads_wpa655.json') + +_EXPECTED_PAGE_IDS = ['design_criteria', 'concrete_extents', + 'structural_model', 'handling', 'reinforcement'] + + +class _Running(object): + """Runs `server.serve_forever()` on a daemon thread for the `with` body, + then shuts it down and joins -- a bare fixture would need the same + teardown twice (one test exercises `/api/close` itself, which already + shuts the server down; the `__exit__` call after that must be a safe, + idempotent no-op, not a hang).""" + + def __init__(self, server): + self.server = server + self.thread = threading.Thread(target=server.serve_forever) + self.thread.daemon = True + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *exc_info): + try: + self.server.shutdown() + except Exception: + pass + self.thread.join(timeout=5) + try: + self.server.server_close() + except Exception: + pass + + @property + def base_url(self): + return 'http://127.0.0.1:{0}'.format(self.server.server_address[1]) + + +def _get(url): + """(status, parsed_json_or_None) -- a non-JSON body (the results HTML, + a static asset) comes back as `None`, not a parse error; a caller that + needs the raw text uses `_get_text` instead.""" + with urlopen(url, timeout=10) as resp: + status = resp.getcode() + ctype = resp.headers.get('Content-Type') or '' + body = resp.read().decode('utf-8') + if 'json' not in ctype: + return status, None + return status, (json.loads(body) if body else None) + + +def _get_text(url): + with urlopen(url, timeout=10) as resp: + return resp.getcode(), resp.read().decode('utf-8') + + +def _post(url, body): + data = json.dumps(body).encode('utf-8') + req = Request(url, data=data, + headers={'Content-Type': 'application/json'}, + method='POST') + with urlopen(req, timeout=30) as resp: + return resp.getcode(), json.loads(resp.read().decode('utf-8')) + + +def _real_field_keys(): + return set(f['key'] for f in pa_web.F.FIELDS if f.get('key')) + + +def _exercise(tmp_path, reads_path): + """Runs the whole GET spec / save / run / close cycle against one + server instance and returns nothing -- every assertion lives here so + the blank and WPA655 tests below cannot drift apart.""" + ctx = pa_web.build_context(reads_path=reads_path, + job_root_override=str(tmp_path)) + server = pa_web.make_server(ctx, port=0) + + with _Running(server) as running: + base = running.base_url + + # -------------------------------------------------------- /api/spec + status, spec = _get(base + '/api/spec') + assert status == 200 + assert spec['schema'] == 1 + assert spec['title'] == 'Panel Analysis' + assert [p['id'] for p in spec['pages']] == _EXPECTED_PAGE_IDS + assert isinstance(spec.get('values'), dict) and spec['values'] + assert isinstance(spec.get('basis'), dict) + assert isinstance(spec.get('defaults_why'), dict) + assert isinstance(spec.get('choices'), dict) + assert isinstance(spec.get('tables'), dict) and spec['tables'] + assert isinstance(spec.get('geometry'), dict) and spec['geometry'] + assert isinstance(spec.get('problems'), list) + + # -------------------------------------------------------- /api/save + status, saved = _post(base + '/api/save', {'values': spec['values']}) + assert status == 200 + assert saved.get('ok') is True + values_path = os.path.join(str(tmp_path), 'panelanalysis', + ctx.mark + '.values.json') + assert os.path.isfile(values_path) + with open(values_path, 'r', encoding='utf-8') as fh: + on_disk = json.load(fh) + assert on_disk.get('values') == spec['values'] + + # A saved edit is picked back up by the next /api/spec (the "saved + # user edits merged over the prefill" contract). + status, spec2 = _get(base + '/api/spec') + assert status == 200 + assert spec2['values'].get('length_ft') == spec['values'].get( + 'length_ft') + + # --------------------------------------------------------- /api/run + status, result = _post(base + '/api/run', {'values': spec['values']}) + assert status == 200 + assert isinstance(result, dict) and 'ok' in result + if result['ok']: + assert result['result_url'].startswith('/results/') + result_name = result['result_url'][len('/results/'):] + result_path = os.path.join(str(tmp_path), 'panelanalysis', + result_name) + assert os.path.isfile(result_path) + pa_json_path = os.path.join(str(tmp_path), 'panelanalysis', + ctx.mark + '.pa.json') + assert os.path.isfile(pa_json_path) + status, html = _get_text(base + result['result_url']) + assert status == 200 + assert '<title>' in html + else: + problems = result.get('problems') + assert isinstance(problems, list) and problems + real_keys = _real_field_keys() + for p in problems: + assert set(p.keys()) >= {'key', 'text'} + assert isinstance(p['text'], str) and p['text'] + # A non-empty key must name a real FIELDS key -- '' (could + # not be mapped) is the only other allowed value. + assert p['key'] == '' or p['key'] in real_keys + + # ------------------------------------------------------- /api/close + status, closed = _post(base + '/api/close', {}) + assert status == 200 + assert closed.get('ok') is True + running.thread.join(timeout=5) + assert not running.thread.is_alive() + + +def test_pa_web_blank_path(tmp_path): + _exercise(tmp_path, reads_path=None) + + +def test_pa_web_wpa655_reads(tmp_path): + if not os.path.isfile(WPA655_READS): + pytest.skip('no _scratch/pa_reads_wpa655.json on this checkout -- ' + 'gitignored agent scratch space, not always present') + _exercise(tmp_path, reads_path=WPA655_READS) + + +def _get_headers(url): + with urlopen(url, timeout=10) as resp: + return resp.getcode(), dict(resp.headers) + + +def test_static_and_json_responses_carry_utf8_charset(tmp_path): + """Item 1 of the 2026-09-05 review: `index.html` gets a `<meta + charset="utf-8">` and every text-ish response (`text/html`, + `application/javascript`, `text/css`, JSON) carries an explicit + `charset=utf-8` -- a browser with no declared charset guesses cp1252/ + latin-1 and every non-ASCII glyph (the title's en dash, the row X + buttons, the section-arrow triangles) renders as mojibake.""" + ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path)) + server = pa_web.make_server(ctx, port=0) + with _Running(server) as running: + base = running.base_url + status, headers = _get_headers(base + '/') + assert status == 200 + assert headers['Content-Type'] == 'text/html; charset=utf-8' + status, headers = _get_headers(base + '/app.js') + assert status == 200 + assert 'charset=utf-8' in headers['Content-Type'] + status, headers = _get_headers(base + '/willtools.css') + assert status == 200 + assert 'charset=utf-8' in headers['Content-Type'] + status, headers = _get_headers(base + '/api/spec') + assert status == 200 + assert headers['Content-Type'] == 'application/json; charset=utf-8' + _post(base + '/api/close', {}) + running.thread.join(timeout=5) + + +def test_index_html_declares_utf8_meta_charset(): + static_dir = os.path.join(TOOLS, 'pa_web', 'static') + with open(os.path.join(static_dir, 'index.html'), 'r', + encoding='utf-8') as fh: + head = fh.read(2000) + assert '<meta charset="utf-8">' in head + + +def test_api_geometry_matches_the_spec_geometry(tmp_path): + """`POST /api/geometry` (item 3's live-redraw fix) must return the SAME + `geometry` dict `/api/spec` computes off the identical `values` -- the + client-side redraw path (`app.js`'s `scheduleGeometryRefresh`) depends + on the two never disagreeing.""" + ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path)) + server = pa_web.make_server(ctx, port=0) + with _Running(server) as running: + base = running.base_url + status, spec = _get(base + '/api/spec') + assert status == 200 + status, geom_result = _post(base + '/api/geometry', + {'values': spec['values']}) + assert status == 200 + assert geom_result.get('geometry') == spec['geometry'] + _post(base + '/api/close', {}) + running.thread.join(timeout=5) + + +def test_api_geometry_reflects_an_opening_row_edit(tmp_path): + """Adding an `opening` row through the client's own key shape must + change the outline geometry `/api/geometry` returns for it -- the + server-side half of "add/remove rows must redraw the outline live".""" + ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path)) + server = pa_web.make_server(ctx, port=0) + with _Running(server) as running: + base = running.base_url + status, spec = _get(base + '/api/spec') + assert status == 200 + values = dict(spec['values']) + values['length_ft'] = '24' + values['width_ft'] = '8' + status, before = _post(base + '/api/geometry', {'values': values}) + assert status == 200 + values['opening.1.present'] = 'yes' + values['opening.1.type'] = 'Opening' + values['opening.1.x_ft'] = '2' + values['opening.1.length_ft'] = '3' + values['opening.1.y_ft'] = '1' + values['opening.1.width_ft'] = '2' + status, after = _post(base + '/api/geometry', {'values': values}) + assert status == 200 + assert after['geometry'] != before['geometry'] + _post(base + '/api/close', {}) + running.thread.join(timeout=5) + + +def test_unknown_endpoint_is_404_not_a_crash(tmp_path): + ctx = pa_web.build_context(reads_path=None, + job_root_override=str(tmp_path)) + server = pa_web.make_server(ctx, port=0) + with _Running(server) as running: + base = running.base_url + try: + _get(base + '/api/nope') + except Exception as exc: + assert '404' in str(exc) + # the server must still be answering afterwards + status, spec = _get(base + '/api/spec') + assert status == 200 + assert spec['schema'] == 1 + _post(base + '/api/close', {}) + running.thread.join(timeout=5) + + +# --------------------------------------------------------------------------- +# /api/run names WHOSE fault a refusal is (issue #19) +# +# A validation refusal ("the piece is still missing f'c") and a solve crash +# used to come back in one shape -- `{"ok": false, "problems": [...]}` -- and +# the page rendered both through one red banner, so a data gap read as a +# broken tool and a fixed dependency bug was mistaken for a new one. The +# reply now carries `kind`: 'input' (the user's to fill, nothing broke) or +# 'error' (a bug, traceback on stderr). Two different answers, never one +# shape. + + +def test_run_refusal_for_missing_input_is_kind_input(tmp_path): + ctx = pa_web.build_context(reads_path=None, + job_root_override=str(tmp_path)) + result = pa_web.run_analysis(ctx, {}) + assert result['ok'] is False + assert result['kind'] == 'input' + assert result['problems'] + # every problem is a plain validate() sentence, none an exception + for p in result['problems']: + assert not p['text'].endswith('Error') + assert 'Traceback' not in p['text'] + + +def test_run_refusal_for_a_solve_crash_is_kind_error(tmp_path, monkeypatch): + ctx = pa_web.build_context(reads_path=None, + job_root_override=str(tmp_path)) + monkeypatch.setattr(pa_web.F, 'validate', lambda values: ([], {})) + + def boom(parsed): + raise RuntimeError('deck exploded') + monkeypatch.setattr(pa_web.F, 'build_deck', boom) + + result = pa_web.run_analysis(ctx, {}) + assert result['ok'] is False + assert result['kind'] == 'error' + assert result['problems'] == [ + {'key': '', 'text': 'RuntimeError: deck exploded'}] + + +def test_run_kind_reaches_the_client_over_http(tmp_path): + ctx = pa_web.build_context(reads_path=None, + job_root_override=str(tmp_path)) + server = pa_web.make_server(ctx, port=0) + with _Running(server) as running: + status, result = _post(running.base_url + '/api/run', + {'values': {}}) + assert status == 200 + assert result['ok'] is False + assert result['kind'] == 'input' + + +def test_page_renders_input_and_crash_banners_differently(): + """The page half of the same rule: two banner faces, and the bare + `.banner` (an unlabelled failure) stays the loud one.""" + static = os.path.join(TOOLS, 'pa_web', 'static') + with open(os.path.join(static, 'app.js'), 'r', encoding='utf-8') as fh: + js = fh.read() + with open(os.path.join(static, 'willtools.css'), 'r', + encoding='utf-8') as fh: + css = fh.read() + assert 'banner--input' in js and 'banner--crash' in js + assert '.banner.banner--input' in css and '.banner.banner--crash' in css + # the reply's `kind` is what the page branches on + assert 'showProblems(data.problems || [], data.kind)' in js + # an unlabelled kind renders as a crash, never as a calm input note + assert "kind === 'input' ? 'banner--input' : 'banner--crash'" in js diff --git a/tools/tests/test_panelanalysis_report.py b/tools/tests/test_panelanalysis_report.py new file mode 100644 index 0000000..9424a1d --- /dev/null +++ b/tools/tests/test_panelanalysis_report.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""`tools/panelanalysis.py::build_analysis_page` -- the READ-ONLY results +page for the `panelanalysis/1` schema (2026-09, results-page redesign). + +Guards the redesign's own hard rules, on two decks: + + MINIMAL `schema.new_deck()` as-is -- two dunnage supports, two + straight strands, no in-place restraints/wind at all (the + Stage-0 default). Exercises the "not judged" path for + in-place strength/service. + IN-PLACE the same deck with lateral restraints and a manual wind zone + added, so the in-place strength/service models actually + solve and their own SVG envelope diagrams + (`panelanalysis._inplace_diagram`) render. + +This does not repeat `test_panelanalysis_schema.py`'s coverage of the +engine/schema layer -- it only checks what the WRITTEN PAGE looks like: + - never an `<input>` or `<select>` (the input side lives in the 前端 now) + - never a DECK / NOT READ / COMPUTED badge (`CHIPS`' own vocabulary) + - the Summary card is present (`class="verdict"` + `class="stageboxes"`) + - no decimal-feet number anywhere (every length is feet-inches) + - the page is well under the ~1 MB budget and opens standalone (its own + `<style>` carries the embedded CSS -- no `<link rel="stylesheet">`) + +Import pattern mirrors `test_panelanalysis_schema.py`: `tools/` on +`sys.path`, `deckcheck` imported first for its `vibe` stub side effect. +""" +import copy +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = os.path.abspath(os.path.join(HERE, os.pardir)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +import deckcheck # noqa: E402,F401 -- registers the `vibe` stub + +import panelanalysis # noqa: E402 +import panelanalysis_schema as schema # noqa: E402 + + +DECIMAL_FT = re.compile(r'\d+\.\d+\s*ft\b') + + +def minimal_deck(): + return schema.new_deck() + + +def in_place_deck(): + """`new_deck()`, wired for the in-place strength/service models to + solve: three lateral restraints (so `schema.restraints()` sees >= 2) + and one manual wind zone -- the same shape `WPA655.pa.json` (the deck + this redesign was built and screenshotted against) carries. + """ + deck = schema.new_deck(length_ft=37.5833, width_ft=8.375) + deck['structural_model']['restraints'] = [ + {'x_ft': 2.0, 'type': 'Pin', 'gravity': True, 'thermal': False}, + {'x_ft': 15.0, 'type': 'Floor tie', 'gravity': True, + 'thermal': False}, + {'x_ft': 22.0, 'type': 'Floor tie', 'gravity': True, + 'thermal': False}, + ] + deck['loading']['wind'] = { + 'mode': 'manual', + 'zones': [{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': deck[ + 'concrete_extents']['length_ft'], 'pressure_psf': 30.0, + 'suction_psf': 35.0}], + } + return deck + + +def _check_common_invariants(html): + assert '<input' not in html + assert '<select' not in html + for badge in ('>DECK<', '>NOT READ<', '>COMPUTED<', '>SPEC<', + '>KNOWLEDGE<', '>PLANNED<', '>NOT COMPUTED<'): + assert badge not in html, badge + assert 'class="chip' not in html + assert '<link rel="stylesheet"' not in html + assert 'class="verdict"' in html + assert 'class="stageboxes"' in html + assert 'class="stagebox' in html + bad = DECIMAL_FT.findall(html) + assert not bad, 'decimal-feet numbers found: {0}'.format(bad) + assert len(html.encode('utf-8')) < 1024 * 1024 + + +def test_minimal_deck_page_has_no_inputs_or_badges_and_a_summary_card(): + html = panelanalysis.build_analysis_page(minimal_deck()) + _check_common_invariants(html) + # Nothing wires the in-place model on this deck -- both its models are + # NOT JUDGED, named as such, never a fabricated zero. + assert 'not judged' in html.lower() + + +def test_in_place_deck_solves_and_draws_its_own_envelope_diagrams(): + deck = in_place_deck() + html = panelanalysis.build_analysis_page(deck) + _check_common_invariants(html) + # The in-place models actually ran on this deck -- PASS/WARN/FAIL, not + # "not judged", for In-Place Strength and In-Place Service. + assert 'In-Place Strength' in html + assert 'In-Place Service' in html + # This file's own SVG diagrams for the governing in-place combination + # (`panelanalysis._inplace_diagram`) rendered -- their titles are + # unique to them (the handling stages' reused `R.diagram_plot` figures + # never say "governing combination"). + assert 'governing combination' in html.lower() + + +def test_handling_stages_each_get_their_own_section_and_tag(): + html = panelanalysis.build_analysis_page(minimal_deck()) + for stage in ('Stripping', 'Transportation', 'Erection'): + assert 'Handling — {0}'.format(stage) in html or \ + 'Handling — {0}'.format(stage) in html + + +def test_page_is_deterministic_given_the_same_deck_modulo_timestamp(): + """Two builds a second apart differ only in the 'generated HH:MM' line + -- nothing else on a read-only report should vary run to run.""" + deck = in_place_deck() + h1 = panelanalysis.build_analysis_page(copy.deepcopy(deck)) + h2 = panelanalysis.build_analysis_page(copy.deepcopy(deck)) + strip = lambda s: re.sub(r'generated \d{4}-\d\d-\d\d \d\d:\d\d', + 'generated X', s) + assert strip(h1) == strip(h2) diff --git a/tools/tests/test_panelanalysis_schema.py b/tools/tests/test_panelanalysis_schema.py new file mode 100644 index 0000000..86d9cef --- /dev/null +++ b/tools/tests/test_panelanalysis_schema.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +"""`tools/panelanalysis_schema.py` -- the manual wind zone contract, the +'Free' restraint filter, and `reinforcement.mild_bars` (PANELANALYSIS-4). + +The engine side only. `WillTools.extension/tests/test_panelanalysis_wind.py` +(and its siblings) already cover the front-end's own `build_deck` shape and +the pre-existing legacy wind precedence (direct > computed > blank); this +file does not repeat those, and does not touch anything under +`WillTools.extension/` -- that tree is the front-end sibling's territory. + +Import pattern mirrors `_scratch/run_w1.py`: `tools/` goes on `sys.path`, +`deckcheck` is imported first purely for its side effect of registering a +bare `vibe` package stub (`panelanalysis_schema` does `from vibe import +panelanalysisform`, which needs that stub to resolve off Revit), THEN +`panelanalysis_schema` itself. +""" +import os +import sys + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = os.path.abspath(os.path.join(HERE, os.pardir)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +import deckcheck # noqa: E402,F401 -- registers the `vibe` stub + +import panelanalysis_schema as schema # noqa: E402 +from panellift import inplace # noqa: E402 +from panellift.model import Panel # noqa: E402 + + +def base_deck(**wind_overrides): + deck = schema.new_deck(strands=[]) + deck['loading']['wind'] = dict(wind_overrides) + return deck + + +def wall_panel(length_ft=40.0, width_ft=8.0): + return Panel(mark='TEST', length_ft=length_ft, width_ft=width_ft, + thickness_in=8.0, unit_weight_pcf=150.0, fc_psi=5000.0, + fci_psi=3500.0) + + +# --------------------------------------------------------------- wind.mode + +def test_project_config_mode_refuses_with_the_named_reason(): + deck = base_deck(mode='project_config') + zones, trib, reason = schema.wind_zones(deck) + assert zones == [] + assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT + assert reason == 'wind from project config is not built yet' + + +def test_project_config_mode_refuses_even_carrying_other_wind_keys(): + """The refusal is unconditional on `mode` alone -- an asce block or a + direct pressure sitting beside it changes nothing.""" + deck = base_deck(mode='project_config', direct_pressure_psf=16.0, + asce={'v_ult_mph': 115.0}) + zones, _trib, reason = schema.wind_zones(deck) + assert zones == [] + assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT + + +def test_manual_mode_two_zone_different_pressure_suction_reaches_the_engine(): + """Two zones with DIFFERENT pressure/suction magnitudes: `wind_zones` + hands `inplace` genuinely different fields, and the deck-level + `in_place_inputs` flags `wind_zone_mode='manual'` so the engine solves + them as two independent cases (not the old negate shortcut).""" + deck = base_deck( + mode='manual', + zones=[ + {'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 20.0, 'width_ft': 8.0, + 'pressure_psf': 16.0, 'suction_psf': 12.0}, + {'label': 'W2', 'x0_ft': 20.0, 'x1_ft': 40.0, 'width_ft': 8.0, + 'pressure_psf': 20.0, 'suction_psf': None}, + ]) + panel = wall_panel() + zones, trib, reason = schema.wind_zones(deck, panel) + assert reason is None + assert len(zones) == 2 + z1, z2 = zones + assert z1.pressure_psf == 16.0 and z1.suction_psf == 12.0 + assert z1.width_ft == 8.0 + assert z2.pressure_psf == 20.0 and z2.suction_psf is None + + assert schema.wind_zone_mode(deck) == 'manual' + + deck['structural_model']['restraints'] = [ + {'x_ft': 0.0, 'type': 'Pin'}, {'x_ft': 20.0, 'type': 'Pin'}, + {'x_ft': 40.0, 'type': 'Pin'}] + inputs, in_reason = schema.in_place_inputs(deck, panel) + assert in_reason is None + assert inputs.wind_zone_mode == 'manual' + res = inplace.run(panel, inputs) + assert res.refusal is None + # A pure-negate suction field would peak at exactly the pressure peak; + # zone 2 (20 psf pressure, no suction) contributing nothing to W- while + # zone 1 does breaks that equality. + key_xs = res.cases['D'].xs + cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs, + res.cases) + assert cases_suction is not None + w_plus_peak = max(abs(m) for m in res.cases['W'].moment_lbft) + w_minus_peak = max(abs(m) for m in cases_suction['W'].moment_lbft) + assert w_minus_peak != pytest.approx(w_plus_peak) + + +def test_manual_mode_zone_width_is_per_zone_not_panel_wide(): + deck = base_deck( + mode='manual', + zones=[{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 40.0, + 'width_ft': 4.2, 'pressure_psf': 10.0, 'suction_psf': 10.0}]) + zones, trib, reason = schema.wind_zones(deck, wall_panel()) + assert reason is None + assert zones[0].width_ft == 4.2 + # trib (the panel-wide fallback) is untouched -- a zone carrying its own + # width does not have to state a deck-wide tributary_width_ft too. + assert trib is None + + +def test_legacy_zones_deck_is_unaffected_by_the_mode_reader(): + """No `mode` key at all (the golden shape) -- the pre-existing single- + pressure zone reading is byte-for-byte what it always was.""" + deck = base_deck(zones=[{'x0_ft': 0.0, 'x1_ft': 37.6, + 'pressure_psf': 16.0, 'label': 'W-pressure'}]) + zones, trib, reason = schema.wind_zones(deck, wall_panel()) + assert reason is None + assert len(zones) == 1 + assert zones[0].pressure_psf == 16.0 + assert zones[0].suction_psf is None + assert zones[0].width_ft is None + assert schema.wind_zone_mode(deck) == 'legacy' + + +# ---------------------------------------------------- restraints, 'Free' + +def test_free_restraint_is_skipped_others_are_kept(): + deck = schema.new_deck() + deck['structural_model']['restraints'] = [ + {'x_ft': 0.0, 'type': 'Pin'}, + {'x_ft': 10.0, 'type': 'Free'}, + {'x_ft': 20.0, 'type': 'Fixed'}, + {'x_ft': 30.0, 'type': 'Floor tie'}, + ] + xs, reason = schema.restraints(deck) + assert reason is None + assert xs == [0.0, 20.0, 30.0] + + +def test_free_restraint_match_is_case_insensitive(): + deck = schema.new_deck() + deck['structural_model']['restraints'] = [ + {'x_ft': 0.0, 'type': 'FREE'}, + {'x_ft': 10.0, 'type': 'free'}, + {'x_ft': 20.0, 'type': 'Pin'}, + {'x_ft': 30.0, 'type': 'Pin'}, + ] + xs, _reason = schema.restraints(deck) + assert xs == [20.0, 30.0] + + +def test_an_unrecognised_type_word_still_counts_as_lateral(): + """Only the literal word 'Free' is excluded -- an unknown word (the + golden's own 'restrained', or 'dunnage') is counted, never quietly + dropped (CLAUDE.md sec.3: unrecognised never means silently excluded).""" + deck = schema.new_deck() + deck['structural_model']['restraints'] = [ + {'x_ft': 0.0, 'type': 'restrained'}, + {'x_ft': 10.0, 'type': 'something-nobody-has-seen'}, + ] + xs, _reason = schema.restraints(deck) + assert xs == [0.0, 10.0] + + +def test_restraint_rows_carries_every_extra_key_for_the_page(): + deck = schema.new_deck() + deck['structural_model']['restraints'] = [ + {'x_ft': 5.0, 'type': 'Pin', 'elevation_ft': 102.5, 'gravity': True, + 'thermal': False, 'stiffness_kin': 250.0}, + {'x_ft': 15.0, 'type': 'Free'}, + ] + rows = schema.restraint_rows(deck) + assert len(rows) == 2 + assert rows[0]['elevation_ft'] == 102.5 + assert rows[0]['gravity'] is True + assert rows[0]['thermal'] is False + assert rows[0]['stiffness_kin'] == 250.0 + assert rows[0]['lateral'] is True + assert rows[1]['lateral'] is False + + +# ---------------------------------------------------------- mild_bars + +def test_mild_bars_with_depth_enter_the_capacity_blank_depth_does_not(): + deck = schema.new_deck(strands=[]) + deck['reinforcement']['mild_bars'] = [ + {'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0, + 'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6}, + {'size': '#3', 'count': 10, 'area_in2': 1.1, 'depth_in': None, + 'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6}, + {'size': '#4', 'count': 8, 'area_in2': 1.6, 'depth_in': 2.0, + 'direction': 'Transverse', 'from_ft': 0.0, 'to_ft': 37.6}, + ] + bars = schema.mild_bars(deck) + assert len(bars) == 3 + assert bars[1]['depth_in'] is None + + layers = schema.mild_bar_reinforcement(deck) + # Only the first row: has a depth AND is Longitudinal. + assert len(layers) == 1 + assert layers[0].area_in2 == pytest.approx(4.8) + assert layers[0].count == 1 # total already, not per-bar + assert layers[0].z_in == pytest.approx(2.0) + assert layers[0].from_ft == pytest.approx(0.0) + assert layers[0].to_ft == pytest.approx(37.6) + assert layers[0].grade_ksi == pytest.approx(schema.DEFAULT_MILD_BAR_GRADE_KSI) + + +def test_mild_bars_grade_reads_settings_rebar_fy_ksi(): + deck = schema.new_deck(strands=[]) + deck['settings']['rebar_fy_ksi'] = 40.0 + deck['reinforcement']['mild_bars'] = [ + {'size': '#5', 'count': 4, 'area_in2': 1.24, 'depth_in': 3.0, + 'direction': 'Longitudinal'}] + layers = schema.mild_bar_reinforcement(deck) + assert layers[0].grade_ksi == pytest.approx(40.0) + + +def test_no_mild_bars_block_is_the_identity_on_to_panel(): + """A deck with no `reinforcement.mild_bars` at all -- every deck before + PANELANALYSIS-4 -- must reach `to_panel` with EXACTLY the reinforcement + `mild_reinforcement` alone would have produced (here: none), proving the + handling path (pu=0) sees no change for a bar-less deck.""" + deck = schema.new_deck(strands=[]) + panel = schema.to_panel(deck) + assert panel.reinforcement == [] + + +def test_mild_bars_area_in2_is_the_layer_total_not_per_bar(): + """24 x #4 (area_in2=4.8 TOTAL, per the field model's own convention) must + not be re-multiplied by count into 24 * 4.8.""" + deck = schema.new_deck(strands=[]) + deck['reinforcement']['mild_bars'] = [ + {'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0, + 'direction': 'Longitudinal'}] + panel = schema.to_panel(deck) + assert len(panel.reinforcement) == 1 + assert panel.reinforcement[0].total_area_in2 == pytest.approx(4.8) + + +def test_mild_bars_missing_required_key_refuses_loudly(): + deck = schema.new_deck(strands=[]) + deck['reinforcement']['mild_bars'] = [{'size': '#4', 'depth_in': 2.0}] + with pytest.raises(schema.DeckError): + schema.mild_bars(deck) diff --git a/tools/tests/test_units_js.py b/tools/tests/test_units_js.py new file mode 100644 index 0000000..d4c0359 --- /dev/null +++ b/tools/tests/test_units_js.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Cross-checks `tools/pa_web/static/units.js` against its CPython twin, +`panellift.units` -- the two are meant to be a byte-for-byte port of each +other (see units.py's own module docstring), and this is the one place that +proves it rather than trusting two hand-written files stayed in sync. + +Node-free on purpose: this whole file SKIPS, never fails, when `node` is not +on PATH. A missing dev tool on some machine is not a reason a CPython test +run should go red, and the JS's own behaviour is otherwise unverified only +by inspection -- an acceptable gap, not a gate anyone has to work around. +""" +import json +import os +import shutil +import subprocess +import sys + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = os.path.abspath(os.path.join(HERE, os.pardir)) +REPO = os.path.abspath(os.path.join(TOOLS, os.pardir)) +UNITS_JS = os.path.join(TOOLS, 'pa_web', 'static', 'units.js') + +PANELLIFT_SRC = os.path.join(REPO, 'panellift', 'src') +if PANELLIFT_SRC not in sys.path: + sys.path.insert(0, PANELLIFT_SRC) + +from panellift import units # noqa: E402 + +NODE = shutil.which('node') + +# (js function name, python function name, args, output kind) +# output kind 'str' compares exactly; 'num' compares within a float epsilon +# (JS and CPython float arithmetic can differ in the last bit). +CASES = [ + ('fmtFtIn', 'fmt_ft_in', [8.375], 'str'), + ('fmtFtIn', 'fmt_ft_in', [None], 'str'), + ('fmtFtIn', 'fmt_ft_in', [-0.5], 'str'), + ('fmtFtIn', 'fmt_ft_in', [0.53125], 'str'), # 0'-6 3/8" + ('fmtFtIn', 'fmt_ft_in', [37.583333333333336], 'str'), # carries to 37'-7" + ('fmtFtIn', 'fmt_ft_in', [0.0], 'str'), + ('fmtIn', 'fmt_in', [6.375], 'str'), + ('fmtIn', 'fmt_in', [None], 'str'), + ('fmtIn', 'fmt_in', [12.0], 'str'), + ('fmtIn', 'fmt_in', [0.0], 'str'), + ('parseLength', 'parse_length', ["8'-4 1/2\""], 'num'), + ('parseLength', 'parse_length', ["8'4\""], 'num'), + ('parseLength', 'parse_length', ["8' 4-1/2\""], 'num'), + ('parseLength', 'parse_length', ['4 1/2"'], 'num'), + ('parseLength', 'parse_length', ['100"'], 'num'), + ('parseLength', 'parse_length', ["-0'-6\""], 'num'), + ('parseLength', 'parse_length', ['5'], 'num'), + ('parseLength', 'parse_length', ['5', 'in'], 'num'), + ('parseLengthIn', 'parse_length_in', ['6 3/8"'], 'num'), + ('parseLengthIn', 'parse_length_in', ['5', 'ft'], 'num'), +] + +_HARNESS = """ +'use strict'; +var fs = require('fs'); +global.window = global; +var src = fs.readFileSync(process.argv[2], 'utf8'); +eval(src); +var Units = global.Units; +var cases = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); +var out = cases.map(function (c) { + try { + return {ok: true, value: Units[c.fn].apply(null, c.args)}; + } catch (e) { + return {ok: false, error: String(e)}; + } +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def _run_js(tmp_path): + harness = tmp_path / 'harness.js' + harness.write_text(_HARNESS, encoding='utf-8') + cases_path = tmp_path / 'cases.json' + cases_path.write_text( + json.dumps([{'fn': fn, 'args': args} for fn, _p, args, _k in CASES]), + encoding='utf-8') + proc = subprocess.run( + [NODE, str(harness), UNITS_JS, str(cases_path)], + capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, ( + 'node harness failed: stdout={0!r} stderr={1!r}'.format( + proc.stdout, proc.stderr)) + return json.loads(proc.stdout) + + +@pytest.mark.skipif(NODE is None, reason='node is not on PATH') +def test_units_js_matches_units_py(tmp_path): + assert os.path.isfile(UNITS_JS), UNITS_JS + js_results = _run_js(tmp_path) + assert len(js_results) == len(CASES) + for (fn, py_name, args, kind), js in zip(CASES, js_results): + py_fn = getattr(units, py_name) + py_value = py_fn(*args) + assert js['ok'], '{0}{1} threw in JS: {2}'.format(fn, args, + js.get('error')) + js_value = js['value'] + if kind == 'str': + assert js_value == py_value, (fn, args, js_value, py_value) + else: + assert abs(js_value - py_value) < 1e-9, ( + fn, args, js_value, py_value) + + +def test_units_js_file_exists_even_without_node(): + """The one assertion that always runs: the file this whole module is + about is actually there, node or not.""" + assert os.path.isfile(UNITS_JS) diff --git a/tools/ticket_delta.py b/tools/ticket_delta.py new file mode 100644 index 0000000..c8785d8 --- /dev/null +++ b/tools/ticket_delta.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +"""Revision delta for precast shop tickets -- CLI. + + python tools/ticket_delta.py OLD NEW [--crops DIR] [--json OUT.json] + +OLD and NEW may each be a single PDF or a folder of them. Folders are paired +by mark, because the two issues of a real set were named differently (old by +mark, new by job-control-mark) and the mark is the only identifier both carry. + +Needs pymupdf on the path. It is not a dependency of the extension -- the +Revit side runs IronPython and cannot import it -- so this script is the only +thing in the repo that touches a PDF. +""" + +from __future__ import print_function + +import argparse +import json +import os +import sys + +import types + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_LIB = os.path.join(_HERE, '..', 'WillTools.extension', 'lib') +sys.path.insert(0, _LIB) + +# Same trick the test conftest uses, and for the same reason: vibe/__init__.py +# imports the Revit API at module scope, which is right inside Revit and fatal +# on host CPython. Register a bare `vibe` package pointed at the directory so +# the Revit-free submodules import normally and __init__.py never runs. +if 'vibe' not in sys.modules: + _stub = types.ModuleType('vibe') + _stub.__path__ = [os.path.join(_LIB, 'vibe')] + sys.modules['vibe'] = _stub + +from vibe import ticketdelta # noqa: E402 + + +def _load_extract(): + try: + from vibe import ticketextract + import pymupdf # noqa: F401 + except ImportError as exc: + raise SystemExit( + 'ticket_delta needs pymupdf on PYTHONPATH (import name pymupdf, ' + 'not fitz). %s' % exc) + return ticketextract + + +SEV_MARK = { + ticketdelta.MATERIAL: '!!', + ticketdelta.UNCERTAIN: '??', + ticketdelta.ADVISORY: ' *', + ticketdelta.HOUSEKEEP: ' ', +} + + +def _pdfs(path): + if os.path.isdir(path): + return sorted(f for f in os.listdir(path) if f.lower().endswith('.pdf')) + return [os.path.basename(path)] + + +def _dir(path): + return path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path)) + + +def render_crop(pdf, page, x, y, out_png, dpi=300, pad=90): + """Render the neighbourhood of a finding so a human can read the number. + + This exists because the text layer is not the sheet. Four independent + text-layer failure modes have been measured on this project's sources, and + each one produces a confident wrong number rather than an error. Every + reported change ships with the picture it was read from; a reviewer who + disagrees with the tool can see immediately which of them is wrong. + """ + import pymupdf + doc = pymupdf.open(pdf) + pg = doc[page] + clip = pymupdf.Rect(max(0, x - pad), max(0, y - pad), + min(pg.rect.x1, x + pad), min(pg.rect.y1, y + pad)) + pix = pg.get_pixmap(dpi=dpi, clip=clip) + pix.save(out_png) + doc.close() + return out_png + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('old') + ap.add_argument('new') + ap.add_argument('--crops', help='directory to render a PNG per material ' + 'finding into') + ap.add_argument('--json', dest='json_out') + ap.add_argument('--all', action='store_true', + help='print housekeeping findings too') + args = ap.parse_args(argv) + + tx = _load_extract() + + old_dir, new_dir = _dir(args.old), _dir(args.new) + plan = ticketdelta.pair_sets(_pdfs(args.old), _pdfs(args.new)) + + print('=' * 78) + print('REVISION DELTA') + print(' old issue: %s (%d file(s))' % (args.old, plan['naming']['old_count'])) + print(' new issue: %s (%d file(s))' % (args.new, plan['naming']['new_count'])) + if plan['naming']['changed']: + print('') + print(' !! THE FILE NAMING CHANGED BETWEEN ISSUES.') + print(' old files carry a control number on %d of %d;' + ' new on %d of %d.' + % (plan['naming']['old_has_control'], plan['naming']['old_count'], + plan['naming']['new_has_control'], plan['naming']['new_count'])) + print(' These two issues therefore CANNOT be paired by filename.') + print(' Pairing below is on the mark, the only identifier both' + ' namings carry.') + if plan['naming']['fan_out']: + print('') + print(' !! ONE OLD FILE MAPS TO SEVERAL NEW ONES:') + for m, n in sorted(plan['naming']['fan_out'].items()): + print(' %s: 1 old file -> %d new files' % (m, n)) + print(' One ticket per control number: several identical panels' + ' now get') + print(' several tickets. The comparison below runs against each' + ' of them.') + for f in plan['old_only']: + print(' !! WITHDRAWN: %s is in the old issue and has no counterpart' + ' in the new' % f) + for f in plan['new_only']: + print(' !! ADDED: %s is in the new issue and has no counterpart' + ' in the old' % f) + print('=' * 78) + + cache = {} + + def get(path): + if path not in cache: + cache[path] = tx.extract(path) + return cache[path] + + results = [] + for orec, nrecs in plan['pairs']: + a = get(os.path.join(old_dir, orec['file'])) + for nrec in nrecs: + b = get(os.path.join(new_dir, nrec['file'])) + fs = ticketdelta.compare(a, b, tx.parse_dim, tx.format_inches, + tx.locate) + results.append({'old': orec['file'], 'new': nrec['file'], + 'mark': orec['mark'], 'control': nrec['control'], + 'findings': fs}) + + for r in results: + counts = ticketdelta.summarise(r['findings']) + print('') + print('-' * 78) + print('%s %s -> %s' % (r['mark'], r['old'], r['new'])) + print(' %d material, %d uncertain, %d advisory, %d housekeeping' + % (counts[ticketdelta.MATERIAL], counts[ticketdelta.UNCERTAIN], + counts[ticketdelta.ADVISORY], counts[ticketdelta.HOUSEKEEP])) + shown = 0 + for i, f in enumerate(r['findings']): + if f['severity'] == ticketdelta.HOUSEKEEP and not args.all: + continue + shown += 1 + print(' %s [%s] %s' % (SEV_MARK[f['severity']], f['kind'], f['where'])) + print(' %s' % f['what']) + if f['old'] is not None or f['new'] is not None: + print(' %s -> %s' + % (_short(f['old']), _short(f['new']))) + if f['note']: + print(' (%s)' % f['note']) + if not shown: + print(' nothing above housekeeping.') + + if args.crops: + if not os.path.isdir(args.crops): + os.makedirs(args.crops) + n = 0 + for r in results: + for i, f in enumerate(r['findings']): + if f['severity'] != ticketdelta.MATERIAL: + continue + for side, key, pdfdir, fname in ( + ('old', 'loc_old', old_dir, r['old']), + ('new', 'loc_new', new_dir, r['new'])): + loc = f.get(key) + if not loc: + continue + stem = os.path.splitext(fname)[0] + out = os.path.join(args.crops, '%s_%02d_%s.png' + % (stem, i, side)) + try: + render_crop(os.path.join(pdfdir, fname), loc['page'], + loc['x'], loc['y'], out) + f.setdefault('crops', {})[side] = out + n += 1 + except Exception as exc: + print(' crop failed: %s' % exc) + print('\n%d crop(s) written to %s' % (n, args.crops)) + + if args.json_out: + with open(args.json_out, 'w') as fh: + json.dump({'plan': {k: v for k, v in plan.items() if k != 'pairs'}, + 'results': results}, fh, indent=1, default=str) + print('\njson written to %s' % args.json_out) + + total_material = sum(ticketdelta.summarise(r['findings'])[ticketdelta.MATERIAL] + for r in results) + print('') + print('=' * 78) + print('%d pair(s) compared, %d material change(s) in total.' + % (len(results), total_material)) + return 0 + + +def _short(v, n=110): + if v is None: + return '(none)' + v = str(v) + return v if len(v) <= n else v[:n - 3] + '...' + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/vision_read.py b/tools/vision_read.py new file mode 100644 index 0000000..df4624d --- /dev/null +++ b/tools/vision_read.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Ask a small vision model one question about one rendered ticket view. + + python tools/vision_read.py <request.json> <response.json> + +This is the CPython half of the Ticket QC vision escalation. It exists as a +separate process for the same two reasons `vibe.aiextract` does: + + * the extension runs on IronPython 2.7 inside Revit's UI thread, and there + is no HTTP client there worth trusting with a model call; + * nothing here touches the Revit API. It reads PNGs and JSON and writes + JSON, so it has no business being inside Revit. + +The two halves talk through files, never imports. `vibe.ticketvision` builds +the request, this writes the response, and neither imports the other. + +WHAT THIS MAY AND MAY NOT DO +---------------------------- +It may return a verdict from a model that looked at a picture. It may not +return anything that renders like a computed result. Every row it writes +carries `source: "model-read"`, and a row it could not obtain is +`status: "NOT_RUN"` with a reason -- never a pass, never silence. Reaching +the model is not the same as the model being right, and neither is the same +as this program having run at all; the three are kept separate on purpose. + +CREDENTIALS +----------- +Read from the environment by the Anthropic SDK: `ANTHROPIC_API_KEY`, or an +`ant auth login` profile. There is no key in this file, no key in the request +JSON, no key in the response JSON, and no key in anything printed. Nothing +here ever reads a key into a variable, so nothing here can leak one. A missing +credential is a NOT_RUN with that reason. +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import time + +# Haiku, because the ruling was Haiku: the work code cannot do goes to the +# small model, and what the small model cannot settle goes to a person. It is +# also the only sane choice per-sheet on a model with four figures of them. +DEFAULT_MODEL = "claude-haiku-4-5" + +# The three answers the skill allows. Anything else the model says is not an +# answer, and is treated as a refusal rather than coerced into one. +ANSWERS = ("clear", "problem_seen", "cannot_tell") + +# One image, one question, one JSON object back. 400 output tokens is roughly +# four times the longest legal answer -- enough that a well-formed reply is +# never truncated, small enough that a runaway one costs nothing. +MAX_TOKENS = 400 + +# The API downscales any image whose long edge exceeds 1568 px, so a bigger +# render buys nothing and costs upload time. The extension renders to 1500; +# this is the ceiling it is staying under, restated here because the two +# halves cannot import each other and a silent drift between them would show +# up as unexplained illegibility. +IMAGE_LONG_EDGE_CEILING_PX = 1568 + + +def _not_run(item, reason): + return { + "id": item.get("id"), + "question_id": item.get("question_id"), + "status": "NOT_RUN", + "reason": reason, + "answer": None, + "where": None, + "why": None, + "source": "model-read", + } + + +def _read_json(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def _write_json(path, obj): + with open(path, "w", encoding="utf-8") as fh: + json.dump(obj, fh, indent=2, sort_keys=True) + + +def load_skill(path): + """The instructions the model reads, from the file that is versioned with + the code. Deliberately not a string in this module: a prompt buried in a + function is a prompt nobody reviews.""" + with open(path, encoding="utf-8") as fh: + return fh.read() + + +def parse_answer(text, question_id): + """The model's reply -> a result row, or None when it is not an answer. + + Strict on purpose. A reply that is prose, or JSON with an answer word the + skill does not define, is NOT coerced into the nearest legal value -- it + is a refusal, because a coerced verdict is exactly the hallucination this + whole path is built to keep out of the report. + """ + if not text: + return None + body = text.strip() + if body.startswith("```"): + body = body.split("\n", 1)[-1] + if body.rstrip().endswith("```"): + body = body.rstrip()[:-3] + start, end = body.find("{"), body.rfind("}") + if start < 0 or end <= start: + return None + try: + obj = json.loads(body[start:end + 1]) + except ValueError: + return None + if not isinstance(obj, dict): + return None + answer = obj.get("answer") + if answer not in ANSWERS: + return None + if obj.get("question_id") not in (None, question_id): + # It answered a different question than the one it was asked. That is + # not a near-miss to be salvaged; it means the reply is not about this + # image. + return None + why = obj.get("why") + where = obj.get("where") + return { + "answer": answer, + "where": where if isinstance(where, str) and where.strip() else None, + "why": why if isinstance(why, str) and why.strip() else None, + } + + +def build_user_content(item): + """One image block, then the question. Image first: the guidance is that a + question about an image reads better after the image.""" + with open(item["image"], "rb") as fh: + data = base64.standard_b64encode(fh.read()).decode("utf-8") + lines = [ + "question_id: " + str(item.get("question_id")), + "", + "This is one view rendered off a precast shop ticket.", + ] + if item.get("view_kind"): + lines.append("The view is a " + str(item["view_kind"]) + ".") + if item.get("effective_dpi"): + lines.append( + "It was rendered at about {0:.0f} dots per inch of the printed " + "sheet. If that was not enough to read it, say so." + .format(float(item["effective_dpi"]))) + lines.append("") + lines.append("Answer the question named above, as one JSON object.") + return [ + {"type": "image", + "source": {"type": "base64", "media_type": "image/png", + "data": data}}, + {"type": "text", "text": "\n".join(lines)}, + ] + + +def ask(client, model, skill_text, item): + """One call. Returns a result row; never raises for an API problem.""" + try: + content = build_user_content(item) + except OSError as ex: + return _not_run(item, "the rendered image could not be read: " + "{0}".format(type(ex).__name__)) + + started = time.time() + try: + message = client.messages.create( + model=model, + max_tokens=MAX_TOKENS, + system=skill_text, + messages=[{"role": "user", "content": content}], + ) + except Exception as ex: # noqa: BLE001 - see below + # Every failure to REACH the model is one outcome: NOT RUN, with the + # reason. Never a pass. The exception type is reported and the message + # is not, because an SDK error string can carry request detail and + # this file's whole discipline is that nothing sensitive is written + # to a file the tool keeps. + return _not_run(item, "the model could not be reached: {0}" + .format(type(ex).__name__)) + + text = "".join([b.text for b in message.content + if getattr(b, "type", None) == "text"]) + parsed = parse_answer(text, item.get("question_id")) + if parsed is None: + return _not_run(item, "the model's reply was not one of the three " + "answers the skill defines") + + usage = getattr(message, "usage", None) + row = _not_run(item, None) + row.update(parsed) + row["status"] = "OK" + row["reason"] = None + row["model"] = model + row["elapsed_s"] = round(time.time() - started, 2) + row["input_tokens"] = getattr(usage, "input_tokens", None) + row["output_tokens"] = getattr(usage, "output_tokens", None) + return row + + +def run(request): + """The request dict -> the response dict. No I/O beyond the images.""" + items = request.get("items") or [] + model = request.get("model") or DEFAULT_MODEL + out = {"model": model, "source": "model-read", "results": []} + + max_calls = request.get("max_calls") + if isinstance(max_calls, int) and max_calls >= 0 and len(items) > max_calls: + # The bound is the caller's, and it is enforced here as well as there. + # Over-budget items are NOT RUN naming the bound -- a dropped item + # would read as an item that passed. + for item in items[max_calls:]: + out["results"].append(_not_run( + item, "this run's vision call budget ({0}) was already spent" + .format(max_calls))) + items = items[:max_calls] + + try: + skill_text = load_skill(request["skill"]) + except (OSError, KeyError) as ex: + out["results"][:0] = [_not_run( + item, "the ticketqc-vision skill file could not be read " + "({0})".format(type(ex).__name__)) for item in items] + return out + + try: + import anthropic + except ImportError: + out["results"][:0] = [_not_run( + item, "the anthropic SDK is not installed for this Python -- " + "pip install anthropic") for item in items] + return out + + try: + client = anthropic.Anthropic() + except Exception as ex: # noqa: BLE001 + out["results"][:0] = [_not_run( + item, "no Anthropic credential is available ({0}) -- set " + "ANTHROPIC_API_KEY in the environment, or run " + "`ant auth login`".format(type(ex).__name__)) + for item in items] + return out + + for i, item in enumerate(items): + out["results"].insert(i, ask(client, model, skill_text, item)) + return out + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("request", help="request JSON written by ticketvision") + ap.add_argument("response", help="where to write the response JSON") + args = ap.parse_args(argv) + + request = _read_json(args.request) + response = run(request) + _write_json(args.response, response) + + ok = len([r for r in response["results"] if r["status"] == "OK"]) + print("{0} answered, {1} NOT RUN".format( + ok, len(response["results"]) - ok)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/webshot.py b/tools/webshot.py new file mode 100755 index 0000000..c8ffa70 --- /dev/null +++ b/tools/webshot.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Render a WillTools web page headless and screenshot every tab -- the +browser dry-run this machine could not do until 2026-09-07 (owner: "浏览器你 +能不能在debian上面自己渲染一个?"). + + python tools/webshot.py pa --reads _scratch/pa_reads_x.json --out /tmp/shots + python tools/webshot.py ba --reads beamcalc/fixtures/rba5_16x22.json --out /tmp/shots + +Starts tools/pa_web.py or tools/ba_web.py on a free port, drives Chrome +headless (google-chrome / chromium on PATH) to /?tab=N&expand=all for each +tab, writes <out>/<tool>-tab<N>.png plus <out>/<tool>-console.txt (the page's +console errors, which is the half a screenshot cannot show), then closes the +server through POST /api/close. Exit 1 if any tab logged a console error. +CPython 3 only; no Revit anywhere. +""" +import argparse, json, os, shutil, subprocess, sys, time, urllib.request + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def chrome(): + for c in ('google-chrome', 'chromium', 'chromium-browser', 'chrome'): + p = shutil.which(c) + if p: + return p + sys.exit('no chrome/chromium on PATH') + + +def start_server(tool, reads): + args = [sys.executable, os.path.join(ROOT, 'tools', '%s_web.py' % tool), '--port', '0'] + if reads: + args += ['--reads', reads] + p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=ROOT) + url = None + for _ in range(200): + line = p.stdout.readline() + if not line: + break + if '_WEB_URL=' in line: + url = line.split('=', 1)[1].strip() + break + if not url: + err = p.stderr.read() + sys.exit('server did not print its URL\n' + err) + return p, url + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('tool', choices=['pa', 'ba']) + ap.add_argument('--reads') + ap.add_argument('--out', default='/tmp/webshot') + ap.add_argument('--tabs', type=int, default=5) + ap.add_argument('--width', type=int, default=1400) + ap.add_argument('--height', type=int, default=2200) + a = ap.parse_args() + os.makedirs(a.out, exist_ok=True) + p, url = start_server(a.tool, a.reads) + exe = chrome() + errors = [] + try: + spec = json.load(urllib.request.urlopen(url + 'api/spec', timeout=30)) + tabs = len(spec.get('pages', [])) or a.tabs + for n in range(1, tabs + 1): + png = os.path.join(a.out, '%s-tab%d.png' % (a.tool, n)) + log = os.path.join(a.out, '%s-tab%d.console.txt' % (a.tool, n)) + cmd = [exe, '--headless=new', '--no-sandbox', '--disable-gpu', '--hide-scrollbars', + '--window-size=%d,%d' % (a.width, a.height), '--virtual-time-budget=4000', + '--enable-logging=stderr', '--v=0', + '--screenshot=' + png, '%s?tab=%d&expand=all' % (url, n)] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + console = [l for l in r.stderr.splitlines() if 'CONSOLE' in l] + with open(log, 'w') as fh: + fh.write('\n'.join(console)) + bad = [l for l in console if 'error' in l.lower() or 'uncaught' in l.lower()] + errors += bad + print('tab %d -> %s (%d console lines, %d errors)' % (n, png, len(console), len(bad))) + finally: + try: + urllib.request.urlopen(urllib.request.Request(url + 'api/close', method='POST', data=b'{}'), timeout=5) + except Exception: + pass + p.terminate() + if errors: + print('CONSOLE ERRORS:\n' + '\n'.join(errors[:20])) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/tools/wpf_smoke.py b/tools/wpf_smoke.py new file mode 100644 index 0000000..637ea77 --- /dev/null +++ b/tools/wpf_smoke.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""wpf_smoke -- exec a button's System / System.Windows imports inside Revit. + +A wrong WPF namespace is invisible to every host gate: host CPython cannot +import System.Windows at all (no .NET), so a button whose +`from System.Windows.Media import StretchDirection` (the type lives in +System.Windows.Controls) passes every host test and dies at import the first +time the owner clicks it. The bridge runs IronPython INSIDE Revit, where the +.NET assemblies exist, so the check is cheap: collect the script's System.* +import statements, ship them through tools/rvt.py as one snippet, and require +zero import errors. That is the only place they can be checked. + + python tools/wpf_smoke.py <script.py> [--port N] + +Host CPython 3 only -- this file never runs inside Revit (the SNIPPET it +builds is IronPython 2.7). Verdicts: + + OK (exit 0) every collected import loaded + FAIL (exit 1) at least one import raised; one line per error + COULD NOT JUDGE (exit 2) the bridge could not run the check -- never OK +""" +from __future__ import annotations + +import argparse +import os +import re + +# Every complete import statement whose FIRST line names the System +# namespace. Anchored to the start of a (possibly indented) line so comment +# prose and string bodies are ignored; `System` must be followed by a dot, +# whitespace or end of line so a longer identifier like `Systematically` is +# not matched. Parenthesised continuations are followed separately. +_IMPORT_START = re.compile(r'^\s*from System(\.|\s)|^\s*import System(\.|\s|$)') + +# The snippet head. The four references cover the WPF surface (System.Xaml +# holds System.Windows.Markup); the rest of System.* resolves through the +# assemblies IronPython already has. An AddReference failure is swallowed: a +# missing assembly is not itself the answer, the import below either finds +# the name or raises. +_REFERENCE_HEAD = ( + 'import clr\n' + "for _r in ('PresentationFramework','PresentationCore','WindowsBase'," + "'System.Xaml'):\n" + ' try:\n' + ' clr.AddReference(_r)\n' + ' except Exception:\n' + ' pass\n' + '_errors = []\n' + '_n = 0\n' +) + +# How much of a failing statement is echoed in its error line. +_ERR_PREFIX_LEN = 80 + + +def collect_system_imports(source: str) -> list: + """Every complete ``System.*`` import statement in `source`, one per entry. + + A statement starts at a line matching `_IMPORT_START`; a parenthesised + multi-line import is followed to the line that balances its parentheses + (counting ``(`` and ``)``). Each physical line is stripped and the parts + joined with a space, so the result is one valid one-line statement -- + exactly the shape IronPython's ``exec`` expects when each entry runs on + its own inside the snippet. + """ + lines = source.splitlines() + out = [] + i = 0 + n = len(lines) + while i < n: + line = lines[i] + if not _IMPORT_START.match(line): + i += 1 + continue + parts = [line.strip()] + depth = line.count('(') - line.count(')') + i += 1 + while depth > 0 and i < n: + nxt = lines[i] + # A line that itself starts a System import cannot be a + # continuation: the current parenthesised import is broken + # (never closed), so stop here and let the outer loop collect + # the next statement normally rather than swallowing it. + if _IMPORT_START.match(nxt): + break + depth += nxt.count('(') - nxt.count(')') + parts.append(nxt.strip()) + i += 1 + collapsed = ' '.join(parts) + # Whitespace-normalise the collapsed one-liner: no space after `(`, + # no space before `)`, and no runs of spaces elsewhere (a `(` or + # `)` alone on its own physical line would otherwise leave a + # padding space around it). + collapsed = re.sub(r'\(\s+', '(', collapsed) + collapsed = re.sub(r'\s+\)', ')', collapsed) + collapsed = re.sub(r'\s+', ' ', collapsed).strip() + out.append(collapsed) + return out + + +def build_snippet(statements: list) -> str: + """The IronPython 2.7 snippet that loads each statement inside Revit. + + Each statement is exec'd in its own try/except so one bad namespace -- the + bug this tool exists for -- is collected as an error instead of killing + the snippet and leaving the rest un-checked. The final `result` line is + the contract /exec reads to send data back (startup.py picks it out of + the namespace after running the snippet). The snippet is IronPython 2.7: + no f-strings, no pathlib; the formatting happens on the HOST side below. + """ + parts = [_REFERENCE_HEAD] + for stmt in statements: + # The error line is THREE concatenated literals in the emitted + # snippet: <repr of the statement, first 80 chars> + ' -> ' + + # str(_e). The statement text is single-quoted by repr(); ' -> ' + # is its own separate literal, never folded into the statement + # string, so the emitted line stays valid Python. + parts.append( + '_n += 1\n' + 'try:\n' + ' exec({0})\n' + 'except Exception as _e:\n' + ' _errors.append({1} + \' -> \' + str(_e))' + .format(repr(stmt), repr(stmt[:_ERR_PREFIX_LEN]))) + parts.append("result = {'import_statements_checked': _n, 'errors': _errors}") + return '\n'.join(parts) + '\n' + + +def _bridge_call(code, port=None): + """Send the snippet through tools/rvt.py's /exec -- the same path that + `rvt.py exec <file>` takes, so the answer carries everything rvt guards + (correlation, the timeout latch, the reload tripwire) for free. + + `port` is honoured the way every other bridge tool honours it: set + WILLTOOLS_BRIDGE_PORT in the environment BEFORE the call, because rvt + reads that variable per request, not at import. rvt is imported here + rather than at module scope so importing this module never touches the + network and never needs `tools/` on sys.path. + """ + if port is not None: + os.environ['WILLTOOLS_BRIDGE_PORT'] = str(port) + import rvt + return rvt.call('/exec', 'POST', {'code': code}) + + +def judge(script: str, port=None): + """Run the whole smoke for one script file. + + Returns (exit_code, lines-to-print): 0 for OK, 1 for FAIL, 2 for COULD + NOT JUDGE. A refusal, a transport failure or an /exec that did not run + are all COULD NOT JUDGE -- "the bridge did not answer" is never reported + as "the imports are fine". + """ + try: + with open(script, 'r', encoding='utf-8') as fh: + source = fh.read() + except (OSError, UnicodeDecodeError) as exc: + return 2, ['COULD NOT JUDGE -- cannot read {0}: {1}' + .format(script, exc)] + statements = collect_system_imports(source) + snippet = build_snippet(statements) + try: + res = _bridge_call(snippet, port=port) + except SystemExit as exc: + # rvt.py refused or could not reach the bridge and already printed + # why on stderr; the verdict must still say it was not judged. + return 2, ['COULD NOT JUDGE -- the bridge call did not complete ' + '(exit {0}); rvt.py printed why on stderr' + .format(exc.code)] + except Exception as exc: # pragma: no cover - defensive only + return 2, ['COULD NOT JUDGE -- the bridge call raised {0}: {1}' + .format(type(exc).__name__, exc)] + if not isinstance(res, dict): + return 2, ['COULD NOT JUDGE -- unexpected bridge answer: {0!r}' + .format(res)] + if res.get('refused'): + return 2, ['COULD NOT JUDGE -- the bridge refused to run the check: ' + '{0}'.format(res.get('error') or 'refused')] + if not res.get('ok'): + return 2, ['COULD NOT JUDGE -- the snippet did not run inside Revit: ' + '{0}'.format(res.get('error') or 'exec failed')] + result = res.get('result') + if not isinstance(result, dict): + return 2, ['COULD NOT JUDGE -- /exec returned no usable result'] + errors = result.get('errors') + checked = result.get('import_statements_checked') + if not isinstance(errors, list) or not isinstance(checked, int): + return 2, ['COULD NOT JUDGE -- /exec result had an unexpected shape'] + if errors: + head = 'FAIL {0} -- {1} import error(s):'.format(script, len(errors)) + return 1, [head] + ['{0}'.format(e) for e in errors] + return 0, ['OK {0} -- {1} WPF import statements load inside Revit' + .format(script, checked)] + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + prog='wpf_smoke', description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('script', help='path to the button script.py to smoke') + ap.add_argument('--port', type=int, default=None, + help='bridge port (default: WILLTOOLS_BRIDGE_PORT, ' + 'else 48884)') + args = ap.parse_args(argv) + code, lines = judge(args.script, port=args.port) + for line in lines: + print(line) + return code + + +if __name__ == '__main__': + raise SystemExit(main())