# -*- coding: utf-8 -*- """Panel Analysis input form -- the "Concrete Extents" section, laid out. The owner has said three times the Panel Analysis form is too ugly to read ("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02: "全部给我做成 普通人能看懂的样子" -- every part of it has to be something an ordinary person, not just an engineer, can understand. The fix agreed on is to make the form look like Eriksson Wall's own printed page for this section (a solid wall panel's "Concrete Extents" page: Panel Geometry, an Openings / Reveals / Solid Zones table, and a drawings row) but with a plain-English `label` beside every engineering `symbol` and a one-sentence `hint` on every field -- never the symbol alone, standing in as if it were the explanation. `page(values, reads=None)` returns `(blocks, problems)`: a flat list of block dicts from a fixed vocabulary a generic WPF renderer (built separately, not here) consumes, plus a list of plain-English sentences for anything that could not be laid out. This module never raises -- a field it cannot place becomes a `problems` entry, never a traceback the owner has to read a Python stack to understand. WHY THIS FILE OWNS NO Revit, NO WPF AND NO `panelsketch` IMPORT: it has to load and run on plain CPython under `tests/conftest.py`'s trick (see that file's docstring) the same way the rest of Revit-free `vibe` does, and it has to run under IronPython 2.7 inside the button. `panelsketch` is named by STRING in a `drawing` block's `source` field -- the renderer calls it, not this module -- because a sibling agent is extending `panelsketch` at the same time this file is being written, and importing a module mid-rewrite is how two agents' work collides. WHY `values['openings_rows']` IS READ DEFENSIVELY: another agent owns `vibe.panelanalysisform`, the source of the form's key names, and is restructuring it at the same time this file is being written. A key this module expects that is not actually there is "not present", never treated as a bug -- `_paf_field` returns `None` and the caller falls back rather than raising. The same discipline applies to the structured opening rows a third agent is meant to be adding at `values['openings_rows']` (shaped `{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross', 'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}`, per that agent's own contract): when it is not there yet, or is not a list, this module falls back to the raw `values['openings']` text field and SAYS SO in `problems`, rather than guessing at a shape that has not landed. WHY THE SECTION PROPERTIES BLOCK OMITS MOST OF ERIKSSON'S OWN ROWS: Eriksson prints Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb and St, but the last seven need either an axis convention this module has no authority to invent, or a net-of-openings deduction it does not compute. Printing a plausible number in a column headed "St, in³" when nobody has verified it is exactly the fabricated section modulus the owner has ruled against elsewhere in this codebase (`CLAUDE.md` "no invented default" discipline) -- so this module computes ONLY Area / Width / Height, from the panel's own width and thickness, and names the rest in `problems` as omitted, never fills them in with a guess. WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT: `panelsketch.elevation()`'s own docstring says it draws the front elevation BESIDE a side elevation as one combined picture, not two separate ones. Eriksson's page prints them as two separately captioned drawings, and this module is asked to lay out a `columns` row of exactly that shape -- so it does, naming `source: 'elevation'` under both the "Side Elevation" and "Front Elevation" captions, and says in `problems` that until `panelsketch` is split in two, both captions currently draw the same combined image. That is a real, current limitation, not something to paper over by inventing a `source` name that does not exist. Miters get the same "print a sentence, never leave a blank" discipline Eriksson uses on its own page (an unchecked "Left Miter" box there is not just left bare -- it prints "Left miter not included in design" underneath). `_miter_sentence` reproduces exactly that: a miter left BLANK (unanswered) is silent, because blank means "not yet decided", never "no" (same rule `panelanalysisform._parse_bool` documents for its own checkboxes); a miter answered explicitly "no" prints the sentence, in words, the way Eriksson does. """ # `panelanalysisform` carries no Revit import at module scope (see its own # docstring), so this succeeds on plain CPython under `tests/conftest.py`'s # trick and under IronPython 2.7 alike. If the sibling agent restructuring it # has, at some moment, left it unimportable, this module still has to load -- # so the import itself is guarded, and every reader of `paf` below is guarded # again on top of that (see `_paf_field`). try: from vibe import panelanalysisform as paf except ImportError: paf = None # --------------------------------------------------------------- utilities def _num(value): """`value` (a form string, or already a number) as a float, or None. None covers "blank", "not a number", and "not present" alike -- the caller only ever needs to know whether there is a usable number, and `validate()`-style error text is not this module's job (the button's completeness gate already owns that; this module only lays out what it is given). """ if value is None: return None if isinstance(value, (int, float)): return float(value) text = str(value).strip() if not text: return None try: return float(text) except (TypeError, ValueError): return None def _fmt_num(value): """A float as the plain string this page prints -- '8', not '8.0'.""" if value is None: return '' if float(value) == int(value): return str(int(value)) return ('%.4f' % value).rstrip('0').rstrip('.') # Eighths-of-an-inch -> the fraction word Eriksson prints. A precast # dimension finer than 1/8" is not meaningful, so `_feet_str` rounds to it # rather than pretending to more precision than the field carries. _EIGHTHS = {0: '', 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8'} def _feet_str(value): """Decimal feet -> an architectural dimension string, e.g. 8'-4 1/2". Internal units are decimal feet throughout this codebase (`CLAUDE.md` §3), so a bare number arriving in a position/length field is read as feet and converted here to the feet-inches form Eriksson prints (`8"`, `2'-11.5"`). A value that is already a string is left untouched by the caller (`_dim_or_dash`) rather than reparsed here -- a sibling agent may hand this module an already-formatted dimension, and reformatting a string that already reads correctly is how a "2'-11.5\"" becomes a wrong "2'-11 1/2\"" glued onto a stray decimal. Returns None only when `value` cannot be read as a number at all. """ try: feet = float(value) except (TypeError, ValueError): return None negative = feet < 0.0 feet = abs(feet) whole_ft = int(feet) inches = (feet - whole_ft) * 12.0 eighths_total = int(round(inches * 8.0)) whole_in, eighths = divmod(eighths_total, 8) if whole_in >= 12: whole_ft += whole_in // 12 whole_in = whole_in % 12 frac = _EIGHTHS[eighths] if frac and whole_in: in_part = '{0} {1}'.format(whole_in, frac) elif frac: in_part = frac else: in_part = str(whole_in) if whole_ft > 0: text = u'{0}\'-{1}"'.format(whole_ft, in_part) else: text = u'{0}"'.format(in_part) return u'-' + text if negative else text def _dim_or_dash(value): """A table cell for one dimension: Eriksson's own '-' for blank.""" if value is None: return '-' if isinstance(value, str): value = value.strip() return value if value else '-' text = _feet_str(value) return text if text is not None else '-' def _from_word(word): """'start' -> 'Start'; blank/None -> ''. Never invents a datum word.""" if not word: return '' word = str(word).strip() return word[:1].upper() + word[1:].lower() if word else '' def _pos_cell(pos, frm): """A 'Position' / 'Horizontal Position' cell: '8" from Start', or '-'.""" dim = _dim_or_dash(pos) if dim == '-': return '-' frm_text = _from_word(frm) return '{0} from {1}'.format(dim, frm_text) if frm_text else dim _TYPE_LABELS = {'opening': 'Opening', 'reveal': 'Reveal', 'solid zone': 'Solid Zone', 'solid_zone': 'Solid Zone'} def _type_label(value): """The Type column's text -- a known word capitalised Eriksson's way, or, for anything this table does not recognise, the entered text back in title case rather than a guessed category -- echoing what was typed is honest; picking the "closest" known type is not. """ if not value: return '-' key = str(value).strip().lower().replace('_', ' ') if key in _TYPE_LABELS: return _TYPE_LABELS[key] return str(value).strip().title() if str(value).strip() else '-' def _tri_bool(text): """(True/False/None) for a checkbox's raw text. None = "not yet said". Mirrors `panelanalysisform._parse_bool`'s own rule (blank is "unspecified", never "no") without importing that private helper across a module boundary neither module owns the other's internals of. """ if text is None: return None t = str(text).strip().lower() if not t: return None if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included', 'checked'): return True if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded', 'unchecked'): return False return None # --------------------------------------------------------- panelanalysisform def _paf_field(key): """The raw field dict `panelanalysisform.FIELDS` has for `key`, or None. None covers every way `paf` can fail to have the answer: the module failed to import, it has no `FIELDS` (mid-restructure), or `key` is simply not one it declares yet. All three are "not present", never an error -- see the module docstring. """ if paf is None: return None try: fields = paf.FIELDS except AttributeError: return None try: for f in fields: if f.get('key') == key: return f except (TypeError, AttributeError): return None return None def _field(key, editor, fallback_label, fallback_symbol, fallback_hint, fallback_required, problems, choices=None): """One `field` block, sourced from `panelanalysisform` when it has the key and from the fallback text when it does not. A missing key still gets a field on the page -- the Eriksson layout this section is copying has a slot there -- but `problems` names it, because a value typed into a field `panelanalysisform` does not declare cannot be saved into the deck yet. That is the honest state of a form being restructured by another agent at the same time, not a bug in this file. """ spec = _paf_field(key) if spec is not None and paf is not None: try: label = paf.label_of(key) symbol = paf.symbol_of(key) or None hint = paf.help_of(key) or fallback_hint required = bool(spec.get('required')) except AttributeError: spec = None if spec is None: label = fallback_label symbol = fallback_symbol or None hint = fallback_hint required = fallback_required problems.append( "{0!r} is not a field vibe.panelanalysisform declares yet; " "shown here to match Eriksson's own Concrete Extents page, but " "a value typed into it will not be saved until that module " "adds this key.".format(key)) return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol, 'hint': hint, 'required': required, 'editor': editor, 'choices': choices} def _miter_sentence(values, key, side): """Eriksson's own discipline: an OFF miter prints a sentence, not a gap. Blank (unanswered) prints nothing here, same as Eriksson's page shows nothing extra for a miter nobody has touched yet -- only an explicit "no" earns the sentence, because blank means "not yet decided", not "excluded" (see `_tri_bool`). """ if _tri_bool(values.get(key)) is False: return {'kind': 'sentence', 'text': '{0} miter not included in this panel\'s design.' .format(side)} return None # ------------------------------------------------------------- sub-sections def _panel_geometry_columns(values, problems): """The three columns Eriksson's own Panel Geometry block prints: (Top Elevation, Bottom Elevation, Height, Section Type, Top in Form) | (Width, Left Miter, Right Miter, Top In Form, Width, Height) | (Thickness). `length_ft` (this codebase's name for the panel's long dimension) is what Eriksson calls "Height" on this page -- the same field, read through `panelanalysisform`'s own label rather than renamed here, so a reviewer sees the one name every section of the form uses for it. """ col1 = [ _field('top_elevation_ft', 'num', 'Top elevation', 'ft', 'How high the top edge of the panel sits above the ' 'project reference elevation. Leave blank if not yet ' 'known.', False, problems), _field('bottom_elevation_ft', 'num', 'Bottom elevation', 'ft', 'How high the bottom edge of the panel sits above the ' 'project reference elevation. Leave blank if not yet ' 'known.', False, problems), _field('length_ft', 'num', 'Panel length', 'ft', "The panel's long dimension, end to end. Required -- " 'everything downstream is measured against this span.', True, problems), _field('section_type', 'text', 'Section type', '', 'A short description of the cross-section (solid, ' 'sandwich, ribbed, ...). Reference only.', False, problems), _field('top_in_form', 'text', 'Which face is up in the casting ' 'bed', 'Interior / Exterior', 'Which face of the panel faces up while it is cast. ' 'Reference only.', False, problems, choices=['Interior', 'Exterior']), ] col2 = [ _field('width_ft', 'num', 'Panel width', 'ft', "The panel's dimension across the span -- how wide it is. " 'Required.', True, problems), _field('left_miter', 'check', 'Left miter', '', 'Whether the left end of the panel is cut at an angle ' '(mitered) instead of square. Leave unchecked if this has ' 'not been decided yet.', False, problems), ] sentence = _miter_sentence(values, 'left_miter', 'Left') if sentence: col2.append(sentence) col2.append( _field('right_miter', 'check', 'Right miter', '', 'Whether the right end of the panel is cut at an angle ' '(mitered) instead of square. Leave unchecked if this has ' 'not been decided yet.', False, problems)) sentence = _miter_sentence(values, 'right_miter', 'Right') if sentence: col2.append(sentence) col2.append( _field('top_in_form_check', 'check', 'Top edge has a formed step', '', 'Whether the top edge of the panel has its own formed ' 'step or insert built in, separate from which face faces ' 'up (see "Which face is up" at left). Leave unchecked if ' 'there is none.', False, problems)) col2.append( _field('top_in_form_width_in', 'num', 'Formed step width', 'in', 'How wide the formed step at the top edge is, if there is ' 'one. Leave blank if there is no step.', False, problems)) col2.append( _field('top_in_form_height_in', 'num', 'Formed step height', 'in', 'How tall the formed step at the top edge is, if there is ' 'one. Leave blank if there is no step.', False, problems)) col3 = [ _field('thickness_in', 'num', 'Panel thickness', 'in', 'How thick the panel is. Required.', True, problems), ] return [{'kind': 'heading', 'text': 'Panel Geometry', 'level': 2}, {'kind': 'columns', 'columns': [col1, col2, col3]}] # The grouped header the Openings / Reveals / Solid Zones table prints, # spanning the last 6 of its 7 columns: 'Type' stands alone at the left the # way Eriksson's own page leaves it, and (2, 4) is what leaves the group # spans adding up to exactly `len(headers) - 1`. _OPENINGS_HEADERS = ['Type', 'Position', 'Length', 'Horizontal Position', 'Width', 'Vertical Position', 'Height'] _OPENINGS_GROUPS = [('Position along Length', 2), ('Position in Cross ' 'Section', 4)] def _openings_row_cells(row): row = row or {} return [ _type_label(row.get('type')), _pos_cell(row.get('pos_along'), row.get('pos_along_from')), _dim_or_dash(row.get('length')), _pos_cell(row.get('pos_cross'), row.get('pos_cross_from')), _dim_or_dash(row.get('width')), _dim_or_dash(row.get('pos_vert')), _dim_or_dash(row.get('height')), ] def _openings_blocks(values, problems): """The Openings, Reveals, and Solid Zones heading + table (or fallback). `values['openings_rows']` is the structured shape a sibling agent is meant to be adding (see the module docstring); when it is missing, or is not a list, this degrades to the raw `values['openings']` text field and a `problems` entry -- never a guessed re-parse of that free text into the grouped shape, which would be inventing datum words ("from Start" / "from Left") nobody has actually said. """ blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid ' 'Zones', 'level': 2}] rows = values.get('openings_rows') if values else None if isinstance(rows, list): table_rows = [_openings_row_cells(r) for r in rows] blocks.append({'kind': 'table', 'headers': _OPENINGS_HEADERS, 'header_groups': _OPENINGS_GROUPS, 'rows': table_rows, 'row_key': None}) if not rows: blocks.append({'kind': 'sentence', 'text': 'No openings, reveals, or solid zones ' 'entered.'}) return blocks problems.append( "'openings_rows' is not present in the form values (or is not a " 'list), so the grouped Position-along-Length / Position-in-' 'Cross-Section table cannot be built; falling back to the raw ' "'openings' text field.") raw = (values.get('openings') if values else None) or '' raw = raw.strip() if isinstance(raw, str) else str(raw).strip() if raw: blocks.append({'kind': 'sentence', 'text': 'Openings were entered as plain text and ' 'cannot be shown as the grouped table yet: ' '{0}'.format(raw)}) else: blocks.append({'kind': 'sentence', 'text': 'No openings, reveals, or solid zones ' 'entered.'}) return blocks # Every Section Properties row Eriksson's own page prints. Only the first # three (`_DERIVABLE_PROPS`) are ever computed here -- see the module # docstring for why the rest are refused rather than guessed. _DERIVABLE_PROPS = ('Area, in²', 'Width, in', 'Height, in') _OMITTED_PROPS = ('Cx, in', 'Cy, in', 'Izz, in⁴', 'Iyy, in⁴', 'Izy, in⁴', 'Sb, in³', 'St, in³') def _section_properties_column(values, problems): """The Section Properties kv list -- Area / Width / Height ONLY. Both come straight from what the user entered (panel width, panel thickness) with no engineering judgement in between, so they are the only rows honest to print. `Height` here is Eriksson's name for the section's own depth through the panel, i.e. the panel THICKNESS -- not the panel's long dimension, which the Panel Geometry block above already calls "Height" in its own different sense. Printing both under the same word in two different places on the same page is exactly Eriksson's own layout, confusing as that is; relabelling one of them would stop this matching the page the owner asked for. """ col = [{'kind': 'heading', 'text': 'Section Properties', 'level': 3}] width_ft = _num(values.get('width_ft')) thickness_in = _num(values.get('thickness_in')) if width_ft is not None and width_ft > 0 and \ thickness_in is not None and thickness_in > 0: width_in = width_ft * 12.0 area_in2 = width_in * thickness_in rows = [('Area, in²', _fmt_num(area_in2)), ('Width, in', _fmt_num(width_in)), ('Height, in', _fmt_num(thickness_in))] col.append({'kind': 'kv', 'rows': rows}) problems.append( '{0} are not shown: deriving them honestly needs an axis ' 'convention and, wherever openings are present, a ' 'net-of-openings deduction this module does not compute -- a ' 'guessed section modulus is worse than none.' .format(', '.join(_OMITTED_PROPS))) else: col.append({'kind': 'sentence', 'text': 'Section properties are waiting on Panel ' 'width and Panel thickness; nothing is shown ' 'until both are entered.'}) problems.append( '{0} could not be derived -- Panel width and/or Panel ' 'thickness is missing.'.format( ', '.join(_DERIVABLE_PROPS + _OMITTED_PROPS))) return col def _drawings_row(problems): """The Side Elevation / Front Elevation / Section Properties row. Both drawings name `source: 'elevation'` -- see the module docstring's "WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT" for why that is today's correct, if unsatisfying, answer rather than a name this module invents to look like two different pictures. """ problems.append( "Side Elevation and Front Elevation both use panelsketch." 'elevation(), which currently draws the side and front views ' 'together as ONE picture (per that function\'s own docstring) ' 'rather than as two separate drawings; until panelsketch is split ' 'in two, both captions below show the same combined image.') side_col = [{'kind': 'drawing', 'source': 'elevation', 'caption': 'Side Elevation', 'w': 260, 'h': 520}] front_col = [{'kind': 'drawing', 'source': 'elevation', 'caption': 'Front Elevation', 'w': 420, 'h': 520}] return side_col, front_col # ------------------------------------------------------------------ page() def page(values, reads=None): """(blocks, problems) for the Concrete Extents section. `values` is the form's current string-valued dict (plus, where a sibling agent has landed it, the structured `openings_rows` list -- see the module docstring). `reads` is what the button read straight off the live framing element, the same shape `panelanalysisform.prefill` takes; it is accepted for symmetry with the other section builders but not read here -- everything this section needs is already folded into `values` by the time `page` sees it, and reading `reads` again here would risk showing a MODEL value beside a form value the user has since typed over it, exactly the kind of stale-looking page the owner has complained about. Never raises. Every field this module cannot place is a `problems` sentence, not a traceback -- see the module docstring for the two running examples (a `panelanalysisform` key that is not there yet, and an `openings_rows` shape that has not landed yet). """ values = values if values is not None else {} problems = [] blocks = [{'kind': 'heading', 'text': 'Concrete Extents', 'level': 1}] blocks.extend(_panel_geometry_columns(values, problems)) blocks.append({'kind': 'space', 'h': 8}) blocks.extend(_openings_blocks(values, problems)) blocks.append({'kind': 'space', 'h': 8}) side_col, front_col = _drawings_row(problems) props_col = _section_properties_column(values, problems) blocks.append({'kind': 'columns', 'columns': [side_col, front_col, props_col]}) blocks.append({'kind': 'space', 'h': 8}) blocks.append({'kind': 'drawing', 'source': 'section', 'caption': 'Cross Section', 'w': 900, 'h': 260}) return blocks, problems