52d94303bc
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.
1556 lines
55 KiB
Python
1556 lines
55 KiB
Python
# 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 NO clash detection, NO OST_SpecialtyEquipment, NO lifters, NO
|
||
# families.lifter_include and NO liftexport.export here. The owner ruled Stage
|
||
# 1 is purely an input-confirmation form (2026-09-02); auto support detection
|
||
# is a later convenience. 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, ticketqc
|
||
from vibe import panelanalysisform as F
|
||
from vibe import panelsketch
|
||
from vibe.compat import as_text
|
||
|
||
doc = revit.doc
|
||
uidoc = revit.uidoc
|
||
output = script.get_output()
|
||
output.close_others()
|
||
|
||
TITLE = 'Panel Analysis'
|
||
|
||
|
||
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))
|
||
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 _resolve_framing():
|
||
"""The framing panels to build a form for, from the current selection.
|
||
|
||
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.
|
||
"""
|
||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||
found = []
|
||
seen = set()
|
||
|
||
def add(el):
|
||
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)
|
||
|
||
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))
|
||
continue
|
||
try:
|
||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||
except Exception:
|
||
owner = None
|
||
if isinstance(owner, AssemblyInstance):
|
||
add(_framing_of_assembly(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 _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 _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
|
||
# 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.
|
||
mark = _role_str(el, know, 'member.mark') or _str_from_param(el, 'Mark')
|
||
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')
|
||
if control and control != mark:
|
||
out['control_number'] = control
|
||
return out
|
||
|
||
|
||
def _reads_for(el, index, know):
|
||
reads = {}
|
||
reads.update(_read_geometry(el))
|
||
reads.update(_read_materials(el, know))
|
||
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')
|
||
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')
|
||
|
||
|
||
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 = liftexport.find_python(KNOW)
|
||
if not python_exe:
|
||
return False, ('CPython 3.9+ was not found. The page renders outside '
|
||
'Revit; install Python or set handling.python_exe in '
|
||
'the knowledge file.')
|
||
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
|
||
|
||
|
||
# ------------------------------------------------------------------- WPF
|
||
|
||
clr.AddReference('PresentationFramework')
|
||
clr.AddReference('PresentationCore')
|
||
clr.AddReference('WindowsBase')
|
||
|
||
from System.Windows import (Window, Thickness, FontWeights, FontStyles,
|
||
TextWrapping, TextDecorations,
|
||
SizeToContent, WindowStartupLocation,
|
||
HorizontalAlignment, VerticalAlignment)
|
||
from System.Windows.Controls import (StackPanel, ScrollViewer, Expander,
|
||
TextBox, TextBlock, Button, Border,
|
||
Orientation, ScrollBarVisibility, Canvas,
|
||
Grid, ColumnDefinition, RowDefinition,
|
||
TabControl, TabItem, CheckBox, ComboBox)
|
||
from System.Windows.Controls import Label as WpfLabel
|
||
from System.Windows.Documents import Run
|
||
from System.Windows.Media import Brushes, RotateTransform
|
||
from System.Windows.Shapes import Line, Polygon, Rectangle, Ellipse
|
||
from System.Windows import Point, BaselineAlignment, GridLength, GridUnitType
|
||
from System.Windows.Media import PointCollection
|
||
|
||
|
||
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,
|
||
}
|
||
|
||
_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 _sketch_text(prim):
|
||
tb = TextBlock()
|
||
tb.Text = prim.get('text', '')
|
||
tb.FontSize = 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))
|
||
r.Stroke = brush
|
||
r.StrokeThickness = 1.5
|
||
r.Fill = (Brushes.Transparent
|
||
if role in ('opening', 'reveal', 'solid-zone')
|
||
else Brushes.AliceBlue)
|
||
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
|
||
ln.StrokeThickness = 1.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.
|
||
x, y = prim.get('x', 0), prim.get('y', 0)
|
||
tri = Polygon()
|
||
pts = PointCollection()
|
||
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)
|
||
# else: an unrecognised primitive kind. Skipped, not raised on.
|
||
|
||
|
||
def _drawing_canvas(source, values, w, h):
|
||
"""(Border-wrapped Canvas, problems) for one `drawing` block.
|
||
|
||
`source` names an entry point in `vibe.panelsketch`; 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.
|
||
"""
|
||
canvas = Canvas()
|
||
canvas.Width = w
|
||
canvas.Height = h
|
||
canvas.Background = Brushes.WhiteSmoke
|
||
|
||
fn = getattr(panelsketch, source, None) if source else None
|
||
if fn is None:
|
||
primitives, problems = [], [
|
||
"drawing source '{0}' is not available.".format(source)]
|
||
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
|
||
|
||
for n, problem in enumerate(problems[:4]):
|
||
note = TextBlock()
|
||
note.Text = u'• ' + problem
|
||
note.FontSize = 10
|
||
note.Foreground = Brushes.Firebrick
|
||
note.TextWrapping = TextWrapping.Wrap
|
||
note.Width = max(w - 12, 40)
|
||
Canvas.SetLeft(note, 6)
|
||
Canvas.SetTop(note, 6 + n * 24)
|
||
canvas.Children.Add(note)
|
||
|
||
border = Border()
|
||
border.BorderBrush = Brushes.Gainsboro
|
||
border.BorderThickness = Thickness(1)
|
||
border.Margin = Thickness(0, 4, 0, 4)
|
||
border.Child = canvas
|
||
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
|
||
|
||
|
||
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
|
||
|
||
|
||
LABEL_COL = 210
|
||
SYMBOL_COL = 92
|
||
EDITOR_COL = 150
|
||
|
||
|
||
def _two_col_row(label_widget, editor, symbol=None, hint=None):
|
||
"""One input, laid out `name | symbol | box` 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.
|
||
"""
|
||
grid = Grid()
|
||
for width in (LABEL_COL, SYMBOL_COL, EDITOR_COL):
|
||
col = ColumnDefinition()
|
||
col.Width = GridLength(width)
|
||
grid.ColumnDefinitions.Add(col)
|
||
grid.RowDefinitions.Add(RowDefinition())
|
||
if hint:
|
||
grid.RowDefinitions.Add(RowDefinition())
|
||
|
||
label_widget.Margin = Thickness(0, 2, 8, 0)
|
||
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, 2, 8, 0)
|
||
Grid.SetRow(sym, 0)
|
||
Grid.SetColumn(sym, 1)
|
||
grid.Children.Add(sym)
|
||
|
||
Grid.SetRow(editor, 0)
|
||
Grid.SetColumn(editor, 2)
|
||
grid.Children.Add(editor)
|
||
|
||
if hint:
|
||
note = _hint_block(hint)
|
||
Grid.SetRow(note, 1)
|
||
Grid.SetColumn(note, 0)
|
||
Grid.SetColumnSpan(note, 3)
|
||
grid.Children.Add(note)
|
||
|
||
grid.Margin = Thickness(0, 1, 0, 1)
|
||
return grid
|
||
|
||
|
||
def _tab(title, child):
|
||
item = TabItem()
|
||
item.Header = title
|
||
scroller = ScrollViewer()
|
||
scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto
|
||
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))
|
||
grid.RowDefinitions.Add(RowDefinition())
|
||
for i, col_blocks in enumerate(cols):
|
||
panel = StackPanel()
|
||
panel.Margin = Thickness(6, 0, 6, 0)
|
||
panel.VerticalAlignment = VerticalAlignment.Top
|
||
for b in (col_blocks or []):
|
||
w = _render_block(b, form)
|
||
if w is not None:
|
||
panel.Children.Add(w)
|
||
Grid.SetColumn(panel, i)
|
||
Grid.SetRow(panel, 0)
|
||
grid.Children.Add(panel)
|
||
return grid
|
||
|
||
|
||
def _render_field(block, form):
|
||
key = block.get('key')
|
||
label = block.get('label') or key or ''
|
||
if block.get('required'):
|
||
label = label + u' *'
|
||
lab = _math_block(label, size=11)
|
||
editor = form.make_editor(block.get('editor'), key, block.get('choices'),
|
||
width=EDITOR_COL)
|
||
return _two_col_row(lab, editor, symbol=block.get('symbol'),
|
||
hint=block.get('hint'))
|
||
|
||
|
||
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') == 'check':
|
||
widget = form.make_editor('check', value.get('key'))
|
||
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))
|
||
if row.get('hint'):
|
||
lbl_panel.Children.Add(_hint_block(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_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):
|
||
"""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."""
|
||
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)
|
||
if kind == 'kv':
|
||
return _render_kv(block)
|
||
if kind == 'sentence':
|
||
return _render_sentence(block)
|
||
if kind == 'table':
|
||
return _render_table(block, form)
|
||
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 == '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):
|
||
self.el = el
|
||
self.index = index
|
||
self.reads = _reads_for(el, index, know)
|
||
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')
|
||
self.drawings = [] # [(container, source, caption, w, h)]
|
||
self.run_btn = None
|
||
self.status = None
|
||
self.gcpi_label = None
|
||
self.tabctrl = None
|
||
self.expander = self._build()
|
||
|
||
# -- 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
|
||
w.Checked += self._on_change
|
||
w.Unchecked += self._on_change
|
||
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
|
||
w.SelectionChanged += self._on_change
|
||
w.LostFocus += self._on_change
|
||
ekind = 'choice'
|
||
else: # 'text' / 'num' / other
|
||
w = TextBox()
|
||
w.Text = initial or ''
|
||
if width:
|
||
w.Width = width
|
||
w.TextChanged += self._on_change
|
||
ekind = 'text'
|
||
if key:
|
||
self.editors[key] = (w, ekind)
|
||
if key == 'wind_enclosure':
|
||
# Owner's requirement: choosing an enclosure shows its matching
|
||
# GCpi (ASCE Table 26.13-1). Pure lookup in the form module --
|
||
# windload itself is CPython and cannot be imported here. The
|
||
# readout sits beside the enclosure editor and `refresh_gate`
|
||
# refreshes it on every change via `_update_gcpi`.
|
||
self.gcpi_label = _label_block('', size=11, brush=Brushes.Gray)
|
||
self.gcpi_label.Width = 220
|
||
self.gcpi_label.Margin = Thickness(8, 0, 0, 0)
|
||
holder = StackPanel()
|
||
holder.Orientation = Orientation.Horizontal
|
||
holder.Children.Add(w)
|
||
holder.Children.Add(self.gcpi_label)
|
||
return holder
|
||
return w
|
||
|
||
# -- drawings ------------------------------------------------------------
|
||
def drawing_widget(self, block):
|
||
source = block.get('source')
|
||
caption = block.get('caption')
|
||
w = block.get('w') or SKETCH_W
|
||
h = block.get('h') or SKETCH_H
|
||
container = StackPanel()
|
||
self._draw_into(container, source, caption, w, h, self.values0)
|
||
self.drawings.append((container, source, caption, w, h))
|
||
return container
|
||
|
||
def _draw_into(self, container, source, caption, w, h, values):
|
||
container.Children.Clear()
|
||
container.Children.Add(_drawing_canvas(source, values, w, h))
|
||
if caption:
|
||
cap = TextBlock()
|
||
cap.Text = caption
|
||
cap.TextDecorations = TextDecorations.Underline
|
||
cap.HorizontalAlignment = HorizontalAlignment.Center
|
||
cap.FontSize = 11
|
||
cap.Margin = Thickness(0, 0, 0, 10)
|
||
container.Children.Add(cap)
|
||
|
||
def _redraw_drawings(self):
|
||
vals = self.current_values()
|
||
for container, source, caption, w, h in self.drawings:
|
||
try:
|
||
self._draw_into(container, source, caption, w, h, 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)
|
||
|
||
for b in (blocks or []):
|
||
w = _render_block(b, self)
|
||
if w is not None:
|
||
content.Children.Add(w)
|
||
|
||
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)
|
||
|
||
exp = Expander()
|
||
exp.Header = self.title
|
||
exp.IsExpanded = False
|
||
exp.Content = body
|
||
exp.Margin = Thickness(0, 2, 0, 2)
|
||
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):
|
||
self.refresh_gate()
|
||
self._redraw_drawings()
|
||
|
||
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
|
||
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)
|
||
else:
|
||
self.status.Text = 'Did not run -- see the output panel.'
|
||
self.status.Foreground = Brushes.Firebrick
|
||
_report_one(self.mark, False, message)
|
||
|
||
|
||
def _report_one(mark, ok, message):
|
||
if ok:
|
||
output.print_md('- **{0}** — ran. [{1}]({2})'.format(
|
||
mark, os.path.basename(message),
|
||
'file:///' + message.replace('\\', '/')))
|
||
else:
|
||
output.print_md('- **{0}** — NOT RUN: {1}'.format(mark, message))
|
||
|
||
|
||
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)
|
||
if ok:
|
||
ran_ok += 1
|
||
for label, errors in skipped:
|
||
output.print_md('- **{0}** — SKIPPED (incomplete): {1}'.format(
|
||
label, '; '.join(errors[:6])))
|
||
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
|
||
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))
|
||
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) for i, el in enumerate(panels)]
|
||
show_form(forms_list)
|