WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""A piece does not always hang the way it was cast.
A horizontal panel is stripped flat, spanning its long direction like
everything else, and then stood up on its long EDGE. Gravity then runs across
what used to be the width, and that is the direction it spans.
The solver is not taught about orientation. The piece is turned instead, so
the mechanics stay one implementation checked against published examples.
"""
import dataclasses
import pytest
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT, make_panel
from panellift import cli, statics
from panellift.model import Blockout, Lifter
def horizontal_job(span):
"""Anchors along one long edge, face lifters inboard of it."""
return {
'schema': cli.SCHEMA,
'panel': {
'mark': 'H1', 'length_ft': 30.0, 'width_ft': 8.0,
'thickness_in': 8.0, 'weight_lb': 24000.0, 'fc_psi': 5000.0,
'blockouts': [{'name': 'O', 'x0': 10.0, 'y0': 2.0,
'length': 6.0, 'width': 3.0}],
'lifters': [
{'mark': 'A', 'family': 'ANCHOR', 'x': 6.0, 'y': 8.0,
'on_edge': True, 'pinned_y': True, 'capacity_lb': 7690.0,
'shear_capacity_lb': 4010.0},
{'mark': 'B', 'family': 'ANCHOR', 'x': 24.0, 'y': 8.0,
'on_edge': True, 'pinned_y': True, 'capacity_lb': 7690.0,
'shear_capacity_lb': 4010.0},
{'mark': 'C', 'family': 'FACE', 'x': 6.0, 'y': 1.5,
'capacity_lb': 8000.0},
{'mark': 'D', 'family': 'FACE', 'x': 24.0, 'y': 1.5,
'capacity_lb': 8000.0},
],
},
'stages': [{'name': 'erection', 'multiplier': 1.2, 'fc_psi': 5000.0,
'span': span}],
}
# --------------------------------------------------------- the transposition
def test_turning_the_piece_swaps_its_two_in_plane_dimensions():
panel = make_panel()
turned = cli.transpose(panel)
assert turned.length_ft == pytest.approx(WIDTH_FT)
assert turned.width_ft == pytest.approx(LENGTH_FT)
assert turned.thickness_in == pytest.approx(THICKNESS_IN)
def test_turning_the_piece_takes_the_blockouts_with_it():
panel = dataclasses.replace(make_panel(), blockouts=[
Blockout(name='O', x0=4.0, y0=1.0, length=6.0, width=2.0)])
hole = cli.transpose(panel).blockouts[0]
assert (hole.x0, hole.y0, hole.length, hole.width) == (1.0, 4.0, 2.0, 6.0)
def test_turning_the_piece_swaps_which_way_an_insert_is_pinned():
"""An anchor pinned to a long edge is pinned across the piece as cast and
ALONG it once the piece has been stood up on that edge. Carrying the flag
through unchanged would free it to slide off its own edge."""
panel = dataclasses.replace(make_panel(), lifters=[
Lifter(mark='A', family='F', x=2.0, y=9.0, pinned_y=True)])
turned = cli.transpose(panel).lifters[0]
assert (turned.x, turned.y) == (9.0, 2.0)
assert turned.pinned and not turned.pinned_y
def test_turning_twice_is_the_piece_you_started_with():
panel = make_panel()
there_and_back = cli.transpose(cli.transpose(panel))
assert there_and_back.length_ft == pytest.approx(panel.length_ft)
assert [(l.x, l.y) for l in there_and_back.lifters] == \
[(l.x, l.y) for l in panel.lifters]
# ------------------------------------------------------------ through a job
def test_a_stage_spanning_the_width_really_spans_the_width():
_p, _r, cases = cli.analyse_job(horizontal_job('width'))
assert cases[0][0].panel.length_ft == pytest.approx(8.0)
assert cases[0][0].stage.span == 'width'
def test_the_same_inserts_group_differently_when_the_piece_turns():
"""The anchors along the top edge are ONE support when the piece is
standing and TWO when it is flat. That is the whole reason to turn it."""
_p, _r, flat = cli.analyse_job(horizontal_job('length'))
_p, _r, stood = cli.analyse_job(horizontal_job('width'))
assert len(flat[0][0].rig.lines) == 2 # by x: 6 ft and 24 ft
assert len(stood[0][0].rig.lines) == 2 # by y: 1.5 ft and 8 ft
stood_line = max(stood[0][0].rig.lines, key=lambda l: l.x)
assert stood_line.count == 2 # both anchors, one support
def test_a_stage_defaults_to_spanning_its_length():
_p, _r, cases = cli.analyse_job(horizontal_job(None))
assert cases[0][0].stage.span == 'length'
assert cases[0][0].panel.length_ft == pytest.approx(30.0)
def test_an_unknown_span_is_refused():
with pytest.raises(cli.JobError):
cli.analyse_job(horizontal_job('diagonally'))
def test_the_position_search_stands_aside_for_a_turned_stage():
"""It optimises one set of coordinates. A turned stage's x is the panel's
y, so including it would be optimising against two different pieces.
It still answers for the stages it can, and says which one it left out --
silently optimising against two of three stages would read as an answer
for all three.
"""
from panellift import layout
job = horizontal_job('width')
job['stages'].insert(0, {'name': 'stripping', 'multiplier': 1.3,
'fc_psi': 4000.0})
_p, _r, cases = cli.analyse_job(job)
rec = layout.recommend(cases)
assert rec.moves # stripping still answered
assert [s.name for s in rec.stages] == ['stripping']
assert 'erection' in rec.note
def test_the_search_says_so_when_every_stage_is_turned():
from panellift import layout
_p, _r, cases = cli.analyse_job(horizontal_job('width'))
rec = layout.recommend(cases)
assert not rec.moves and 'erection' in rec.note