# 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)