# -*- coding: utf-8 -*- """Design summaries as reviewable data. A design summary is a drafting view full of free text that a human wrote. The checking scripts want numbers. Turning one into the other is the only part of the job that genuinely needs judgement, so it happens once, up front, and the result is written to disk where you can read and correct it: /_ticketqc/ evidence/.json everything we could scrape off the summary specs/.json the structured spec <-- the thing to review knowledge.yaml hand-written rules that outlive one project log/.txt what the extractor printed Evidence is gathered deterministically here. So is the spec now: the shape of a spec, and how it reads and writes as text, live in `specformat.py` -- which touches no Revit API at all, on purpose, so that shape can be unit tested without a licence or a model (see its docstring). This module is the Revit side of that boundary: it reads the model, and it reads and writes specformat's shapes to disk beside it. Everything downstream reads only the spec, so a checking run is pure, repeatable arithmetic. The spec file on disk is YAML, not JSON: JSON cannot carry a comment, and the whole point of this file is that an engineer reads it and corrects it by hand. An older spec written as plain JSON is still read -- see load_spec. IronPython 2.7: no f-strings, no pathlib. """ # pylint: disable=import-error,invalid-name,broad-except import os import io import re import json import datetime from Autodesk.Revit.DB import ( FilteredElementCollector, View, ViewSheet, ViewType, BuiltInCategory, BrowserOrganization, Element, StorageType, ) from vibe import specformat from vibe import specsave from vibe.specformat import ( # noqa: F401 -- re-exported, see below SCHEMA_VERSION, SPEC_TEMPLATE, REQUIRED_PATHS, get_path, validate_spec, normalize_spec, new_spec, strand_positions, strand_row_marks, strand_diagram, spec_text, parse_spec_text, design_key, ) from vibe.compat import as_text # Re-exported, not re-implemented. Choosing the plant knowledge file is pure # path arithmetic and lives in a module with no Revit import, so it can be # tested on host CPython -- but every caller has always asked designspec for # it, and a second spelling of the same question is how two answers start. from vibe.plantpath import ( # noqa: F401 -- re-exported PLANT_FOLDER, PLANT_ENV, valid_plant_id, plant_root, plant_knowledge_path, ) # Control characters and format characters that ride along in Revit text and # crash downstream printers / parsers. Measured: a model parameter whose # ValueString was exactly U+200E (a lone left-to-right mark) made it all the # way into evidence/WP1.json and broke a tool that printed it. Everything # that scrapes model text goes through clean_text at the boundary; the # function itself lives in ticketpage (the Revit-free module, unit-tested). from vibe.ticketpage import clean_text FOLDER = '_ticketqc' JOBS_ENV = 'WILLTOOLS_JOBS' # ------------------------------------------------------------------- layout def root_dir(doc): """Where this project's QC data lives. WILLTOOLS_JOBS wins if it is set: the job data goes in a per-model folder underneath it. Everything a project knows then sits in one place the engineer chose, rather than in two that the tool chose for him. Without it, beside the model, falling back to APPDATA when the model has no path yet or its folder cannot be written. That fallback is the reason the override exists. Saving a detached copy gives it a path, so the lookup silently moves from APPDATA to the model folder, and a job knowledge file written before the save stops being read -- measured 2026-08-30, and from the outside it looks exactly like edits that did not take. """ override = os.environ.get(JOBS_ENV) if override: return _ensure(os.path.join(override, doc.Title or 'untitled')) model = doc.PathName if model: base = os.path.join(os.path.dirname(model), FOLDER) try: _ensure(base) return base except Exception: pass fallback = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')), 'pyrevit-ticketqc', doc.Title or 'untitled') _ensure(fallback) return fallback def _ensure(path): if not os.path.isdir(path): os.makedirs(path) return path def sub_dir(doc, name): return _ensure(os.path.join(root_dir(doc), name)) def evidence_path(doc, design): return os.path.join(sub_dir(doc, 'evidence'), _safe(design) + '.json') def spec_path(doc, design, ext='yaml'): """Where one design's spec lives. New specs are written as '.yaml'. A spec written before this file carried comments is still on disk as '.json' -- pass ext='json' to name it, or use find_spec_path() to find whichever one is actually there. """ return os.path.join(sub_dir(doc, 'specs'), _safe(design) + '.' + ext) def find_spec_path(doc, design): """(path, ext) of the spec actually on disk for this design. '.yaml' wins when both exist -- it is the one a human could have corrected, and load_spec must not silently prefer the one they didn't touch. Neither existing, this is where a new spec would be written. """ yaml_path = spec_path(doc, design, 'yaml') if os.path.isfile(yaml_path): return yaml_path, 'yaml' json_path = spec_path(doc, design, 'json') if os.path.isfile(json_path): return json_path, 'json' return yaml_path, 'yaml' def log_path(doc, design): return os.path.join(sub_dir(doc, 'log'), _safe(design) + '.txt') def knowledge_path(doc): return os.path.join(root_dir(doc), 'knowledge.yaml') # ------------------------------------------------------- the project config # # A project's configuration file is written wherever the engineer wants it -- # the job folder on a share, usually, where the rest of the job's paperwork # lives and where a reviewer will look for it. That is the whole point of # letting them choose, and it means the tool cannot go and find it by # convention afterwards. # # So a one-line pointer stays behind, beside the model with the rest of the QC # data. It holds the path and nothing else. Losing it costs one button press; # putting the config itself here would defeat the choice. CONFIG_POINTER = 'active-config.txt' def config_pointer_path(doc): return os.path.join(root_dir(doc), CONFIG_POINTER) def active_config_path(doc): """The project config file in force for this model, or None. Returns the path even when the file has gone missing. A pointer to nothing is a fact the caller has to be told about -- resolving it to None would silently fall back to the layered files, and the run would use a different configuration than the one the engineer set, without saying so. """ pointer = config_pointer_path(doc) if not os.path.isfile(pointer): return None try: with io.open(pointer, 'r', encoding='utf-8') as fh: path = fh.read().strip() except Exception: return None return path or None def set_active_config(doc, path): """Make this file the configuration this model runs on.""" with io.open(config_pointer_path(doc), 'w', encoding='utf-8') as fh: fh.write(as_text(path)) return path def clear_active_config(doc): """Go back to the layered files. Returns what was in force, or None.""" pointer = config_pointer_path(doc) previous = active_config_path(doc) if os.path.isfile(pointer): os.remove(pointer) return previous def _safe(name): """A design number as a filename. One rule, and it lives in `specsave` -- the Extract Design button names the COPY it saves elsewhere with the same function, so a copy can never end up called something the canonical file is not. """ return specsave.safe_name(name) # ---------------------------------------------------------- design numbers # # `design_key` MOVED TO `specformat` and is re-exported at the top of this # file, alongside the rest of the spec shape. It is the same function under # the same name; nothing that calls `designspec.design_key` has to change. # # Why it moved: this module imports `Autodesk.Revit.DB` at module scope, so # asking "are these two design numbers the same design?" needed a licence. # `reinforcebind` has to ask exactly that -- to keep a donor of another design # out of a suggestion -- and it must answer both inside Revit and in # `tools/reinforce_offline.py`, which runs with no Revit at all. Reimplementing # the reduction there would have been a second spelling of one question, which # is how two answers start; so the function went to the Revit-free module and # the callers stayed put. `_FALLBACK_DESIGN_RE` went with it as # `specformat.FALLBACK_DESIGN_RE`. def _nomen(know, key, default): """One nomenclature setting, tolerating a knowledge dict that is not there. Imported late: knowledge imports this module, so a module-level import would be circular. `tools/knowledge_drift.py` knows this wrapper by name and resolves `nomenclature.` at each call site, so do not rename it without teaching the scanner. """ if know is None: return default from vibe import knowledge as K return K.get(know, 'nomenclature.' + key, default) def list_specs(doc): """Design numbers that already have a spec on disk. A design may have both a '.yaml' and a leftover '.json' -- the '.yaml' wins, same rule as find_spec_path, so a spec a human has never touched (still '.json') does not shadow one they have. """ folder = sub_dir(doc, 'specs') by_design = {} for fn in sorted(os.listdir(folder)): low = fn.lower() if low.endswith('.yaml'): rank = 0 elif low.endswith('.json'): rank = 1 else: continue spec = normalize_spec(read_spec_file(os.path.join(folder, fn))) if not spec or not spec.get('design_number'): continue design = spec['design_number'] prior = by_design.get(design) if prior is None or rank < prior[0]: by_design[design] = (rank, spec) return dict((d, rank_spec[1]) for d, rank_spec in by_design.items()) # --------------------------------------------------------------------- io def read_json(path): if not os.path.isfile(path): return None try: with io.open(path, 'r', encoding='utf-8') as fh: return json.loads(fh.read()) except Exception: return None def write_json(path, data): _ensure(os.path.dirname(path)) text = as_text(json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False)) with io.open(path, 'w', encoding='utf-8') as fh: fh.write(text) return path def read_yaml(path): """A spec file written by save_spec, or None. Comments are just dropped.""" if not os.path.isfile(path): return None try: with io.open(path, 'r', encoding='utf-8') as fh: text = fh.read() except Exception: return None try: return specformat.parse_spec_text(text) except Exception: return None def read_spec_file(path): """A spec, whichever of the two formats it was written in.""" if path.lower().endswith('.json'): return read_json(path) return read_yaml(path) def load_spec(doc, design): path, _ext = find_spec_path(doc, design) return normalize_spec(read_spec_file(path)) def save_spec(doc, design, spec): """Write a spec as hand-editable YAML. Always '.yaml', regardless of what was there before -- a spec that gets re-extracted goes back to being machine-written, and a stale '.json' left beside it would otherwise look like an untouched original. Nothing here deletes the old '.json'; find_spec_path prefers the '.yaml' from here on, same as list_specs. """ path = spec_path(doc, design, 'yaml') _ensure(os.path.dirname(path)) text = specformat.spec_text(spec) with io.open(path, 'w', encoding='utf-8') as fh: fh.write(as_text(text)) return path # ------------------------------------------------------------ element utils def _name(el): try: return Element.Name.GetValue(el) except Exception: return '' def _eid(el): try: return el.Id.IntegerValue except Exception: return el.Id.Value def _params(el, wanted=None): out = {} for p in el.Parameters: pname = p.Definition.Name if wanted is not None and pname not in wanted: continue try: if p.StorageType == StorageType.String: val = p.AsString() elif p.StorageType == StorageType.Integer: val = p.AsInteger() elif p.StorageType == StorageType.Double: val = p.AsValueString() else: val = p.AsValueString() except Exception: val = None if val not in (None, ''): out[pname] = val return out def _dimension(d): """One dimension, with enough geometry to say what it measures. A bare string is not enough for a strand chain. Where the strands sit is dimensioned as a multi-segment chain -- rail to first strand, then strand to strand, then last strand to the far edge -- so the segments in order, their exact lengths, and which way the chain runs are all part of the reading. N segments locate N-1 strands and the total is the piece width, which is what makes the reading checkable. Two traps, both met on real summaries: * Dimension.Curve throws on these drafting views, so the direction is inferred from how the segment origins are spread instead. * segment.Value is the drawn geometry and segment.ValueString is what is printed. On an overridden segment they disagree, and only the printed text is the instruction -- so both are recorded and neither is silently preferred. """ entry = {} try: entry['value'] = clean_text(d.ValueString) except Exception: entry['value'] = None for key, attr in (('above', 'Above'), ('below', 'Below'), ('prefix', 'Prefix'), ('suffix', 'Suffix')): try: val = getattr(d, attr, None) except Exception: val = None if val: entry[key] = val try: raw = d.Value if raw is not None: entry['value_ft'] = round(float(raw), 6) except Exception: pass try: entry['references'] = d.References.Size except Exception: pass try: origin = d.Origin entry['x'] = round(origin.X, 4) entry['y'] = round(origin.Y, 4) except Exception: pass segments = [] try: if d.NumberOfSegments > 1: for seg in d.Segments: item = {} try: item['value'] = clean_text(seg.ValueString) except Exception: item['value'] = None try: if seg.Value is not None: item['value_ft'] = round(float(seg.Value), 6) except Exception: pass try: point = seg.Origin item['x'] = round(point.X, 4) item['y'] = round(point.Y, 4) except Exception: pass segments.append(item) except Exception: pass if segments: entry['segments'] = segments entry['segment_count'] = len(segments) xs = [s['x'] for s in segments if 'x' in s] ys = [s['y'] for s in segments if 'y' in s] if xs and ys: entry['axis'] = ('horizontal' if (max(xs) - min(xs)) >= (max(ys) - min(ys)) else 'vertical') lengths = [s['value_ft'] for s in segments if 'value_ft' in s] if len(lengths) == len(segments): entry['total_ft'] = round(sum(lengths), 6) if entry.get('value') or segments: return entry return None def _view_content(doc, view): """Text, dimensions and drawn components of one view, positions included. Positions matter: the min/max table in a summary is a label note and a value note sitting side by side, and only the geometry links them. """ notes = [] for t in (FilteredElementCollector(doc, view.Id) .OfCategory(BuiltInCategory.OST_TextNotes).ToElements()): try: c = t.Coord notes.append({'text': t.Text.replace('\r', '').strip(), 'x': round(c.X, 4), 'y': round(c.Y, 4)}) except Exception: notes.append({'text': t.Text.replace('\r', '').strip()}) notes.sort(key=lambda n: (-n.get('y', 0), n.get('x', 0))) dims = [_dimension(d) for d in (FilteredElementCollector(doc, view.Id) .OfCategory(BuiltInCategory.OST_Dimensions) .WhereElementIsNotElementType().ToElements())] dims = [d for d in dims if d] components = {} for e in (FilteredElementCollector(doc, view.Id) .OfCategory(BuiltInCategory.OST_DetailComponents).ToElements()): try: key = e.Symbol.Family.Name except Exception: key = type(e).__name__ components[key] = components.get(key, 0) + 1 return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType), 'text_notes': notes, 'dimensions': dims, 'components': components} # ------------------------------------------------------------ the gathering def _sheet_folder(org, sheet): """The sheet's path in the project browser, e.g. 'DESIGN PRODUCT / WALLS'.""" if org is None: return '' try: return ' / '.join([item.Name for item in org.GetFolderItems(sheet.Id)]) except Exception: return '' def find_summary_views(doc, design, know=None): """The views and sheets that make up one design's packet. Driven by SHEETS, not by view names. A view belongs to the design of the sheet it is placed on, whatever it is called -- a view still titled PNL2.0 sitting on sheet PNL1.0 belongs to PNL1.0, and matching on the view name would file it under the wrong design without anything looking wrong. Sheets are reduced through design_key, so the whole packet -- cover sheet PNL1.0 plus PNL1.A, PNL1.B1 and the rest -- comes back together, in sheet order, which puts the cover sheet (the design summary itself) first. """ key = design_key(design, know) if not key: return [], [] folder_wanted = (_nomen(know, 'design_folder', '') or '').strip().upper() ignore = [str(p).upper() for p in (_nomen(know, 'ignore_sheet_prefixes', []) or [])] stale = _nomen(know, 'stale_view_re', None) try: org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc) except Exception: org = None sheets = [] for s in (FilteredElementCollector(doc).OfClass(ViewSheet) .WhereElementIsNotElementType().ToElements()): if s.IsTemplate: continue number = (s.SheetNumber or '').strip() if any(number.upper().startswith(p) for p in ignore): continue if design_key(number, know) != key: continue if folder_wanted and folder_wanted not in _sheet_folder(org, s).upper(): continue sheets.append(s) sheets.sort(key=lambda s: (s.SheetNumber or '')) views, seen = [], set() for sheet in sheets: for vid in sheet.GetAllPlacedViews(): if vid in seen: continue seen.add(vid) view = doc.GetElement(vid) if view is None: continue if stale: try: if re.search(stale, view.Name): continue except Exception: pass views.append(view) return views, sheets def gather_evidence(doc, design, know=None): """Scrape everything the extractor could possibly need. No interpretation.""" views, sheets = find_summary_views(doc, design, know) evidence = { 'schema': SCHEMA_VERSION, 'design_number': design, 'model': doc.Title, 'model_path': doc.PathName, 'gathered': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), 'summary_views': [_view_content(doc, v) for v in views], 'sheets': [], } for sheet in sheets: entry = { 'number': sheet.SheetNumber, 'name': sheet.Name, 'id': _eid(sheet), 'parameters': _params(sheet), 'views': [], } for vid in sheet.GetAllPlacedViews(): view = doc.GetElement(vid) if any(v.Id == view.Id for v in views): continue entry['views'].append(_view_content(doc, view)) evidence['sheets'].append(entry) # A worked example of a real piece helps the extractor ground its units. evidence['sample_piece'] = _sample_piece(doc, design, know=know) return evidence def _sample_piece(doc, design, know=None): """One assembly on this design, so the extractor can see real magnitudes.""" from Autodesk.Revit.DB import AssemblyInstance for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance) .WhereElementIsNotElementType().ToElements()): for mid in asm.GetMemberIds(): member = doc.GetElement(mid) if not (member.Category and member.Category.Name == 'Structural Framing'): continue from vibe.ticketqc import rstr # Compared as reduced keys: the piece may be stamped PNL1 while # the summary sheet is PNL1.0, and they are the same design. if design_key(rstr(member, know, 'panel.design_number'), know) != design_key(design, know): break bom = {} for m2 in [doc.GetElement(i) for i in asm.GetMemberIds()]: try: bom[m2.Symbol.Family.Name] = bom.get(m2.Symbol.Family.Name, 0) + 1 except Exception: pass return { 'mark': asm.AssemblyTypeName, 'family': member.Symbol.Family.Name, 'panel_parameters': _params(member), 'type_parameters': _params(doc.GetElement(member.GetTypeId())), 'bill_of_material': bom, } else: continue return None # ------------------------------------------------------------- spec shape # # SPEC_TEMPLATE, get_path, validate_spec, normalize_spec and new_spec all live # in specformat.py now -- see the imports at the top of this file -- because # none of them touch Revit and every one of them is worth testing without it. # They are imported here so `designspec.SPEC_TEMPLATE` etc. keep working for # code written against this module. def build_spec(doc, design, know=None, evidence=None): """A spec built purely from the regex/geometry reader. No AI, no judgement. Everything below either matched one of ticketqc's rules or it did not -- there is no third option, and nothing here guesses. What matched is filled in; every text note on the design summary that nothing matched is recorded in 'unresolved' rather than silently dropped. What the chain of dimensions means -- the strand matrix -- is not attempted here at all: it is not text, matching it well enough to trust would take real judgement, and a wrong grid is worse than an honest blank one a human fills in by looking at the drawing. datum comes from knowledge (rails.strand_datum) because that much genuinely is known already; columns_in and rows start empty. Returns None when this design has no summary view to read. """ from vibe import knowledge as K from vibe import ticketqc if know is None: know = K.load(doc) views, sheets = find_summary_views(doc, design, know) if not views: return None if evidence is None: evidence = gather_evidence(doc, design, know=know) view = views[0] # the design's own summary view -- # the rest of the packet's views # are sub-details, not this design reading = ticketqc.parse_design_summary(doc, view) spec = new_spec(design, evidence) spec['source']['extractor'] = 'regex' spec['piece_description'] = sheets[0].Name if sheets else None spec['concrete']['fc_psi'] = reading.get('fc') spec['concrete']['fci_psi'] = reading.get('fci') spec['concrete']['unit_weight_pcf'] = reading.get('pcf') spec['reinforcing']['mild_grade_ksi'] = reading.get('mild_ksi') spec['reinforcing']['wwf']['mesh'] = reading.get('wwf_mesh') spec['reinforcing']['wwf']['layers'] = reading.get('wwf_layers') spec['reinforcing']['wwf']['per_face'] = reading.get('wwf_per_face') spec['reinforcing']['strand_groups'] = [ {'qty': g.get('qty'), 'dia_in': g.get('dia'), 'grade_ksi': g.get('ksi'), 'it_lb': g.get('it'), 'role': None, 'note': None} for g in (reading.get('strands') or []) ] spec['reinforcing']['strand_layout']['datum'] = K.get( know, 'rails.strand_datum', 'left_rail') envelope = reading.get('envelope') or {} for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')): for bound in ('min', 'max'): entry = envelope.get('{0}_{1}'.format(bound.upper(), key)) if entry: val, raw = entry # Rounded, not truncated: parse_feet divides by 12 and 16ths, # which does not land on a clean decimal even when the # drawing dimension is exact -- 34'-11 1/2" is really # 34.958333333333336 in binary float. Round to the nearest # 1/16" (6 decimal places comfortably clears that) so the # spec shows the number a person would actually write, not # float noise nobody typed. spec['envelope'][dim][bound + '_ft'] = round(val, 6) spec['envelope'][dim][bound + '_text'] = raw # Every note the generic rules could not place. The plant's declared rule # packs then get one look at that list and may move a note into `notes` # (prose a person reads) or leave it where it is with a better sentence # beside it -- never remove one. `apply_to` returns both halves and their # lengths always sum back to what went in, which is the property that # keeps a house-style rule from becoming a silent exclusion. # # No packs declared, or a declaration this build refuses, means this is # exactly the single assignment it used to be. A refusal is carried on the # spec rather than raised: a spec that lost its extraction because a # knowledge file named an unknown pack would be a worse outcome than one # that says so on its face and leaves every note visible. from vibe import extractrules entries = ticketqc.unresolved_in_view(doc, view) packs, problems = extractrules.load(know) notes, unresolved = extractrules.apply_to(entries, packs) spec['notes'] = notes spec['unresolved'] = unresolved if problems: spec['source']['rule_pack_problems'] = problems return spec