25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
819 lines
32 KiB
Python
819 lines
32 KiB
Python
# -*- 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:
|
|
|
|
<model folder>/_ticketqc/
|
|
evidence/<DESIGN>.json everything we could scrape off the summary
|
|
specs/<DESIGN>.json the structured spec <-- the thing to review
|
|
knowledge.yaml hand-written rules that outlive one project
|
|
log/<DESIGN>.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
|
|
|
|
# The strand-matrix geometry reader. A sibling module, developed in parallel
|
|
# -- guarded, and PERMANENTLY guarded even once it always ships, because a
|
|
# machine missing the file must get an honest refusal from build_spec rather
|
|
# than an ImportError that takes the whole extraction down with it.
|
|
try:
|
|
from vibe import strandmatrix
|
|
except ImportError:
|
|
strandmatrix = None
|
|
|
|
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.<key>` 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 = {}
|
|
symbols = []
|
|
symbols_skipped = 0
|
|
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
|
|
|
|
# Positions, for the strand-matrix reader: which symbol is drawn
|
|
# where. A FilledRegion (the usual strand marker -- see
|
|
# knowledge.DEFAULTS['design_summary']) has no .Symbol, so `key`
|
|
# above is already its class name; the same key is reused here so
|
|
# the two counts describe the same population.
|
|
try:
|
|
bbox = e.get_BoundingBox(view)
|
|
if bbox is None:
|
|
symbols_skipped += 1
|
|
continue
|
|
cx = (bbox.Min.X + bbox.Max.X) / 2.0
|
|
cy = (bbox.Min.Y + bbox.Max.Y) / 2.0
|
|
w = bbox.Max.X - bbox.Min.X
|
|
h = bbox.Max.Y - bbox.Min.Y
|
|
symbols.append({'kind': key, 'x': round(cx, 4), 'y': round(cy, 4),
|
|
'w': round(w, 4), 'h': round(h, 4)})
|
|
except Exception:
|
|
symbols_skipped += 1
|
|
|
|
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
|
|
'text_notes': notes, 'dimensions': dims, 'components': components,
|
|
'symbols': symbols, 'symbols_skipped': symbols_skipped}
|
|
|
|
|
|
# ------------------------------------------------------------ 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. The strand matrix
|
|
is read from GEOMETRY, not guessed at from text -- the drawn strand
|
|
symbols and the dimension chain that locates them -- and only when every
|
|
identity check on that reading passes: the symbol count equals the
|
|
stated strand quantity, each row's spacing matches a dimension chain, and
|
|
row depths match a vertical chain. Any one of those failing leaves
|
|
columns_in and rows empty with the reason on 'unresolved' rather than a
|
|
grid nobody can trust; a wrong grid is worse than an honest blank one,
|
|
and the AI pass and a human both fill what stays blank. datum comes from
|
|
knowledge (rails.strand_datum) because that much genuinely is known
|
|
already, independent of whether the geometry read succeeds.
|
|
|
|
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
|
|
|
|
# ---- the strand matrix, read from geometry -------------------------
|
|
#
|
|
# `strandmatrix.read_layout` is the identity-checked reader: it counts
|
|
# the drawn strand symbols against the stated quantity, matches each
|
|
# row's spacing to a dimension chain and each row's depth to a vertical
|
|
# chain, and refuses -- one sentence saying which identity failed --
|
|
# rather than fill in a grid that only half matches. `symbol_kind` is
|
|
# plant knowledge (design_summary.strand_symbol) because the drafting
|
|
# standard's choice of marker is not universal: a filled region on this
|
|
# default, a detail-component family name on another plant. Computed
|
|
# here, applied below once `spec['unresolved']` exists to carry a
|
|
# refusal reason -- never raised out of this function, because a bad
|
|
# geometry read must lose only the matrix, not the whole spec.
|
|
matrix_layout, matrix_why = None, 'strandmatrix module not installed'
|
|
if strandmatrix is not None:
|
|
try:
|
|
qty = sum([g.get('qty') for g in spec['reinforcing']['strand_groups']
|
|
if g.get('qty') is not None])
|
|
view_ev = (evidence.get('summary_views') or [None])[0]
|
|
symbol_kind = K.get(know, 'design_summary.strand_symbol', 'FilledRegion')
|
|
width_ft = spec['envelope']['width']['max_ft']
|
|
matrix_layout, matrix_why = strandmatrix.read_layout(
|
|
view_ev, qty, thickness_in=spec.get('thickness_in'),
|
|
width_ft=width_ft, symbol_kind=symbol_kind)
|
|
except Exception as ex:
|
|
matrix_layout = None
|
|
matrix_why = 'strand matrix read failed: {0}: {1}'.format(
|
|
type(ex).__name__, ex)
|
|
|
|
if matrix_layout:
|
|
layout = spec['reinforcing']['strand_layout']
|
|
layout['columns_in'] = matrix_layout.get('columns_in')
|
|
layout['rows'] = matrix_layout.get('rows')
|
|
layout['total_in'] = matrix_layout.get('total_in')
|
|
layout['note'] = matrix_layout.get('note')
|
|
|
|
# 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
|
|
|
|
# Appended AFTER extractrules.apply_to so the rule packs never see this
|
|
# line -- it is not a note off the drawing, it is this function's own
|
|
# refusal, and folding it into `entries` above would let a house-style
|
|
# rule pack move or reinterpret it like ordinary drawing text.
|
|
if not matrix_layout:
|
|
spec['unresolved'].append({'text': 'STRAND PATTERN (matrix)',
|
|
'reason': matrix_why})
|
|
return spec
|