WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
@@ -0,0 +1,12 @@
|
||||
title: Beam Analysis (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
|
||||
# owner's own instruction, alongside ConnectionAnalysis; nothing behind this
|
||||
# button is built yet, see script.py.
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Beam Analysis -- PLACEHOLDER. Nothing is implemented.
|
||||
#
|
||||
# Added 2026-09-05 alongside ConnectionAnalysis, on the owner's own
|
||||
# instruction to put a button here for it. Same posture as GravityLoadChase
|
||||
# and LateralExtract: a row that opens is still a row somebody clicks, so it
|
||||
# says plainly that nothing is built rather than staying silent about it.
|
||||
#
|
||||
# No scope has been settled -- not what a "beam" is in this model's terms,
|
||||
# not which code governs, not read-only or not. None of that is guessed
|
||||
# here; this button exists so there is somewhere to build it once it is.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Beam Analysis (WIP)'
|
||||
|
||||
from pyrevit import forms
|
||||
|
||||
forms.alert(
|
||||
'Beam Analysis is not built yet.\n\n'
|
||||
'This row exists so there is somewhere on the ribbon for it. Nothing '
|
||||
'has been read from the model and nothing has been calculated.',
|
||||
title='Beam Analysis', exitscript=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
title: Connection Analysis (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
|
||||
# owner's own instruction, alongside BeamAnalysis; nothing behind this
|
||||
# button is built yet, see script.py.
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Connection Analysis -- PLACEHOLDER. Nothing is implemented.
|
||||
#
|
||||
# Added 2026-09-05 alongside BeamAnalysis, on the owner's own instruction to
|
||||
# put a button here for it. Same posture as GravityLoadChase and
|
||||
# LateralExtract: a row that opens is still a row somebody clicks, so it
|
||||
# says plainly that nothing is built rather than staying silent about it.
|
||||
#
|
||||
# No scope has been settled -- not which connections, not which code
|
||||
# governs, not read-only or not. None of that is guessed here; this button
|
||||
# exists so there is somewhere to build it once it is.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Connection Analysis (WIP)'
|
||||
|
||||
from pyrevit import forms
|
||||
|
||||
forms.alert(
|
||||
'Connection Analysis is not built yet.\n\n'
|
||||
'This row exists so there is somewhere on the ribbon for it. Nothing '
|
||||
'has been read from the model and nothing has been calculated.',
|
||||
title='Connection Analysis', exitscript=True)
|
||||
@@ -0,0 +1,14 @@
|
||||
title: Gravity (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration. This tool is buttons-and-docs only until the owner
|
||||
# provides worked examples; the suffix is what stops it being mistaken for a
|
||||
# finished tool by anyone who opens the DesignHub dropdown. It sits on the
|
||||
# title rather than the second line now because a pulldown row has no second
|
||||
# line.
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Gravity load chasing -- PLACEHOLDER. Nothing is implemented.
|
||||
#
|
||||
# It does not write to the Revit model and never will -- like Lateral
|
||||
# Extract, this is meant to be an extractor: read the model, write a file
|
||||
# elsewhere. This one's target is Excel -- it is meant to extract
|
||||
# information and write an Excel workbook. The owner's framing, verbatim
|
||||
# (2026-08-30):
|
||||
#
|
||||
# gravity-load-chasing 是设计,但是我一两句话说不清楚。这不是一个现在就要推的
|
||||
# 项目,先做个按钮跟占位符出来。这将是一个不小于 panel_lift 的功能性按钮。
|
||||
#
|
||||
# "I cannot say it in a sentence or two" is a statement about the problem, so
|
||||
# this file does not try to say it either. It states what is settled, states
|
||||
# that the rest is not, and prints the list of open questions -- above all the
|
||||
# ones only he can answer, because those are what the placeholder is FOR. A
|
||||
# placeholder that quietly starts making engineering assumptions is worse than
|
||||
# an empty one; a placeholder that collects the open questions is worth having.
|
||||
#
|
||||
# The full document is QNA/gravity-load-chasing.md. This script deliberately
|
||||
# carries the substance rather than only a pointer: the person who presses the
|
||||
# button is in Revit, not in a text editor, and "see the file" is how a
|
||||
# placeholder becomes a dead end.
|
||||
#
|
||||
# WHAT THIS FILE MUST NOT GROW INTO
|
||||
# ---------------------------------
|
||||
# No arithmetic. No load model, no tributary rule, no accumulation. No
|
||||
# knowledge key, no schema field, no config default, no data structure, no file
|
||||
# format. None of that has been chosen by anybody, and structure invented ahead
|
||||
# of the method is how a placeholder turns into a set of assumptions nobody
|
||||
# agreed to. If something looks obviously needed, it goes in the QNA document
|
||||
# as a PROPOSAL and waits.
|
||||
#
|
||||
# It also reads nothing. Not geometry, not parameters, not the knowledge
|
||||
# layers. It opens no Transaction, because there is nothing to write and there
|
||||
# never will be from this file. The only thing it touches is the model's title,
|
||||
# so the report says which session printed it.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Gravity (WIP)'
|
||||
|
||||
import os
|
||||
|
||||
from pyrevit import revit, script
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Gravity Load Chasing'
|
||||
NOTES = 'QNA/gravity-load-chasing.md'
|
||||
|
||||
|
||||
def notes_path():
|
||||
"""Absolute path to the design-notes document, if it can be found.
|
||||
|
||||
Four levels up from this file is the extension folder; one more is the
|
||||
repo. Purely a convenience for the reader -- a miss costs a line, never
|
||||
the report.
|
||||
"""
|
||||
try:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
repo = os.path.abspath(os.path.join(here, '..', '..', '..', '..'))
|
||||
found = os.path.join(repo, 'QNA', 'gravity-load-chasing.md')
|
||||
if os.path.isfile(found):
|
||||
return found
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the report
|
||||
|
||||
output.print_md('# {0} — NOT IMPLEMENTED'.format(TITLE))
|
||||
output.print_md(
|
||||
'**None of this feature is built.** There is no calculation behind this '
|
||||
'button, and nothing was read from the model to produce this page. That '
|
||||
'is deliberate and it is the whole state of the work: a button, and a '
|
||||
'document holding the questions.')
|
||||
|
||||
try:
|
||||
output.print_md('Model **{0}** — opened, not read.'.format(doc.Title))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
output.print_md('---')
|
||||
|
||||
output.print_md('## What it will be')
|
||||
output.print_md(
|
||||
'Gravity load chasing, as the owner put it on 2026-08-30: *"it is '
|
||||
'design, but I cannot say it in a sentence or two. It is not a project '
|
||||
'to push right now — make a button and a placeholder first. It '
|
||||
'will be a functional button no smaller than Panel Lift Check."*')
|
||||
output.print_md(
|
||||
'One thing beyond that quote is already settled: like Lateral Extract, '
|
||||
'this tool does not write to the Revit model and never will. Both are '
|
||||
'extractors — read the model, write a file elsewhere. This one\'s '
|
||||
'target is **Excel**: it is meant to extract information and write an '
|
||||
'Excel workbook. Nothing here does that yet.')
|
||||
output.print_md(
|
||||
'Only four things follow from the quote itself, and nothing here will '
|
||||
'pretend to more:')
|
||||
output.print_md(
|
||||
'1. It is **design**, not checking.\n'
|
||||
'2. It **cannot yet be specified**, and that is a fact about the '
|
||||
'problem, not about anybody\'s time.\n'
|
||||
'3. It is **not being pushed now**.\n'
|
||||
'4. When it is built it is a **full feature** — its own '
|
||||
'engineering core, its own report, its own tests — at least the '
|
||||
'size of Panel Lift Check.')
|
||||
|
||||
output.print_md('## Two boundaries that are already settled')
|
||||
|
||||
output.print_md(
|
||||
'**It is design, not checking, and the liability is different.** '
|
||||
'Everything else on this panel reviews work that already exists — '
|
||||
'Ticket QC compares a drawn ticket to the model, Panel Lift Check judges '
|
||||
'rigging somebody placed, Reinforcing Suggestion proposes donors for a '
|
||||
'human to accept. In each case the original work is still there to look '
|
||||
'at if the tool is wrong. Gravity load chasing produces the answer '
|
||||
'itself, and it goes out under an engineer\'s seal. That has to shape '
|
||||
'the design from the first commit; it cannot be added at the end.')
|
||||
|
||||
output.print_md(
|
||||
'**Its input is not the current model.** It needs old jobs and their '
|
||||
'spreadsheets as samples and controls — mass-layout among them. '
|
||||
'Nothing else in this extension works that way. Those files also carry '
|
||||
'client and job identity, which may never enter the repository, so where '
|
||||
'the controls live is a real constraint on the project layout and not a '
|
||||
'detail for later.')
|
||||
|
||||
output.print_md(
|
||||
'**Carried forward from the reinforcing work** (see '
|
||||
'`QNA/reinforcing-suggestion-brief.md`): the plant standard bands '
|
||||
'vertical reinforcing `0.2H / PER DESIGN / 0.2H`, and the middle band is '
|
||||
'explicitly the engineer\'s own calculation for that one piece. The '
|
||||
'reinforcing matcher is forbidden from proposing it, because presenting '
|
||||
'one panel\'s design calculation as another panel\'s answer would look '
|
||||
'exactly like an ordinary suggestion. Gravity load chasing stands on the '
|
||||
'same ground — its entire output is that class of quantity. So the '
|
||||
'rule here is the mirror image: **a result from one job must never '
|
||||
'become an input to another by any automatic path**, and nothing this '
|
||||
'tool produces may ever be presented with the confidence the checking '
|
||||
'tools use.')
|
||||
|
||||
output.print_md('## What it will need from you before it can be built')
|
||||
output.print_md(
|
||||
'This is the useful half of a placeholder. None of these have answers '
|
||||
'yet, and none will be invented:')
|
||||
output.print_md(
|
||||
'- **Old jobs, and the spreadsheets that go with them.** Which ones, and '
|
||||
'where they can be reached from — they cannot live in the '
|
||||
'repository.\n'
|
||||
'- **Mass-layout: what it actually is.** A spreadsheet, a tool, a stage '
|
||||
'of the job? Who produces it, when, in what format?\n'
|
||||
'- **Which of those jobs are the controls**, and what counts as passing '
|
||||
'— reproducing your number to within what tolerance, on which '
|
||||
'quantities?\n'
|
||||
'- **Whether the spreadsheets are machine-readable** — fixed '
|
||||
'columns and stable sheet names, or hand-formatted per job. This is a '
|
||||
'large fork: a parser, or an import UI.\n'
|
||||
'- **What is being chased, over what unit, producing what.** One panel, '
|
||||
'one wall line, a whole building; a number per piece, a table, a '
|
||||
'drawing.\n'
|
||||
'- **Which method governs** — which standard and edition, and '
|
||||
'whether the plant has its own that supersedes it.\n'
|
||||
'- **Which loads are in scope**, and what arrives as given rather than '
|
||||
'computed.\n'
|
||||
'- **Design, or a first pass an engineer edits.** Different products, '
|
||||
'not different wordings.\n'
|
||||
'- **Read-only, or not.** Everything in this extension is read-only '
|
||||
'today. Is this the feature that changes that?')
|
||||
|
||||
output.print_md('## The five that block everything else')
|
||||
output.print_md(
|
||||
'The rest can be discovered while building. These cannot — each '
|
||||
'one changes the shape of the whole thing, and getting one wrong is a '
|
||||
'rewrite rather than a fix:')
|
||||
output.print_md(
|
||||
'1. What is being chased, over what unit, producing what.\n'
|
||||
'2. What mass-layout is, and whether the sample spreadsheets can be read '
|
||||
'by a program.\n'
|
||||
'3. Where the controls live, given that they are client work.\n'
|
||||
'4. Design or first pass; read-only or not.\n'
|
||||
'5. Which method governs.')
|
||||
|
||||
output.print_md('## What was deliberately not built')
|
||||
output.print_md(
|
||||
'So it reads as a decision and not an oversight: no load model, no '
|
||||
'tributary logic, no accumulation rule, no arithmetic. No knowledge key, '
|
||||
'no schema field, no config default, no data structure, no file format. '
|
||||
'No library module on either side of the CPython boundary. Anything that '
|
||||
'looked obviously needed was written into the notes as a **proposal** '
|
||||
'instead, so that it gets chosen rather than inherited.')
|
||||
|
||||
output.print_md('---')
|
||||
found = notes_path()
|
||||
if found:
|
||||
output.print_md(
|
||||
'The full scope, every open question, and the proposals are in '
|
||||
'`{0}`.'.format(found))
|
||||
else:
|
||||
output.print_md(
|
||||
'The full scope, every open question, and the proposals are in '
|
||||
'`{0}` in the repository.'.format(NOTES))
|
||||
output.print_md(
|
||||
'Answers are welcome anywhere — in that file, or in '
|
||||
'`QNA/questions.md` the usual way. Until they arrive this button stays '
|
||||
'exactly as honest as it is now.')
|
||||
@@ -0,0 +1,10 @@
|
||||
title: Lateral (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration -- see GravityLoadChase. Half-built, dropdown only.
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,351 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Extract the lateral-resisting members and write an analysis input file.
|
||||
#
|
||||
# WHICH MEMBERS? The user says. Not this tool. A member is lateral-resisting
|
||||
# when somebody has typed the marker into its identity description -- by
|
||||
# default 'lateral-resistent' (the owner's spelling) or 'lateral-resistant'.
|
||||
# Nothing here infers it from geometry, category or name.
|
||||
#
|
||||
# READ-ONLY. No Transaction is opened, nothing is written to the model, and
|
||||
# the output files go beside the model, never into the repository.
|
||||
#
|
||||
# WHAT IT WRITES: a JSON intermediate model, two CSVs (nodes, members), and a
|
||||
# RISA-3D draft that is deliberately named `.r3d.DRAFT.txt` so it cannot be
|
||||
# loaded by accident. See vibe.latrisa for what is established about the RISA
|
||||
# format and what is not.
|
||||
#
|
||||
# The selection idiom follows Ticket QC and Panel Lift Check: what is
|
||||
# preselected is what is checked; nothing preselected scans the configured
|
||||
# categories across the model. The difference from both is that this tool
|
||||
# never asks the user to pick pieces from a list -- the marker IS the pick,
|
||||
# and offering a second way to choose would let the two disagree.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Lateral (WIP)'
|
||||
|
||||
import io
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, knowledge, latextract, latmodel, latrisa
|
||||
from vibe import runlog
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Lateral Extract'
|
||||
|
||||
|
||||
def die(message):
|
||||
"""The user has not given us something yet. Not a refusal to extract."""
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text=message)
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
def not_run(message):
|
||||
"""'Could not judge' -- a RESULT, and it must survive on the report.
|
||||
|
||||
The distinction this whole tool turns on: NOT RUN is not the same as
|
||||
'ran and found nothing marked'. NOT RUN means the question could not be
|
||||
asked at all, so the absence of marked members says nothing. A run that
|
||||
asked and found nothing gets the ordinary report, with a nothing-found
|
||||
banner and no files written.
|
||||
"""
|
||||
output.print_md('# Lateral Extract — NOT RUN')
|
||||
output.print_md('**NOT RUN** — the extraction did not run.')
|
||||
output.print_md('> {0}'.format(message))
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='not-run',
|
||||
report_text=message)
|
||||
script.exit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
# -------------------------------------------------------------- context
|
||||
know = knowledge.load(doc)
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='refused', report_text=problem)
|
||||
forms.alert(problem, title=TITLE, exitscript=True)
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
scope = ('the {0} element(s) you selected'.format(len(selection))
|
||||
if selection else 'the whole model, in the configured categories')
|
||||
|
||||
# --------------------------------------------------------------------- run
|
||||
with forms.ProgressBar(title='Reading identity descriptions...') as pb:
|
||||
pb.update_progress(0, 2)
|
||||
model, found, why = latextract.gather(doc, selection=selection or None,
|
||||
know=know)
|
||||
pb.update_progress(2, 2)
|
||||
|
||||
cats = knowledge.get(know, 'lateral.categories', None) \
|
||||
or latextract.DEFAULT_CATEGORIES
|
||||
|
||||
if why == latextract.NOT_RUN_UNMAPPED:
|
||||
not_run(
|
||||
'The `parameters.{0}` role is not mapped, so this does not know '
|
||||
'which parameter holds the identity description the marker is '
|
||||
'typed into.\n\n'
|
||||
'It will not guess a name. A guessed name would work on the '
|
||||
'template it was guessed from and silently find nothing on every '
|
||||
'other one, and "found nothing" is indistinguishable from "nothing '
|
||||
'is marked" once it is on the screen.\n\n'
|
||||
'Map it in the plant knowledge file and run again. Self Check lists '
|
||||
'which layers are in play.'.format(latextract.DESC_ROLE))
|
||||
|
||||
if why == latextract.NOT_RUN_ABSENT:
|
||||
# A literal role path, not a variable, so knowledge_drift can see it.
|
||||
names = knowledge.param_names(know, 'member.identity_description')
|
||||
not_run(
|
||||
'The `parameters.{0}` role is mapped to **{1}**, but not one '
|
||||
'candidate element actually carries that parameter.\n\n'
|
||||
'That is not the same as "nothing is marked". The field is not '
|
||||
'there, so nothing could have been marked, and this tool has no '
|
||||
'opinion of its own about which members resist lateral load.\n\n'
|
||||
'Scope searched: {2}. Categories: `{3}`.\n\n'
|
||||
'Either add the parameter to these families, or point the role at '
|
||||
'whatever field this office really uses.'
|
||||
.format(latextract.DESC_ROLE, ', '.join(names), scope,
|
||||
', '.join(cats)))
|
||||
|
||||
# ------------------------------------------------------------------ report
|
||||
output.print_md('# Lateral extraction')
|
||||
output.print_md(
|
||||
'Model **{0}** · scope: {1} · {2} candidate element(s) read'
|
||||
.format(doc.Title, scope, found['candidates']))
|
||||
output.print_md(
|
||||
'Marker tokens accepted: {0}. Read from: {1}.'
|
||||
.format(', '.join(['`' + t + '`' for t in found['tokens']]),
|
||||
', '.join(['`' + p + '`' for p in found['params_tried']])))
|
||||
|
||||
# ---- the declared scope, and why it is NOT applied here ------------------
|
||||
#
|
||||
# Every other button on this panel narrows what it judges to the job's
|
||||
# declared scope (`vibe.scope`). This one deliberately does not, and says so
|
||||
# rather than leaving the reader to assume either way.
|
||||
#
|
||||
# The reason is that the marker IS the pick. A member is lateral-resisting
|
||||
# because somebody typed it into the identity description, one element at a
|
||||
# time -- that is a person choosing, not the tool choosing, and this whole
|
||||
# tool's rule is that a filter never overrides what the user picked. Beyond
|
||||
# that, a lateral system is columns, beams and shear walls by nature; scoping
|
||||
# it to one discipline's prefix would delete most of the frame and produce an
|
||||
# analysis model that looks complete and is not. The line below is printed
|
||||
# every run so the difference is visible on the page.
|
||||
_declared = scopelib.of(know)
|
||||
output.print_md(
|
||||
'> NOTHING WAS SET ASIDE FOR SCOPE. The declared scope is {0}, and it is '
|
||||
'deliberately NOT applied here: a member is in this extraction because '
|
||||
'somebody marked it, and a lateral system crosses every discipline on '
|
||||
'the job. Every marked member is exported whatever its mark, and the '
|
||||
'members another engineer owns are as much a part of the frame as '
|
||||
'yours.'.format(_declared.label()))
|
||||
|
||||
n_marked = len(found['marked'])
|
||||
n_amb = len(found['ambiguous'])
|
||||
|
||||
if not n_marked and not n_amb:
|
||||
# Asked, and the answer was nothing. Distinct from NOT RUN above, and
|
||||
# said in words rather than left as an empty table -- and critically,
|
||||
# NO FILES ARE WRITTEN. An empty RISA file on disk looks like an answer.
|
||||
output.print_md('## Nothing is marked')
|
||||
output.print_md(
|
||||
'> {0} element(s) were read and **not one** carries the marker. '
|
||||
'The identity-description field exists, so the question was asked '
|
||||
'properly and the answer is that no member has been marked as '
|
||||
'lateral-resisting yet.\n\n'
|
||||
'> **No files have been written.** An empty analysis file on disk '
|
||||
'would look like a result.\n\n'
|
||||
'> To mark a member, type `{1}` into its identity description '
|
||||
'(`{2}`) and run this again.'
|
||||
.format(found['candidates'], found['tokens'][0],
|
||||
found['params_tried'][0]))
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Nothing is marked - no files written.')
|
||||
script.exit()
|
||||
|
||||
if n_amb:
|
||||
output.print_md('## Ambiguous marks — not counted either way')
|
||||
output.print_md(
|
||||
'The marker appears in these fields but is preceded by a negating '
|
||||
'word, so it may mean the opposite. They are **excluded** from the '
|
||||
'export and named here for you to settle.')
|
||||
output.print_table(
|
||||
[[str(r['element_id']),
|
||||
r['mark'] or '', r['category'] or '', r['desc'] or '']
|
||||
for r in found['ambiguous']],
|
||||
columns=['element id', 'mark', 'category', 'identity description'])
|
||||
|
||||
# ---- what was selected, element by element -------------------------------
|
||||
output.print_md('## Marked as lateral-resisting ({0})'.format(n_marked))
|
||||
output.print_table(
|
||||
[[str(r['element_id']), r['mark'] or '', r['category'] or '',
|
||||
r['desc_param'] or '', r['token'] or '', r['desc'] or '']
|
||||
for r in found['marked']],
|
||||
columns=['element id', 'mark', 'category', 'read from', 'matched',
|
||||
'identity description'])
|
||||
|
||||
# ---- what was skipped, element by element, with the reason ---------------
|
||||
output.print_md('## Skipped ({0})'.format(len(model['skipped'])))
|
||||
if model['skipped']:
|
||||
output.print_table(
|
||||
[[str(s.get('element_id')), s.get('mark') or '',
|
||||
s.get('category') or '', s.get('reason') or '']
|
||||
for s in model['skipped']],
|
||||
columns=['element id', 'mark', 'category', 'why it is not a member'])
|
||||
else:
|
||||
output.print_md('> Nothing was skipped.')
|
||||
|
||||
# ---- what became members -------------------------------------------------
|
||||
output.print_md('## Members written ({0})'.format(len(model['members'])))
|
||||
if model['members']:
|
||||
output.print_table(
|
||||
[[m['label'], m['i_node'], m['j_node'],
|
||||
'{0:.3f}'.format(m['length_ft']),
|
||||
(m.get('section') or {}).get('name') or '(none)',
|
||||
m.get('material') or '(none)',
|
||||
m.get('axis_source') or '',
|
||||
'; '.join(m['unresolved'])]
|
||||
for m in model['members']],
|
||||
columns=['member', 'i', 'j', 'length ft', 'section label',
|
||||
'material', 'axis from', 'NOT determined'])
|
||||
else:
|
||||
output.print_md(
|
||||
'> **No member survived extraction.** Every marked element is in the '
|
||||
'skipped table above with its reason. Nothing analysable came out of '
|
||||
'this run.')
|
||||
|
||||
# ---- what could not be determined ----------------------------------------
|
||||
rows = latmodel.unresolved_rows(model)
|
||||
output.print_md('## Not determined ({0} item(s))'.format(len(rows)))
|
||||
output.print_md(
|
||||
'Every one of these is **left empty** in the output. None of them is '
|
||||
'given a default value. A member with no section is not quietly assigned '
|
||||
'one; the draft file writes `{0}` for it, which no analysis program can '
|
||||
'resolve, so it fails to analyse rather than analysing wrongly.'
|
||||
.format(latrisa.UNRESOLVED_SHAPE))
|
||||
if rows:
|
||||
output.print_table(
|
||||
[[r['label'], str(r['element_id']), r['mark'] or '', r['missing']]
|
||||
for r in rows],
|
||||
columns=['member', 'element id', 'mark', 'what is missing'])
|
||||
|
||||
# ---- connectivity --------------------------------------------------------
|
||||
conn = model['connectivity']
|
||||
output.print_md('## Connectivity')
|
||||
output.print_md(
|
||||
'{0} node(s), {1} member(s), welded at {2} ft. **{3}** node(s) have only '
|
||||
'one member reaching them. This tool does not extract supports, so it '
|
||||
'cannot tell a support from a disconnected member -- both look like this.'
|
||||
.format(conn['node_count'], conn['member_count'], model['node_tol_ft'],
|
||||
len(conn['free_ends'])))
|
||||
if not conn.get('near_miss_checked'):
|
||||
output.print_md(
|
||||
'> The near-miss search was **not run** ({0} nodes is too many for an '
|
||||
'O(n^2) scan on Revit\'s UI thread). That is not a finding of "no '
|
||||
'near misses" -- nobody looked.'.format(conn['node_count']))
|
||||
elif conn['near_misses']:
|
||||
output.print_md(
|
||||
'**{0} pair(s) of nodes sit within 6 inches of each other but further '
|
||||
'apart than the weld tolerance.** These are almost certainly meant to '
|
||||
'be the same point. They are **not** merged here.'
|
||||
.format(conn['near_miss_total']))
|
||||
output.print_table(
|
||||
[[r['a'], r['b'], '{0:.4f}'.format(r['gap_ft'])]
|
||||
for r in conn['near_misses'][:40]],
|
||||
columns=['node', 'node', 'gap ft'])
|
||||
output.print_md(
|
||||
'> A member ending part-way along another member is **not** connected to '
|
||||
'it and no node is inserted to make it so.')
|
||||
|
||||
# ---- the standing caveats ------------------------------------------------
|
||||
output.print_md('## What is deliberately not in the output')
|
||||
for note in model['notes']:
|
||||
output.print_md('- ' + note)
|
||||
|
||||
# ------------------------------------------------------------ where it goes
|
||||
#
|
||||
# Beside the MODEL, never in the repository.
|
||||
start_dir = knowledge.get(know, 'lateral.output_dir', None) or \
|
||||
designspec.sub_dir(doc, 'lateral')
|
||||
try:
|
||||
if start_dir and not os.path.isdir(start_dir):
|
||||
os.makedirs(start_dir)
|
||||
except Exception:
|
||||
start_dir = ''
|
||||
|
||||
if not doc.PathName:
|
||||
output.print_md(
|
||||
'## Not written\n> This model has never been saved, so there is no '
|
||||
'folder beside it to write to. Save the model and run again.')
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Model has never been saved - nothing written.')
|
||||
script.exit()
|
||||
|
||||
stem = os.path.splitext(os.path.basename(doc.PathName))[0]
|
||||
written = []
|
||||
for name, text in latrisa.file_set(model, stem):
|
||||
path = os.path.join(start_dir, name)
|
||||
try:
|
||||
f = io.open(path, 'w', encoding='utf-8')
|
||||
try:
|
||||
f.write(text if isinstance(text, type(u'')) else text.decode('utf-8'))
|
||||
finally:
|
||||
f.close()
|
||||
written.append(path)
|
||||
except Exception as ex:
|
||||
output.print_md('- **could not write** `{0}`: {1}'.format(name, ex))
|
||||
|
||||
output.print_md('## Files written')
|
||||
for path in written:
|
||||
output.print_md('- `{0}`'.format(path))
|
||||
output.print_md(
|
||||
'> The `.json` and the two `.csv` files are complete statements of what '
|
||||
'was extracted. The `.r3d.DRAFT.txt` is **not** a verified RISA input '
|
||||
'file -- its section keywords follow RISA\'s published Appendix D but '
|
||||
'the field order inside each record is unverified, so it is named so '
|
||||
'that it cannot be opened into RISA by accident. Read its header before '
|
||||
'renaming it.')
|
||||
if not latmodel.is_analysable(model):
|
||||
output.print_md(
|
||||
'> **This model would not analyse correctly as written**, and that '
|
||||
'is on purpose: it has unresolved sections, free ends, no supports '
|
||||
'and no releases. Fix those in the analysis package, not here.')
|
||||
|
||||
# The extraction ran and its files are on disk -- the record is 'ok' with
|
||||
# the report text kept short, since record() caps at 20000 characters.
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='ok',
|
||||
report_path=written[0] if written else None,
|
||||
report_text=('{0} marked member(s), {1} skipped, '
|
||||
'{2} node(s), {3} member(s) written; files: {4}'
|
||||
.format(n_marked, len(model['skipped']),
|
||||
conn['node_count'],
|
||||
conn['member_count'],
|
||||
'; '.join(written))))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Lateral Extract', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,17 @@
|
||||
title: Panel Analysis (WIP)
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# (WIP) is not decoration, same as its siblings in this pulldown. This button
|
||||
# pops an Eriksson-Wall-shaped INPUT CONFIRMATION form -- one collapsed section
|
||||
# per selected panel, pre-filled from the model where readable and blank
|
||||
# otherwise, gated so a panel cannot run until its required fields (incl. >=2
|
||||
# supports) are filled -- then feeds the confirmed inputs to the Stage-0
|
||||
# self-weight solve. The in-place / wind / service engine behind the form is a
|
||||
# later stage. See script.py's own comments for exactly what it does and does
|
||||
# not do.
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,27 @@
|
||||
title: Panel Lift Check
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to `liftexport` -- the Revit half of the
|
||||
# panellift boundary -- is visible without a Revit restart. It trades press
|
||||
# latency (engine construction instead of engine reuse) for restart-freedom.
|
||||
# The CPython half already needs no restart; this is for the half that does.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# MOVED 2026-09-05 from Tools.panel, where it was one of four large icons
|
||||
# (see Tools.panel/bundle.yaml). One line, no `\n`: this button is now a row
|
||||
# in DesignHub's `.pulldown`, and a pulldown row renders as text beside a
|
||||
# 16px icon -- a `\n` has nowhere to go and the dropdown grows to fit the
|
||||
# longest row, so the title is free to say what it means. No (WIP): moving
|
||||
# panels did not change what this tool does, and it is not half-built.
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,36 @@
|
||||
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
|
||||
# why a container may carry a `title:`.
|
||||
title: DesignHub
|
||||
|
||||
# REORGANISED 2026-09-05, on the owner's own instruction: "把 Panel Lift
|
||||
# 移到 Design Hub 里面去,再把 Beam Analysis 跟 Connection Analysis 也加
|
||||
# 进去" -- Panel Lift Check moved onto this pulldown from being one of
|
||||
# Tools.panel's four large icons (see that panel's own bundle comment), and
|
||||
# two new placeholder rows, BeamAnalysis and ConnectionAnalysis, joined
|
||||
# GravityLoadChase and LateralExtract as more (WIP) rows.
|
||||
#
|
||||
# PanelLiftCheck is NOT half-built -- it is the one row here that runs, end
|
||||
# to end, on a real piece, exactly like PanelAnalysis. It moved here because
|
||||
# the owner wants it grouped with the other analysis tools rather than
|
||||
# because it stopped working; nothing about its own behaviour changed.
|
||||
#
|
||||
# The other four rows are half-built and all four say so in their own
|
||||
# titles. Keep the (WIP) suffixes until the tool behind one actually runs:
|
||||
# the arrow hides them from the ribbon, but a row that opens is still a row
|
||||
# somebody clicks. PanelAnalysis differs from BeamAnalysis, ConnectionAnalysis,
|
||||
# GravityLoadChase and LateralExtract in one way worth naming: it DOES run,
|
||||
# end to end, and opens a real page -- what is unfinished is the analysis
|
||||
# pipeline behind the page it opens, not the button itself. See that
|
||||
# button's own script.py.
|
||||
#
|
||||
# Order: the two tools that run today, then the four that do not, in the
|
||||
# order the owner named them.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- PanelLiftCheck
|
||||
- PanelAnalysis
|
||||
- BeamAnalysis
|
||||
- ConnectionAnalysis
|
||||
- GravityLoadChase
|
||||
- LateralExtract
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,16 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Design").
|
||||
#
|
||||
# THIS PANEL IS THE QUARANTINE, and the pulldown is its door. The owner's
|
||||
# standing instruction is that unfinished tools must never sit on the same
|
||||
# panel as the polished ones; gravity-load chasing, lateral extraction, beam
|
||||
# analysis and connection analysis are buttons-and-docs only until he
|
||||
# provides worked examples, so they live one click down, behind DesignHub,
|
||||
# on a panel of their own. Panel Analysis and Panel Lift Check share that
|
||||
# same door for a different reason (2026-09-05 for Panel Lift Check) --
|
||||
# both actually run, but the owner wants them grouped with the tools that
|
||||
# do not rather than back out on Tools.panel; see DesignHub's own bundle
|
||||
# comment.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- DesignHub
|
||||
@@ -0,0 +1,13 @@
|
||||
title: GOD_MOD
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# The name is the owner's, verbatim and with the underscore, from
|
||||
# 2026-08-30: "我不喜欢 claude button. 还是叫 GOD_MOD." The folder is GodMod
|
||||
# because a pyRevit component name is a C# class name in the generated
|
||||
# assembly; the title is what anybody actually reads.
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Starts a detached terminal running the `claude` CLI with its working
|
||||
# directory set to the repository root, so the session comes up on CLAUDE.md
|
||||
# and tools/rvt.py -- which is what wires it to THIS running Revit.
|
||||
#
|
||||
# The terminal is a separate process. Nothing runs on Revit's UI thread, so a
|
||||
# long agent session cannot freeze or crash Revit, and this button touches the
|
||||
# model in no way at all: it opens no Transaction, reads no element, and does
|
||||
# not even import `vibe.helpers`' document handle.
|
||||
#
|
||||
# PLAIN `claude`, DEFAULT PERMISSIONS. Standing ruling: this launch must never
|
||||
# pass --dangerously-skip-permissions, --permission-mode bypassPermissions, or
|
||||
# any other auto-approve flag. The agent is being pointed at a live Revit
|
||||
# session and the owner's real model; every tool call it makes is worth a
|
||||
# human seeing first. If a future edit "just needs" the prompts gone, that is
|
||||
# a conversation with the owner, not a flag.
|
||||
#
|
||||
# Interactive, so this deliberately does NOT use the redirect-both-streams
|
||||
# runner from vibe.liftexport.run -- that pattern exists to capture a headless
|
||||
# run without deadlocking on a pipe, and redirecting stdout here would leave
|
||||
# the CLI with no terminal to draw on. What is reused is the other half of the
|
||||
# idiom: a generated .cmd does the cd and holds the window open, so nothing
|
||||
# has to nest quotes inside a wt.exe command line.
|
||||
#
|
||||
# WHAT THIS REPLACED: the button used to run tools/dsh-here.cmd, which starts
|
||||
# `dsh web` (the DeepSeek Harness browser UI) in the same folder. That was the
|
||||
# whole of its behaviour -- a different agent CLI in the same slot -- so it is
|
||||
# gone rather than preserved. tools/dsh-here.cmd is left on disk, now
|
||||
# unreferenced, for the owner to keep or bin.
|
||||
#
|
||||
# This button was called GOD_MOD, then `Claude` for the 2026-08-30 ribbon
|
||||
# rebuild, and is GOD_MOD again after the 2026-08-30 one -- the owner's own
|
||||
# word, underscore and all. Same button, same folder contents throughout; the
|
||||
# folder is `GodMod.pushbutton` because a component name becomes a C# class
|
||||
# name in the generated assembly.
|
||||
#
|
||||
# `__title__` is dead weight while `bundle.yaml` exists -- pyRevit reads it
|
||||
# only `if not self.meta` -- and it is kept in step anyway, so that trimming a
|
||||
# bundle file cannot resurrect a name nobody uses.
|
||||
__title__ = 'GOD_MOD'
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
|
||||
from pyrevit import forms, script
|
||||
|
||||
TITLE = 'Claude'
|
||||
MARKER = 'CLAUDE.md' # the project instructions the session reads
|
||||
|
||||
|
||||
def repo_root():
|
||||
"""The checkout to start the session in, and whether it looks like one.
|
||||
|
||||
This script lives at
|
||||
<repo>/WillTools.extension/<tab>/<panel>/<button>/script.py
|
||||
so the repo is four levels above the button. Deriving it means the
|
||||
checkout can sit anywhere; ~/pyrevit-dev stays as the fallback for the
|
||||
case where pyRevit does not hand us __file__.
|
||||
|
||||
Returns (folder, found_marker). A folder without CLAUDE.md still gets the
|
||||
session -- it is only missing the project instructions -- but the report
|
||||
says so rather than letting the agent start somewhere surprising in
|
||||
silence.
|
||||
"""
|
||||
home = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
|
||||
try:
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
except NameError:
|
||||
return home, os.path.isfile(os.path.join(home, MARKER))
|
||||
for _ in range(4):
|
||||
root = os.path.dirname(root)
|
||||
if os.path.isfile(os.path.join(root, MARKER)):
|
||||
return root, True
|
||||
if os.path.isfile(os.path.join(home, MARKER)):
|
||||
return home, True
|
||||
return root, False
|
||||
|
||||
|
||||
def find_cli():
|
||||
"""Absolute path to the Claude Code CLI, or None.
|
||||
|
||||
`vibe.aiextract.find_claude` already knows the four places a per-user
|
||||
install lands and honours CLAUDE_CODE_EXE; two copies of that list would
|
||||
drift. Imported here rather than at module scope so a broken library
|
||||
still leaves this button able to say what went wrong.
|
||||
"""
|
||||
from vibe.aiextract import find_claude
|
||||
return find_claude()
|
||||
|
||||
|
||||
def write_runner(claude_exe, workdir):
|
||||
"""A one-shot .cmd that cds, runs claude, and keeps the window up.
|
||||
|
||||
It goes in %TEMP%, never in the repo and never beside the model: it holds
|
||||
an absolute path to somebody's install, which is exactly what the scrub
|
||||
gate exists to keep out of the tree. Named per Revit process, because
|
||||
cmd.exe re-reads a batch file as it runs it and two Revits rewriting one
|
||||
file under each other is a class of bug not worth owning.
|
||||
"""
|
||||
folder = os.path.join(tempfile.gettempdir(), 'WillTools')
|
||||
if not os.path.isdir(folder):
|
||||
os.makedirs(folder)
|
||||
runner = os.path.join(folder,
|
||||
'god-mod-claude-{0}.cmd'.format(os.getpid()))
|
||||
# newline='' or the CRLFs below get translated again and every line ends
|
||||
# \r\r\n. cmd.exe survives that, but a batch file nobody can read in a
|
||||
# text editor is a bad place to be debugging a launch from.
|
||||
with io.open(runner, 'w', encoding='utf-8', newline='') as fh:
|
||||
fh.write(u'@echo off\r\n')
|
||||
fh.write(u'chcp 65001 >nul\r\n')
|
||||
fh.write(u'title Claude Code - Revit\r\n')
|
||||
fh.write(u'cd /d "{0}"\r\n'.format(workdir))
|
||||
fh.write(u'echo Revit dev folder: %CD%\r\n')
|
||||
fh.write(u'echo.\r\n')
|
||||
fh.write(u'"{0}"\r\n'.format(claude_exe))
|
||||
fh.write(u'echo.\r\n')
|
||||
fh.write(u'echo [claude exited - this window stays open]\r\n')
|
||||
fh.write(u'pause >nul\r\n')
|
||||
return runner
|
||||
|
||||
|
||||
def launch(runner, workdir):
|
||||
"""Start the runner detached. Windows Terminal when it is there."""
|
||||
psi = ProcessStartInfo()
|
||||
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
|
||||
'Microsoft', 'WindowsApps', 'wt.exe')
|
||||
if os.path.isfile(wt):
|
||||
psi.FileName = wt
|
||||
psi.Arguments = '-d "{0}" cmd.exe /c "{1}"'.format(workdir, runner)
|
||||
else:
|
||||
psi.FileName = 'cmd.exe'
|
||||
psi.Arguments = '/c "{0}"'.format(runner)
|
||||
psi.WorkingDirectory = workdir
|
||||
psi.UseShellExecute = True
|
||||
return Process.Start(psi)
|
||||
|
||||
|
||||
WORKDIR, HAS_MARKER = repo_root()
|
||||
|
||||
try:
|
||||
CLAUDE = find_cli()
|
||||
except Exception as ex:
|
||||
forms.alert('Could not look up the Claude Code CLI:\n\n{0}: {1}'.format(
|
||||
type(ex).__name__, ex), title=TITLE, exitscript=True)
|
||||
|
||||
if not CLAUDE:
|
||||
forms.alert(
|
||||
'Claude Code CLI not found.\n\n'
|
||||
'Install it, or set the CLAUDE_CODE_EXE environment variable to the '
|
||||
'full path of claude.cmd / claude.exe.\n\n'
|
||||
'Looked in the usual per-user install folders and on PATH.',
|
||||
title=TITLE, exitscript=True)
|
||||
|
||||
try:
|
||||
RUNNER = write_runner(CLAUDE, WORKDIR)
|
||||
launch(RUNNER, WORKDIR)
|
||||
except Exception as ex:
|
||||
forms.alert('Could not start Claude Code:\n\n{0}: {1}'.format(
|
||||
type(ex).__name__, ex), title=TITLE, exitscript=True)
|
||||
|
||||
output = script.get_output()
|
||||
output.print_md('### Claude Code is starting')
|
||||
output.print_md('Working folder: `{0}`'.format(WORKDIR))
|
||||
output.print_md('CLI: `{0}`'.format(CLAUDE))
|
||||
if not HAS_MARKER:
|
||||
output.print_md(
|
||||
'**No `{0}` in that folder** - the session will start without the '
|
||||
'project instructions, and without them it does not know about the '
|
||||
'bridge to this Revit.'.format(MARKER))
|
||||
output.print_md(
|
||||
'It runs with **default permissions**, so it asks before it acts. '
|
||||
'It can read and drive **this** Revit session through `tools/rvt.py` - '
|
||||
'ask it things like _"how many walls are on level 2?"_ or _"build me a '
|
||||
'button that renumbers the selected doors"_.')
|
||||
@@ -0,0 +1,10 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Lab").
|
||||
#
|
||||
# One button, and it is not a work in progress -- GOD_MOD works. It sits
|
||||
# alone at the right-hand end because it is the escape hatch rather than one
|
||||
# of the four tools, and an escape hatch you can find without looking is
|
||||
# worth a panel divider.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- GodMod
|
||||
@@ -0,0 +1,8 @@
|
||||
title: Design Extract
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,714 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Turn a design summary into a reviewable spec file, in two chained passes.
|
||||
#
|
||||
# PASS 1, mechanical, in-process: scrapes the summary drafting view and its
|
||||
# sheet, then reads it with ticketqc's regex/geometry parser -- no AI call,
|
||||
# no subprocess, nothing that leaves this process. What the parser could not
|
||||
# place lands verbatim in the spec's 'unresolved' list rather than being
|
||||
# dropped, which is what makes a narrow rule set honest instead of lossy.
|
||||
# Read-only as far as Revit is concerned, except for exporting the summary
|
||||
# view to a PNG for pass 2 (also read-only -- ExportImage writes no model
|
||||
# data).
|
||||
#
|
||||
# PASS 2, AI, out of process (owner, 2026-09-01, Discord): chained
|
||||
# automatically after pass 1 succeeds, `vibe.aiextract.launch` starts a
|
||||
# DETACHED process (`tools/aiextract_run.py`, DSV4F with vision) that fills
|
||||
# whatever pass 1 left null, empty or unresolved, plus the strand matrix
|
||||
# pass 1 never attempts. It can only ADD to the spec pass 1 wrote -- see
|
||||
# that module's docstring for the never-overwrite rule -- and this button
|
||||
# does not wait for it: the spec file is updated in place whenever that
|
||||
# process finishes, or left exactly as pass 1 wrote it if the AI pass fails.
|
||||
#
|
||||
# Every value this button writes is machine-read and UNVERIFIED. Check it
|
||||
# against the drawing before anything downstream (Ticket QC, Panel Lift Check)
|
||||
# is trusted to have used the right numbers.
|
||||
#
|
||||
# THE DECLARED SCOPE, AND WHY THIS BUTTON DOES NOT ENFORCE IT
|
||||
# -----------------------------------------------------------
|
||||
# The specs written here are what Ticket QC's design-intent comparison
|
||||
# enforces, so a design extracted here enters the scoped path. Until
|
||||
# 2026-08-31 this button offered EVERY design in the model with no mention of
|
||||
# the scope at all -- a side door: an out-of-scope design could be extracted,
|
||||
# reach Ticket QC, and be compared against, without ever being labelled.
|
||||
#
|
||||
# It is not a door that should be shut. The engineer may have a reason to
|
||||
# extract somebody else's design -- to read it, to compare against it, to
|
||||
# check a shared edge -- and a picker that hid designs would be exactly the
|
||||
# silent exclusion `vibe.scope` exists to refuse. So nothing is filtered and
|
||||
# nothing is blocked. Instead:
|
||||
#
|
||||
# * the picker row says OUTSIDE THE DECLARED SCOPE, at the moment of
|
||||
# choosing, before anything is written;
|
||||
# * the report carries the scope line every other report carries, and,
|
||||
# when one was actually extracted, the annotation saying the values in
|
||||
# it rest on knowledge nobody validated for that kind of piece;
|
||||
# * the fold opens ITSELF when that happened, by the standing display rule.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Design Extract'
|
||||
|
||||
import io
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from Autodesk.Revit.DB import (BrowserOrganization, FilteredElementCollector,
|
||||
ViewSheet)
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, ticketqc, knowledge, specsave
|
||||
from vibe import aiextract, ticketvision
|
||||
from vibe import foldreport as fold
|
||||
from vibe import runlog
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
if not doc.PathName:
|
||||
runlog.record('Design Extract', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Save the model first - the spec files live '
|
||||
'beside it.')
|
||||
forms.alert('Save the model first - the spec files live beside it.',
|
||||
exitscript=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
|
||||
# --------------------------------------------------------------- pick work
|
||||
know = knowledge.load(doc)
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=know)
|
||||
existing = designspec.list_specs(doc)
|
||||
|
||||
DESIGN_FOLDER = knowledge.get(know, 'nomenclature.design_folder', '') or ''
|
||||
IGNORE = knowledge.get(know, 'nomenclature.ignore_sheet_prefixes', []) or []
|
||||
|
||||
# The declared scope, off the job knowledge, read the same way Job Recon,
|
||||
# Takeoff and Ticket QC read it. Used to LABEL, never to filter -- see the
|
||||
# note at the top of this file.
|
||||
the_scope = scopelib.of(know)
|
||||
|
||||
|
||||
def out_of_scope(design):
|
||||
"""True when this design number is declared to be somebody else's.
|
||||
|
||||
Through `as_mark`, so a design key that does not read as a mark at all
|
||||
is 'could not tell' and is never labelled as another engineer's.
|
||||
"""
|
||||
return not the_scope.includes(scopelib.as_mark(design))
|
||||
|
||||
|
||||
def sheets_by_folder():
|
||||
"""{'FOLDER / SUB': [ViewSheet, ...]} read from the project browser's own
|
||||
organization, so this picker is grouped the way the browser tree is.
|
||||
|
||||
A flat list of every drafting view is unusable on a real job -- there are
|
||||
hundreds, and their names are not the design numbers anyway.
|
||||
"""
|
||||
try:
|
||||
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
|
||||
except Exception:
|
||||
org = None
|
||||
|
||||
out = {}
|
||||
for sheet in (FilteredElementCollector(doc).OfClass(ViewSheet)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
if sheet.IsTemplate:
|
||||
continue
|
||||
number = (sheet.SheetNumber or '').strip()
|
||||
if any(number.upper().startswith(str(p).upper()) for p in IGNORE):
|
||||
continue
|
||||
folder = ''
|
||||
if org is not None:
|
||||
try:
|
||||
folder = ' / '.join(
|
||||
[item.Name for item in org.GetFolderItems(sheet.Id)])
|
||||
except Exception:
|
||||
folder = ''
|
||||
out.setdefault(folder or '(no folder)', []).append(sheet)
|
||||
return out
|
||||
|
||||
|
||||
def packets(sheets):
|
||||
"""{design key: [ViewSheet, ...]} in page order.
|
||||
|
||||
One row per design packet, not per sheet: PNL1.0 / PNL1.A / PNL1.B are
|
||||
pages of design PNL1, and extracting any of them means extracting the
|
||||
packet.
|
||||
"""
|
||||
out = {}
|
||||
for sheet in sheets:
|
||||
key = designspec.design_key(sheet.SheetNumber, know)
|
||||
if key:
|
||||
out.setdefault(key, []).append(sheet)
|
||||
for key in out:
|
||||
out[key].sort(key=lambda s: (s.SheetNumber or ''))
|
||||
return out
|
||||
|
||||
|
||||
DESIGNS = {} # row label -> design key, so nothing has to be split
|
||||
|
||||
|
||||
def row(design, sheets=None):
|
||||
bits = []
|
||||
if sheets:
|
||||
cover = sheets[0]
|
||||
bits.append(cover.Name)
|
||||
if len(sheets) > 1:
|
||||
bits.append('{0} sheets ({1})'.format(
|
||||
len(sheets), ', '.join([s.SheetNumber for s in sheets[:6]])))
|
||||
else:
|
||||
bits.append('sheet {0}'.format(cover.SheetNumber))
|
||||
bits.append('{0} pieces'.format(len(grouped[design])) if design in grouped
|
||||
else 'NO pieces')
|
||||
if design in existing:
|
||||
bits.append('spec exists - will be overwritten')
|
||||
# At the point of choosing, and in capitals: this is the one moment the
|
||||
# engineer can decide he did not mean to extract somebody else's design.
|
||||
# It never removes the row.
|
||||
if out_of_scope(design):
|
||||
bits.append('OUTSIDE THE DECLARED SCOPE')
|
||||
label = '{0} - {1}'.format(design, ', '.join(bits))
|
||||
DESIGNS[label] = design
|
||||
return label
|
||||
|
||||
|
||||
groups = OrderedDict()
|
||||
|
||||
by_folder = sheets_by_folder()
|
||||
for folder in sorted(by_folder):
|
||||
found = packets(by_folder[folder])
|
||||
if found:
|
||||
groups['Sheets / ' + folder] = [row(key, found[key])
|
||||
for key in sorted(found)]
|
||||
|
||||
# Designs the pieces claim that no sheet in the design folder accounts for.
|
||||
# These would silently go unchecked, so they get their own group rather than
|
||||
# being left off the list.
|
||||
accounted = set()
|
||||
for folder in by_folder:
|
||||
if not DESIGN_FOLDER or DESIGN_FOLDER.upper() in folder.upper():
|
||||
accounted.update(packets(by_folder[folder]).keys())
|
||||
orphans = [d for d in sorted(grouped) if d not in accounted]
|
||||
if orphans:
|
||||
groups['Pieces with no design sheet'] = [row(d) for d in orphans]
|
||||
|
||||
if existing:
|
||||
leftover = [row(d) for d in sorted(existing) if d not in DESIGNS.values()]
|
||||
if leftover:
|
||||
groups['Specs already on disk'] = leftover
|
||||
|
||||
if not groups:
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text='No design numbers found in this model.')
|
||||
forms.alert('No design numbers found in this model.', exitscript=True)
|
||||
|
||||
kwargs = {
|
||||
'title': 'Extract which design summaries?',
|
||||
'group_selector_title': 'Browser folder',
|
||||
'multiselect': True,
|
||||
'button_name': 'Extract',
|
||||
'width': 900,
|
||||
'height': 650,
|
||||
}
|
||||
preferred = 'Sheets / ' + DESIGN_FOLDER
|
||||
if DESIGN_FOLDER and preferred in groups:
|
||||
kwargs['default_group'] = preferred
|
||||
|
||||
picked = forms.SelectFromList.show(groups, **kwargs)
|
||||
if not picked:
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text='Design picker closed -- no design chosen.')
|
||||
script.exit()
|
||||
designs = sorted(set([DESIGNS.get(p, p.split(' ')[0]) for p in picked]))
|
||||
|
||||
# What was OFFERED and left unpicked is not the scope's business -- the
|
||||
# engineer chose. What matters is what he DID pick that the declaration says
|
||||
# is somebody else's, because those specs are about to feed Ticket QC.
|
||||
picked_out = [d for d in designs if out_of_scope(d)]
|
||||
scope_line = the_scope.line(picked_out, total=len(designs), unit='design')
|
||||
scope_annotation = the_scope.annotation(picked_out, unit='design')
|
||||
|
||||
# ------------------------------------------------------------- gather + go
|
||||
output.print_md('# Extract design summaries')
|
||||
output.print_md('Model **{0}**'.format(doc.Title))
|
||||
output.print_md('Data folder: `{0}`'.format(designspec.root_dir(doc)))
|
||||
output.print_md(
|
||||
'**Every value below is machine-read and unverified.** A regex either '
|
||||
'matched a rule or it did not -- there is no judgement in between. What '
|
||||
"matched is filled in; every line of the summary that didn't is listed "
|
||||
'verbatim in the spec\'s `unresolved` section instead of being silently '
|
||||
'dropped -- and, from this press on, on this page as well. Read those, '
|
||||
'then check the spec in `{0}` against the drawing before Ticket QC or '
|
||||
'Panel Lift Check are trusted to have used the right numbers.'
|
||||
.format(designspec.sub_dir(doc, 'specs')))
|
||||
|
||||
# ---- the declared scope. Not folded, and above the results rather than
|
||||
# below them, because a spec is written to disk by the time this page exists
|
||||
# -- the reader is being told what he already has, and Ticket QC will pick it
|
||||
# up whether or not he scrolls.
|
||||
output.print_md('> ' + scope_line)
|
||||
if scope_annotation:
|
||||
output.print_md('> **' + scope_annotation + '**')
|
||||
for _note in the_scope.declaration_lines():
|
||||
output.print_md('_{0}_'.format(_note))
|
||||
|
||||
def _per_plant_layer_exists():
|
||||
"""True when this machine already has at least one per-plant folder.
|
||||
|
||||
Answers a different question from `knowledge.plant_id(doc)`: that asks
|
||||
which plant THIS job names; this asks whether the per-plant migration
|
||||
has already happened on THIS MACHINE at all, which is what decides
|
||||
whether the legacy machine-wide path is still a safe place to drop a
|
||||
starter.
|
||||
|
||||
A machine nobody has ever set up has no `<root>/<anything>/knowledge.yaml`
|
||||
-- writing the machine-wide starter there is exactly what this button has
|
||||
always done, and it is still right: a fresh install needs something to
|
||||
edit. But once a machine has even one real per-plant folder, the
|
||||
machine-wide file at `<root>/knowledge.yaml` is what
|
||||
`plant_knowledge_path(None)` hands back as the NO-PLANT FALLBACK
|
||||
(`plantpath.py`), so writing a blank template there would give a job
|
||||
that simply forgot to set `job.plant` a silent, empty plant layer
|
||||
instead of a loud "no plant declared." That is the exact trap this fix
|
||||
exists to close (QNA/seam-review-2026-08-31.md section 2), so on a
|
||||
migrated machine the safe move is to write nothing and say so.
|
||||
"""
|
||||
root = designspec.plant_root()
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except Exception:
|
||||
return False
|
||||
for name in names:
|
||||
if os.path.isfile(os.path.join(root, name, 'knowledge.yaml')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# `write_plant_starter()` with no plant argument always targets the
|
||||
# machine-wide `<root>/knowledge.yaml` -- the exact file
|
||||
# `plant_knowledge_path(None)` returns as the fallback for any job that
|
||||
# omits `job.plant`. Calling it unconditionally, as this button used to,
|
||||
# recreated a blank template there on every press, which any no-plant job
|
||||
# would then silently read as its entire plant layer. `knowledge.plant_id`
|
||||
# is the pre-pass `knowledge.load()` itself uses to choose the plant layer
|
||||
# before the ordinary merge runs (see its docstring), so it is read the
|
||||
# same way here rather than re-derived.
|
||||
plant = knowledge.plant_id(doc)
|
||||
|
||||
if plant:
|
||||
# A named plant gets its own folder. `write_plant_starter` already
|
||||
# refuses to overwrite a file that exists (knowledge.py:6601 --
|
||||
# `if os.path.isfile(path) and not overwrite: return path, False`), so
|
||||
# calling it on every press is safe: it only ever fills a gap, never
|
||||
# clobbers a real plant layer.
|
||||
ppath, pcreated = knowledge.write_plant_starter(plant=plant)
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
elif _per_plant_layer_exists():
|
||||
# No plant named, but this machine has already been migrated to
|
||||
# per-plant folders -- writing at the legacy path would recreate the
|
||||
# exact decoy this fix removes. Report the gap instead of filling it
|
||||
# with a blank file that looks like an answer.
|
||||
output.print_md(
|
||||
'Plant standards: **no `job.plant` set**, and this machine already '
|
||||
'has per-plant folders under `{0}` -- nothing written at the '
|
||||
'legacy machine-wide path, because that path is what a no-plant '
|
||||
'job reads as its plant layer. Set `job.plant` in this job\'s '
|
||||
'knowledge file.'.format(designspec.plant_root()))
|
||||
else:
|
||||
# A genuinely fresh machine: no per-plant folder exists anywhere yet,
|
||||
# so the machine-wide file is still the only plant layer this install
|
||||
# could possibly mean, exactly as it was before per-plant folders
|
||||
# existed. Safe to seed, and `write_plant_starter` still refuses to
|
||||
# overwrite if one is somehow already there.
|
||||
ppath, pcreated = knowledge.write_plant_starter()
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
|
||||
kpath, created = knowledge.write_starter(doc, plant=plant)
|
||||
if created:
|
||||
output.print_md('Wrote a starter knowledge file: `{0}`'.format(kpath))
|
||||
|
||||
written, skipped = [], []
|
||||
for design in designs:
|
||||
try:
|
||||
evidence = designspec.gather_evidence(doc, design, know=know)
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
if not evidence.get('summary_views'):
|
||||
skipped.append((design, 'no sheet in "{0}" numbers to {1}, or its '
|
||||
'sheets carry no views'.format(
|
||||
DESIGN_FOLDER or 'any folder', design)))
|
||||
continue
|
||||
designspec.write_json(designspec.evidence_path(doc, design), evidence)
|
||||
notes = sum([len(v.get('text_notes') or [])
|
||||
for v in evidence.get('summary_views') or []])
|
||||
|
||||
try:
|
||||
spec = designspec.build_spec(doc, design, know=know, evidence=evidence)
|
||||
if spec is None:
|
||||
skipped.append((design, 'no summary view to read'))
|
||||
continue
|
||||
path = designspec.save_spec(doc, design, spec)
|
||||
# The spec itself is kept, not just its counts: what is unresolved is
|
||||
# listed by name below, and the copy the engineer asks for is written
|
||||
# from the file this returned.
|
||||
written.append({'design': design, 'notes': notes, 'path': path,
|
||||
'spec': spec})
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
# ----------------------------------------------- the AI pass, chained on
|
||||
#
|
||||
# Owner, 2026-09-01 (Discord), approving the two-stage-extraction
|
||||
# proposal: the mechanical reader above always runs first and never
|
||||
# guesses; whatever it left null, empty or unresolved is this second
|
||||
# pass's job, on DSV4F WITH VISION, never opus ("可以,不过别用opus,用
|
||||
# dsv4f带vision吧"). The strand matrix is one such field, not a
|
||||
# standing exception any more -- `build_spec` now reads it from
|
||||
# geometry (the drawn strand symbols against the dimension chain) and
|
||||
# takes it only when every identity check on that reading passes; the
|
||||
# AI pass picks it up here exactly when the geometry read refused, the
|
||||
# same as any other blank left by the mechanical reader. It runs
|
||||
# detached (`vibe.aiextract.launch`), the same
|
||||
# way the AI reader always has, so this button returns immediately --
|
||||
# the spec file above is updated IN PLACE whenever that process
|
||||
# finishes, or is left exactly as written above (plus one `unresolved`
|
||||
# line saying so) if the AI pass fails or times out. Nothing here waits
|
||||
# for it and nothing here re-reads the spec after kicking it off.
|
||||
#
|
||||
# Rendering the summary view to an image is Revit API work, done here
|
||||
# rather than inside `aiextract` (which otherwise has no reason to touch
|
||||
# a live View) -- OWNER MUST VERIFY: exercised only against a live Revit
|
||||
# session, not tonight.
|
||||
ai_image, ai_error = None, None
|
||||
try:
|
||||
ai_views, _ai_sheets = designspec.find_summary_views(doc, design,
|
||||
know=know)
|
||||
if ai_views:
|
||||
ai_image = ticketvision.render_view_png(
|
||||
doc, ai_views[0].Id, designspec.sub_dir(doc, 'log'))
|
||||
except Exception as ex:
|
||||
ai_error = ('could not render the summary view to an image ({0}: '
|
||||
'{1}) -- the AI pass will run text-only, and the strand '
|
||||
'matrix will very likely stay blank'
|
||||
.format(type(ex).__name__, ex))
|
||||
|
||||
try:
|
||||
aiextract.launch(doc, design, know=know,
|
||||
image_paths=[ai_image] if ai_image else [])
|
||||
written[-1]['ai_launched'] = True
|
||||
except Exception as ex:
|
||||
written[-1]['ai_launched'] = False
|
||||
started = ('the AI pass itself could not be started ({0}: {1})'
|
||||
.format(type(ex).__name__, ex))
|
||||
ai_error = (ai_error + '; ' + started) if ai_error else started
|
||||
written[-1]['ai_image'] = ai_image
|
||||
written[-1]['ai_error'] = ai_error
|
||||
|
||||
# ------------------------------------------------------- where the copy goes
|
||||
#
|
||||
# Asked AFTER the extraction has run and BEFORE any of it is shown. Owner,
|
||||
# 2026-08-30: "跑完extractdesign,出结果以前,让用户选择存designextraction文件的
|
||||
# 路径。用户也可以打开看到底是什么东西unresolved。"
|
||||
#
|
||||
# A COPY, NOT A MOVE. The spec is already written where the rest of the tool
|
||||
# looks for it: `designspec.list_specs` reads `_ticketqc/specs/` and nothing
|
||||
# else, so a spec saved only into a folder the engineer picked would look
|
||||
# extracted and be invisible to Ticket QC and Panel Lift Check. What this
|
||||
# dialog chooses is a SECOND copy -- to keep, to send, or just to open and
|
||||
# read.
|
||||
#
|
||||
# CANCELLING KEEPS THE WORK. PanelLiftCheck asks the same question and exits
|
||||
# on cancel, which is right there: nothing has been calculated yet, so a
|
||||
# cancel costs nothing. Here the extraction has already run and the files are
|
||||
# already on disk, so exiting would throw away work that only another press
|
||||
# could get back. Cancel therefore means "no second copy" and nothing else --
|
||||
# the results still print, pointing at the canonical file.
|
||||
copies = {}
|
||||
copy_problems = []
|
||||
in_place = []
|
||||
cancelled_save = False
|
||||
|
||||
if written:
|
||||
designs_written = [w['design'] for w in written]
|
||||
# Seeded with wherever the last extraction was saved; the job's data
|
||||
# folder when there is no such memory. Both are real places on this
|
||||
# machine -- a dialog opening on a path that does not exist is how a file
|
||||
# lands somewhere nobody meant. NOT the `specs` folder itself: the
|
||||
# default name is the name the canonical file already has, so opening
|
||||
# there would greet a single-design run with "PNL1.yaml already exists,
|
||||
# replace it?" -- a question about the file it just wrote.
|
||||
start_dir = (specsave.recall_dir(designspec.root_dir(doc))
|
||||
or designspec.root_dir(doc))
|
||||
chosen = forms.save_file(
|
||||
file_ext='yaml', init_dir=start_dir or '',
|
||||
default_name=specsave.default_name(designs_written),
|
||||
title='Save the design extraction as')
|
||||
if not chosen:
|
||||
cancelled_save = True
|
||||
else:
|
||||
by_design = dict([(w['design'], w['path']) for w in written])
|
||||
for design, target in specsave.targets(chosen, designs_written):
|
||||
# Saving onto the file itself. The dialog opens on the folder the
|
||||
# specs already live in, so this is an ordinary thing to do by
|
||||
# accident -- and rewriting a file with its own contents is at
|
||||
# best pointless. Say so instead of claiming a copy that is the
|
||||
# same path twice.
|
||||
if specsave.same_file(by_design[design], target):
|
||||
in_place.append(design)
|
||||
continue
|
||||
try:
|
||||
# Copied as text rather than re-rendered from the spec, so the
|
||||
# copy is character-for-character the file Ticket QC reads. A
|
||||
# second rendering could drift from the first and the two
|
||||
# would look like the same extraction.
|
||||
with io.open(by_design[design], 'r', encoding='utf-8') as fh:
|
||||
body = fh.read()
|
||||
with io.open(target, 'w', encoding='utf-8') as fh:
|
||||
fh.write(body)
|
||||
copies[design] = target
|
||||
except Exception as ex:
|
||||
copy_problems.append('{0}: {1}'.format(design, ex))
|
||||
specsave.remember_dir(designspec.root_dir(doc),
|
||||
os.path.dirname(chosen))
|
||||
|
||||
if written:
|
||||
output.print_md('## Written')
|
||||
output.print_table(
|
||||
[[w['design'], str(w['notes']),
|
||||
str(len(specsave.unresolved_entries(w['spec']))),
|
||||
os.path.basename(w['path'])] for w in written],
|
||||
columns=['design', 'text notes scraped', 'unresolved notes',
|
||||
'spec file'])
|
||||
|
||||
# THE LINKS ARE `file:///` HREFS. A bare Windows path in a markdown link
|
||||
# is a dead link in this panel -- backslashes and no scheme, so it renders
|
||||
# and navigates nowhere. Same fix as PanelLiftCheck: the visible text
|
||||
# stays the real path, because that is what gets copied into Explorer,
|
||||
# and only the href gets the URL form. `specsave.link` is that pattern.
|
||||
for w in written:
|
||||
line = '- **{0}** {1}'.format(w['design'],
|
||||
specsave.link(w['path']))
|
||||
if w['design'] in copies:
|
||||
line += ' — copy: {0}'.format(
|
||||
specsave.link(copies[w['design']]))
|
||||
output.print_md(line)
|
||||
|
||||
if cancelled_save:
|
||||
output.print_md(
|
||||
'> No second copy was saved -- the save dialog was cancelled. '
|
||||
'Nothing was lost: every spec above is on disk at the path shown, '
|
||||
'which is where Ticket QC reads it from.')
|
||||
if in_place:
|
||||
output.print_md(
|
||||
'> {0}: you picked the file the spec is already saved as, so '
|
||||
'nothing further was written. The path above is it.'
|
||||
.format(', '.join(in_place)))
|
||||
if len(written) > 1 and copies:
|
||||
output.print_md(
|
||||
'> More than one design was extracted, so the FOLDER you chose '
|
||||
'was used and each spec kept its own design number as its file '
|
||||
'name -- that name is how everything downstream finds it.')
|
||||
for problem in copy_problems:
|
||||
output.print_md('> The copy could not be written — {0}'
|
||||
.format(problem))
|
||||
|
||||
output.print_md(
|
||||
'Then check the fields the reader did fill against the drawing. '
|
||||
'Ticket QC picks specs up automatically, from the paths above.')
|
||||
|
||||
# --------------------------------------------------------- the AI pass
|
||||
output.print_md('---')
|
||||
output.print_md(
|
||||
'## AI pass (DSV4F, vision) -- chained automatically\n\n'
|
||||
'For each design above, a second, detached pass just launched: it '
|
||||
'reads whatever the mechanical reader left null, empty or '
|
||||
'unresolved, plus the strand matrix the mechanical reader never '
|
||||
'even attempts. It runs on DSV4F with vision, never opus (owner\'s '
|
||||
'ruling, 2026-09-01) -- and it can only ADD to the spec: a field '
|
||||
'the mechanical reader above already filled is never touched. It '
|
||||
'writes back to the SAME spec file above, in place, whenever it '
|
||||
'finishes; if it fails or times out the file is left exactly as '
|
||||
'the mechanical reader wrote it, with one line in `unresolved` '
|
||||
'saying so.')
|
||||
for w in written:
|
||||
bits = ['launched' if w.get('ai_launched') else 'NOT launched']
|
||||
if w.get('ai_image'):
|
||||
bits.append('drawing image rendered')
|
||||
else:
|
||||
bits.append('no drawing image -- text-only pass')
|
||||
output.print_md('- **{0}** — {1}'.format(w['design'],
|
||||
', '.join(bits)))
|
||||
if w.get('ai_error'):
|
||||
output.print_md(' > {0}'.format(w['ai_error']))
|
||||
output.print_md(
|
||||
'**Every value in these spec files remains machine-read and '
|
||||
'unverified** -- the AI pass does not change that, it only changes '
|
||||
'who read which line. Re-open the spec after the AI pass\'s window '
|
||||
'closes and check it against the drawing the same as above, '
|
||||
'especially the strand matrix and anything now stamped '
|
||||
'`dsv4f-vision` in `source.extractor`.')
|
||||
|
||||
# ------------------------------------------- what is unresolved, by name
|
||||
#
|
||||
# "用户也可以打开看到底是什么东西unresolved" -- so the notes themselves are
|
||||
# on the page, verbatim, not a number in a column. A count tells a reader
|
||||
# there is something to look at and nothing about whether to look now.
|
||||
#
|
||||
# Folded by the standing display rule (`vibe.foldreport`, set 2026-08-30):
|
||||
# everything below the answer starts closed, and a section carrying a
|
||||
# problem opens ITSELF. Unresolved notes are exactly that case, so a
|
||||
# design with any opens on its own; a design with none stays a one-line
|
||||
# header. The contract that buys is: if nothing here is open, nothing on
|
||||
# this page needs you.
|
||||
sections = []
|
||||
for w in written:
|
||||
entries = specsave.unresolved_entries(w['spec'])
|
||||
blanks = specsave.blank_fields(w['spec'])
|
||||
problems = designspec.validate_spec(w['spec'])
|
||||
section = fold.Section(
|
||||
key='unresolved-' + w['design'],
|
||||
# A plain dash. `foldreport._section_html` now restores the
|
||||
# reports' entities in a title the way it always did for a table
|
||||
# cell or a markdown block (fixed 2026-08-30, after this note was
|
||||
# written), so `—` would render here too -- but a plain dash
|
||||
# is right in the panel AND in the .md, so it stays.
|
||||
title='{0} - what the reader could not place'.format(w['design']),
|
||||
summary=specsave.summary_line(entries, blanks),
|
||||
problem=specsave.problem_sentence(entries, problems))
|
||||
reason = specsave.shared_reason(entries)
|
||||
if entries:
|
||||
# The mechanical reader gives every unplaced note the same reason.
|
||||
# Printed once it is an explanation; printed on all fourteen lines
|
||||
# it is what stops the list being read.
|
||||
if reason:
|
||||
section.md('_{0}_'.format(reason))
|
||||
for i, entry in enumerate(entries):
|
||||
text, why = entry
|
||||
line = '**{0}.** {1}'.format(i + 1, text)
|
||||
if why and not reason:
|
||||
line += ' — {0}'.format(why)
|
||||
section.md(line)
|
||||
else:
|
||||
section.md('Every text note on this design summary matched a '
|
||||
'rule. Nothing was dropped and nothing was left over.')
|
||||
if blanks:
|
||||
section.md(
|
||||
'**Still blank** — the summary filled none of these, '
|
||||
'and a check that reads one of them does not run: {0}'
|
||||
.format(', '.join(['`' + b + '`' for b in blanks])))
|
||||
if problems:
|
||||
section.md('**Validation:** ' + '; '.join(problems))
|
||||
# PLAIN PATH INSIDE THE FOLD, NOT A MARKDOWN LINK. `foldreport.md_html`
|
||||
# converts bold, code and italic and nothing else -- a markdown link
|
||||
# written here reaches the panel as the literal text `[path](file:///
|
||||
# ...)`, which is worse than no link at all. Measured on stubs,
|
||||
# 2026-08-30. The CLICKABLE link is in the list above, printed through
|
||||
# pyRevit's own markdown, which does render one.
|
||||
section.md('The spec: `{0}` (clickable in the list above)'
|
||||
.format(w['path']))
|
||||
if w['design'] in copies:
|
||||
section.md('Your copy: `{0}`'.format(copies[w['design']]))
|
||||
sections.append(section)
|
||||
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(sections)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md(
|
||||
'**{0} of the {1} design(s) below opened themselves** — '
|
||||
'they have notes the reader could not place. The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(sections)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every note on every summary matched '
|
||||
'a rule. The sections are folded; click a header to open one.')
|
||||
output.print_html(fold.html(sections))
|
||||
|
||||
if skipped:
|
||||
output.print_md('## Skipped')
|
||||
for design, why in skipped:
|
||||
output.print_md('- **{0}** - {1}'.format(design, why))
|
||||
|
||||
# ---------------------------------------------- review specs already on disk
|
||||
if existing:
|
||||
output.print_md('## Specs already on disk')
|
||||
rows = []
|
||||
for design in sorted(existing):
|
||||
spec = existing[design]
|
||||
problems = designspec.validate_spec(spec)
|
||||
row_cells = [design,
|
||||
spec.get('source', {}).get('extractor') or '?',
|
||||
spec.get('source', {}).get('reviewed_by') or 'NOT reviewed',
|
||||
'ok' if not problems else '; '.join(problems[:2]),
|
||||
str(len(spec.get('unresolved') or []))]
|
||||
# A spec on disk feeds Ticket QC whether this press wrote it or an
|
||||
# earlier one did, so the label belongs on the whole folder, not
|
||||
# only on what was extracted just now.
|
||||
if the_scope.is_declared:
|
||||
row_cells.append('OUT OF SCOPE' if out_of_scope(design) else '-')
|
||||
rows.append(row_cells)
|
||||
columns = ['design', 'extractor', 'reviewed by', 'validation',
|
||||
'unresolved notes']
|
||||
if the_scope.is_declared:
|
||||
columns.append('scope')
|
||||
output.print_table(rows, columns=columns)
|
||||
if the_scope.is_declared:
|
||||
output.print_md(
|
||||
'`scope` marks a spec whose design number falls outside {0}. '
|
||||
'Nothing is hidden and nothing is deleted -- Ticket QC will use '
|
||||
'these specs if it is pointed at those pieces, and the label is '
|
||||
'so that it is a decision rather than an accident.'
|
||||
.format(the_scope.label()))
|
||||
output.print_md(
|
||||
'`reviewed by` stays NOT reviewed until a person sets '
|
||||
'`source.reviewed_by` in the spec file by hand, which is the only '
|
||||
'thing that ever marks a spec as checked.')
|
||||
output.print_md(
|
||||
'`extractor` says which reader wrote the file: `regex` is the '
|
||||
'mechanical reader this button runs, `dsv4f-vision` is the second, '
|
||||
'AI pass this button now chains after it (`tools/aiextract_run.py`, '
|
||||
'DSV4F with vision -- owner\'s ruling, 2026-09-01), and `opus` / '
|
||||
'`claude-code` are what an earlier AI reader stamped before that '
|
||||
'ruling -- older files, read the same way. Nothing branches on the '
|
||||
'value; it is shown so a spec written by one reader is not mistaken '
|
||||
'for a spec written by another.')
|
||||
|
||||
# The extraction ran and everything is on the page. The record is 'ok'
|
||||
# with a short summary; record() never raises, so a logging failure cannot
|
||||
# turn a finished run into an error.
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='ok',
|
||||
report_text=('{0} design(s) written, {1} skipped; saved '
|
||||
'copy: {2}'
|
||||
.format(len(written), len(skipped),
|
||||
chosen if not cancelled_save else 'none')))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Design Extract', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,8 @@
|
||||
title: Hot Reload
|
||||
# No tooltip and no author on purpose -- the owner wants nothing on hover,
|
||||
# and test_button_tooltips.py enforces it. Document the button in comments
|
||||
# in script.py instead.
|
||||
#
|
||||
# One line: this is a row in a `.pulldown` (text beside a 16px icon), so a
|
||||
# `\n` has nowhere to go. The dropdown grows to fit, so the title may read
|
||||
# in full.
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Hot Reload -- stop the WillTools routes bridge cleanly, THEN reload pyRevit.
|
||||
#
|
||||
# Why the stop must come first: the routes HTTP dispatch is wired to a module-
|
||||
# global external event handler created once, on the host main thread, when
|
||||
# `pyrevit/routes/server/server.py` is first imported (server.py:35-36), and it
|
||||
# is NOT recreated across a reload -- pyRevit re-imports extension scripts, not
|
||||
# its own lib. A request in flight when the engine is torn down runs its handler
|
||||
# against a dead engine -> unhandled exception on a non-main thread -> Revit dies
|
||||
# uncatchably (measured 2026-08-14; see startup.py and CLAUDE.md "Never reload").
|
||||
# Stopping the HTTP server first closes that crash window. On reload, startup.py
|
||||
# re-registers the routes and a fresh server comes back up automatically.
|
||||
#
|
||||
# If the server will NOT stop cleanly, this ABORTS rather than reloads -- a
|
||||
# reload with a live bridge is the exact crash, so a bad stop must never fall
|
||||
# through to it.
|
||||
#
|
||||
# IronPython 2.7: no f-strings. No module docstring / tooltip / author
|
||||
# (test_button_tooltips.py).
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
from pyrevit import EXEC_PARAMS
|
||||
from pyrevit import routes
|
||||
from pyrevit import forms
|
||||
from pyrevit import script
|
||||
from pyrevit.loader import sessionmgr
|
||||
from pyrevit.loader import sessioninfo
|
||||
|
||||
logger = script.get_logger()
|
||||
|
||||
go = True
|
||||
if EXEC_PARAMS.executed_from_ui:
|
||||
go = forms.alert(
|
||||
'Hot Reload stops the WillTools bridge, then reloads pyRevit.\n\n'
|
||||
'Run this only when no bridge request is in flight -- no sweep, no '
|
||||
'agent mid-exec. A request raised the instant before this click cannot '
|
||||
'be cancelled and can still take Revit down.\n\nReload now?',
|
||||
ok=False, yes=True, no=True)
|
||||
|
||||
if go:
|
||||
stopped_clean = True
|
||||
try:
|
||||
routes.deactivate_server()
|
||||
except Exception as ex:
|
||||
stopped_clean = False
|
||||
logger.error('deactivate_server raised: {0}'.format(ex))
|
||||
|
||||
# Verify the socket is actually gone before tearing the engine down. A
|
||||
# deactivate that "succeeded" but left the server up is still unsafe.
|
||||
try:
|
||||
if routes.get_active_server() is not None:
|
||||
stopped_clean = False
|
||||
except Exception as ex:
|
||||
stopped_clean = False
|
||||
logger.error('get_active_server raised: {0}'.format(ex))
|
||||
|
||||
if not stopped_clean:
|
||||
forms.alert(
|
||||
'Could not stop the WillTools bridge cleanly.\n\n'
|
||||
'Do NOT reload with a live bridge -- restart Revit instead of '
|
||||
'reloading. (A reload while the routes handler is live is what '
|
||||
'crashes Revit.)', ok=True)
|
||||
else:
|
||||
# Vouch to tools/rvt.py that THIS reload is a clean stop-first one, so
|
||||
# the reloaded bridge is not refused as a raw-reload landmine. startup.py
|
||||
# consumes this marker on the very next load and stamps that session
|
||||
# 'hotreload'. Keyed by pid: a reload is in-process, so the same Revit
|
||||
# picks it up and no other session can. Best-effort -- if it cannot be
|
||||
# written the only cost is rvt.py refusing the reloaded bridge (the safe
|
||||
# default), never a failed reload.
|
||||
try:
|
||||
import os as _os
|
||||
from System.Diagnostics import Process as _Process
|
||||
_here = _os.path.abspath(__file__).replace('\\', '/')
|
||||
_mark = _here.find('/WillTools.extension/')
|
||||
if _mark != -1:
|
||||
_root = _here[:_mark]
|
||||
else:
|
||||
_root = _os.path.join(_os.path.expanduser('~'), 'pyrevit-dev')
|
||||
_pid = _Process.GetCurrentProcess().Id
|
||||
_flagpath = _os.path.join(
|
||||
_root, '.bridge-hotreload-{0}.flag'.format(_pid))
|
||||
_fh = open(_flagpath, 'w')
|
||||
_fh.write('hotreload\n')
|
||||
_fh.close()
|
||||
except Exception as ex:
|
||||
logger.warning('could not write hot-reload marker: {0}'.format(ex))
|
||||
|
||||
logger.info('WillTools bridge stopped. Reloading pyRevit...')
|
||||
sessionmgr.reload_pyrevit()
|
||||
# Mirrors the shipped Reload button: record the new session id.
|
||||
script.get_results().newsession = sessioninfo.get_session_uuid()
|
||||
@@ -0,0 +1,12 @@
|
||||
title: Job Recon
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# Back on the ribbon on 2026-08-30 after a day in `parked/`. Nothing about
|
||||
# the tool changed while it was out: only its ribbon entry was ever parked,
|
||||
# and `vibe.jobrecon` / `vibe.jobreconscan` and their tests never moved.
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Reconcile the issued ticket set against the whole job.
|
||||
#
|
||||
# Every other check on this panel judges one ticket at a time, and no amount of
|
||||
# per-ticket checking can find a ticket that is not there. This button asks the
|
||||
# question about the SET: every piece printed on something, everything printed
|
||||
# about a piece that exists, and the counts reconciling mark by mark.
|
||||
#
|
||||
# It reads. It never writes, and it opens no transaction.
|
||||
#
|
||||
# THE SHAPE OF THE PAGE, AND WHOSE IT IS. Owner, 2026-08-30, now standing
|
||||
# across this tab: the answer is the headline and is never folded; everything
|
||||
# else follows it, collapsed; a section that carries a problem opens itself.
|
||||
# The reader's contract is that if nothing below the headline is open, nothing
|
||||
# needs him.
|
||||
#
|
||||
# Here the answer is the VERDICT IN NUMBERS -- pieces and tickets accounted
|
||||
# for, and a count per finding -- not the findings themselves. On a job with
|
||||
# four hundred uncovered pieces the lists are thousands of lines and the
|
||||
# verdict is invisible inside them. So:
|
||||
#
|
||||
# * the headline is the totals and the finding counts, printed through
|
||||
# pyRevit's own markdown and tables;
|
||||
# * every non-empty finding is a section below it, OPEN, because there is no
|
||||
# finding in this reconciliation that is merely interesting;
|
||||
# * coverage by design, the page-set inventory, the categories swept, the
|
||||
# checks that found nothing and the timings are folded;
|
||||
# * and the two sections that say what this run could NOT see -- out of
|
||||
# scope, and blind spots -- are separate, always, and carry their counts
|
||||
# on their headers with the fold shut. A blind spot that is a REFUSAL
|
||||
# (nothing reconciled) opens itself and is repeated in the headline,
|
||||
# because a refusal reported quietly reads as a clean job.
|
||||
#
|
||||
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED. This output panel is a
|
||||
# `System.Windows.Forms.WebBrowser` -- the IE engine, in IE7 document mode.
|
||||
# `<details>` does not exist there and fails SILENTLY. The fold is an inline
|
||||
# `onclick` plus `style.display`, which is IE5-era DOM; a section with a
|
||||
# problem in it renders open as plain HTML and needs no script at all; and the
|
||||
# whole report, nothing folded and nothing capped, is written to
|
||||
# `last-report.txt` beside the model's other job data on every press, with the
|
||||
# page saying where. A fold that will not open therefore costs a file open,
|
||||
# never a lost sentence. All of that lives in `vibe.foldreport`.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Job Recon'
|
||||
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script
|
||||
|
||||
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
|
||||
from vibe import foldreport as fold
|
||||
from vibe import runlog
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
REQUIRED = ('member.control_number', 'sheet.control_numbers')
|
||||
|
||||
# The cap on each list ON THE PAGE. The file written beside the model has no
|
||||
# cap at all -- a page is read, a file is searched.
|
||||
PAGE_LIMIT = 60
|
||||
|
||||
HOW_TO_MAP = """
|
||||
This reconciliation needs roles the older checks never used. Add them to the
|
||||
plant knowledge file (the layer that describes the office TEMPLATE), naming
|
||||
whatever your title block and your pieces actually call these things:
|
||||
|
||||
parameters:
|
||||
member:
|
||||
control_number: <the piece's control number>
|
||||
ticket_created: <optional -- the model's own 'a ticket exists' flag>
|
||||
ticket_detailed: <optional>
|
||||
sheet:
|
||||
total_required: <the quantity the title block states>
|
||||
sheet_count: <how many pages the ticket says it has>
|
||||
# either list the control-number slots explicitly ...
|
||||
control_numbers: [<slot 1>, <slot 2>, ...]
|
||||
|
||||
nomenclature:
|
||||
# ... or, for numbered slots, match them with one expression:
|
||||
control_slot_re: '^<PREFIX>_\\d+$'
|
||||
|
||||
Until then this button refuses rather than reporting a job it cannot see.
|
||||
"""
|
||||
|
||||
|
||||
def write_full(recon, full_path):
|
||||
"""The whole report, unfolded, beside the model. Returns True if written.
|
||||
|
||||
Written BEFORE anything is printed, so the file exists even if the
|
||||
printing throws. `as_text` first and encode second: under IronPython 2.7
|
||||
the report is a byte string and a mark carrying a non-ASCII character
|
||||
would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
||||
"""
|
||||
payload = compat.as_text(
|
||||
'\n'.join(jobrecon.format_report(recon, full_path=full_path)))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
for line in problems:
|
||||
output.print_md('**' + line + '**')
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
|
||||
report_text=('\n'.join(problems) +
|
||||
'\n\nJob Recon cannot run until the '
|
||||
'configuration is fixed.'))
|
||||
return
|
||||
|
||||
names = knowledge.param_names(know, 'member.control_number')
|
||||
slots = (knowledge.param_names(know, 'sheet.control_numbers')
|
||||
or knowledge.get(know, 'nomenclature.control_slot_re', None))
|
||||
if not names or not slots:
|
||||
output.print_md('## Job Recon cannot run')
|
||||
output.print_md('Nothing was reconciled, and that is **not** a clean '
|
||||
'result -- it is a refusal.')
|
||||
print(HOW_TO_MAP)
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
|
||||
report_text=('Job Recon cannot run: the control-number '
|
||||
'roles are not mapped.\n' + HOW_TO_MAP))
|
||||
return
|
||||
|
||||
recon = jobreconscan.run(doc, know=know)
|
||||
|
||||
full_path = os.path.join(designspec.sub_dir(doc, 'jobrecon'),
|
||||
'last-report.txt')
|
||||
written = False
|
||||
try:
|
||||
written = write_full(recon, full_path)
|
||||
except Exception as exc:
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold '
|
||||
'headers.'.format(exc))
|
||||
|
||||
sections = jobrecon.render(recon, limit=PAGE_LIMIT,
|
||||
full_path=full_path if written else None)
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded.
|
||||
tail = sections[1:]
|
||||
if not tail:
|
||||
# The headline alone was the whole page -- recorded the same as a
|
||||
# full page, with the file copy's path when one was written.
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
|
||||
report_path=full_path if written else None,
|
||||
report_text='Job Recon ran -- nothing below the headline '
|
||||
'to report.')
|
||||
return
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md('**Nothing below needs you.** Every section is folded; '
|
||||
'click a header to open one.')
|
||||
if written:
|
||||
output.print_md('The whole report, nothing folded and nothing capped, '
|
||||
'is in `{0}`.'.format(full_path))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
# The page is complete and the file copy exists. The verdict in numbers
|
||||
# is the headline, which `sections[0]` carried as markdown -- this short
|
||||
# summary says what reconciled, and points at the file for the rest.
|
||||
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
|
||||
report_path=full_path if written else None,
|
||||
report_text='Job Recon ran; full report at {0}'
|
||||
.format(full_path if written else 'this page'))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Job Recon', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,167 @@
|
||||
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Project Config"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SizeToContent="Height"
|
||||
Width="880" MaxHeight="940" MinHeight="380"
|
||||
ResizeMode="CanResize"
|
||||
Background="#f6f7f9">
|
||||
|
||||
<!--
|
||||
THE WINDOW IS CHROME ONLY. Every field is built in `script.py` from
|
||||
`vibe.configform`, because the field list is data: it depends on which
|
||||
build of `configmodel` this Revit session is holding, on which stages
|
||||
the job has, and on how many checklist items are muted. A XAML
|
||||
DataTemplate would have had to know all three.
|
||||
|
||||
What lives here is what does not change: the four module boxes, the
|
||||
reason line every job-layer write needs, and the four buttons. Every
|
||||
x:Name below is looked up by name in `script.py`, and every Click
|
||||
handler names a method on the window class. `tests/test_configform.py`
|
||||
parses this file and that one together and fails if the two have
|
||||
drifted, which is the only check available for a window that cannot be
|
||||
opened without Revit.
|
||||
|
||||
EVERY MODULE IS AN Expander, NOT A GroupBox, AND STARTS COLLAPSED. The
|
||||
owner's ruling, 2026-09-01, verbatim in `script.py`'s own comment (kept
|
||||
out of THIS file because the no-Chinese-in-the-XAML test below scans
|
||||
every byte of it, comments included, and not only the strings a build
|
||||
would render): every section folds, and folded is the default; open
|
||||
the one you came to change, adjust it, done. `IsExpanded="False"` here
|
||||
is the fallback for the very first time this window opens in a
|
||||
session; `script.py` overrides it per module from
|
||||
`vibe.configform.remembered_expanded`, which is the ONLY thing that
|
||||
may reopen one on its own, since a module the owner left open a minute
|
||||
ago staying open is a convenience, and a module opening because it
|
||||
carries a value is not this window's call to make silently, which is
|
||||
why the header text says so instead (see `configform.is_modified` /
|
||||
`modified_count`, and `script.py`'s use of them building each
|
||||
Header).
|
||||
|
||||
No double hyphen anywhere in this comment: XML forbids it inside a
|
||||
comment, and the test above catches it as a parse error rather than as
|
||||
a window that will not load.
|
||||
-->
|
||||
|
||||
<Window.Resources>
|
||||
<!-- No FontWeight or Foreground here: a GroupBox passes both down to
|
||||
its CONTENT, so setting them to style the header would make every
|
||||
label and every note in the box bold as well. The rows set their
|
||||
own weight and colour in script.py. -->
|
||||
<Style TargetType="{x:Type GroupBox}">
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="Padding" Value="6"/>
|
||||
<Setter Property="BorderBrush" Value="#c9d2dd"/>
|
||||
</Style>
|
||||
<!-- The four modules are Expanders now, not GroupBoxes; see the
|
||||
note above this Window's own opening comment. Same border,
|
||||
same spacing, so folding cost nothing visually except the
|
||||
fold itself. -->
|
||||
<Style TargetType="{x:Type Expander}">
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="Padding" Value="6"/>
|
||||
<Setter Property="BorderBrush" Value="#c9d2dd"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Background" Value="White"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type Button}">
|
||||
<Setter Property="Padding" Value="10,3,10,3"/>
|
||||
<Setter Property="MinWidth" Value="76"/>
|
||||
<Setter Property="Margin" Value="0,0,6,0"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Padding" Value="3,2,3,2"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel Margin="14">
|
||||
|
||||
<!-- The sentence that used to be cut off by a 36-pixel Label. It
|
||||
wraps here, because there is room here. -->
|
||||
<TextBlock x:Name="header"
|
||||
DockPanel.Dock="Top"
|
||||
TextWrapping="Wrap"
|
||||
FontWeight="Bold"
|
||||
Foreground="#2c3e50"
|
||||
Margin="0,0,0,4"
|
||||
Text="Project Config"/>
|
||||
<TextBlock x:Name="subheader"
|
||||
DockPanel.Dock="Top"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#5a6b7d"
|
||||
Margin="0,0,0,10"
|
||||
Text=""/>
|
||||
|
||||
<Border DockPanel.Dock="Bottom"
|
||||
Background="#eef1f5"
|
||||
BorderBrush="#c9d2dd"
|
||||
BorderThickness="1"
|
||||
Padding="8"
|
||||
Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="status"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#2c3e50"
|
||||
Margin="0,0,0,6"
|
||||
Text="Nothing changed yet."/>
|
||||
<DockPanel Margin="0,0,0,8">
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
VerticalAlignment="Center"
|
||||
Width="150"
|
||||
Text="Why (written into the file)"/>
|
||||
<TextBox x:Name="reason_box" Height="24"/>
|
||||
</DockPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="save_button"
|
||||
Content="Save to the job layer"
|
||||
MinWidth="150"
|
||||
Click="save_click"/>
|
||||
<Button x:Name="cancel_button"
|
||||
Content="Close"
|
||||
Click="cancel_click"/>
|
||||
<Button x:Name="openfile_button"
|
||||
Content="Open the job file"
|
||||
MinWidth="130"
|
||||
Click="openfile_click"/>
|
||||
<Button x:Name="fullview_button"
|
||||
Content="Full view"
|
||||
Click="fullview_click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel>
|
||||
<Expander x:Name="group_file_path" Header="File paths"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_file_path"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_lifting" Header="Lifting and handling"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_lifting"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_ticketqc" Header="Ticket QC"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_ticketqc"/>
|
||||
</Expander>
|
||||
<Expander x:Name="group_job_knowledge" Header="Job knowledge"
|
||||
IsExpanded="False">
|
||||
<StackPanel x:Name="panel_job_knowledge"/>
|
||||
</Expander>
|
||||
<Border Background="#fff8e6"
|
||||
BorderBrush="#e0c98a"
|
||||
BorderThickness="1"
|
||||
Padding="8">
|
||||
<TextBlock x:Name="rest_line"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#6b5a20"
|
||||
Text="Everything else: edit the config file directly."/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,29 @@
|
||||
title: Project Config
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom. Second-hottest button on this ribbon --
|
||||
# 14 commits to configmodel/configpage/configform/configdump/knowledge in the
|
||||
# seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# "Project Config" rather than plain "Config": the button one level up is
|
||||
# already called Config, and a Config row inside a Config button reads like
|
||||
# a mistake even though the two name tables never collide.
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,8 @@
|
||||
title: Self Check
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Report whether this machine, this Revit and this model can run the QC tools.
|
||||
#
|
||||
# Written for the moment the extension lands on a computer it was not developed
|
||||
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
|
||||
# so a failure names itself instead of arriving as a traceback from four frames
|
||||
# deep in a checking routine.
|
||||
#
|
||||
# Reads nothing but metadata and does not touch the model.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Self Check'
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, HOST_APP
|
||||
|
||||
from vibe import runlog
|
||||
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
doc = revit.doc
|
||||
rows = []
|
||||
|
||||
|
||||
def report(area, item, state, detail):
|
||||
rows.append([area, item, state, detail])
|
||||
|
||||
|
||||
def probe(area, item, fn, missing='FAIL'):
|
||||
"""Run one probe. An exception is a finding, never a crash."""
|
||||
try:
|
||||
state, detail = fn()
|
||||
report(area, item, state or missing, detail)
|
||||
except Exception as ex:
|
||||
# Name the frame that blew, not just the exception: a bare
|
||||
# "SystemError: Object reference not set to an instance of an object"
|
||||
# (IronPython's face for a .NET NullReferenceException) says nothing
|
||||
# about WHERE, and 2026-09-05 one of these cost a round trip to the
|
||||
# owner's machine to find out. Innermost frame of our own code first.
|
||||
where = ''
|
||||
try:
|
||||
import traceback
|
||||
frames = traceback.extract_tb(sys.exc_info()[2])
|
||||
ours = [f for f in frames if 'WillTools' in (f[0] or '')] or frames
|
||||
if ours:
|
||||
fn, ln, func, _ = ours[-1]
|
||||
where = ' @ {0}:{1} in {2}()'.format(fn.replace('\\', '/').split('/')[-1], ln, func)
|
||||
except Exception:
|
||||
pass
|
||||
report(area, item, missing, '{0}: {1}{2}'.format(type(ex).__name__, ex, where))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- host
|
||||
|
||||
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
|
||||
|
||||
|
||||
def _revit():
|
||||
app = HOST_APP.app
|
||||
version = app.VersionNumber
|
||||
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
|
||||
if version == DEVELOPED_ON['revit']:
|
||||
return 'OK', detail
|
||||
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
|
||||
DEVELOPED_ON['revit'])
|
||||
|
||||
|
||||
def _pyrevit():
|
||||
from pyrevit import versionmgr
|
||||
version = versionmgr.get_pyrevit_version().get_formatted()
|
||||
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
|
||||
return state, version
|
||||
|
||||
|
||||
def _engine():
|
||||
detail = sys.version.split('\n')[0]
|
||||
major = sys.version_info[0]
|
||||
if major == 2:
|
||||
return 'OK', detail
|
||||
# The code is written for both, but only Python 2 has been exercised.
|
||||
return 'WARN', detail + ' (only Python 2 has been run in anger)'
|
||||
|
||||
|
||||
def _routes():
|
||||
from pyrevit.userconfig import user_config
|
||||
if not user_config.routes_server:
|
||||
return 'WARN', ('routes server off - the Claude Code bridge will not '
|
||||
'connect. The buttons themselves do not need it.')
|
||||
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
|
||||
user_config.routes_port)
|
||||
|
||||
|
||||
probe('host', 'Revit', _revit)
|
||||
probe('host', 'pyRevit', _pyrevit)
|
||||
probe('host', 'python engine', _engine)
|
||||
probe('host', 'routes bridge', _routes, missing='WARN')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- library
|
||||
|
||||
def _import_all():
|
||||
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
|
||||
ticketqc, ticketchecks, aiextract)
|
||||
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
|
||||
'ticketchecks, aiextract'
|
||||
|
||||
|
||||
def _units():
|
||||
from vibe import ft, mm
|
||||
round_trip = mm(ft(1000.0))
|
||||
if abs(round_trip - 1000.0) > 1e-6:
|
||||
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
|
||||
return 'OK', '1000 mm round-trips exactly'
|
||||
|
||||
|
||||
def _checklist():
|
||||
from vibe import ticketpage
|
||||
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
|
||||
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
|
||||
return 'OK', '{0} row(s) from {1}'.format(
|
||||
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
|
||||
|
||||
|
||||
probe('library', 'modules import', _import_all)
|
||||
probe('library', 'unit conversion', _units)
|
||||
probe('library', 'published checklist', _checklist, missing='WARN')
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- tooling
|
||||
|
||||
def _claude():
|
||||
from vibe import aiextract
|
||||
found = aiextract.find_claude()
|
||||
if not found:
|
||||
return 'WARN', ('not found - Extract Design cannot run. Install the '
|
||||
'CLI or set CLAUDE_CODE_EXE. Ticket QC is unaffected.')
|
||||
return 'OK', found
|
||||
|
||||
|
||||
probe('tooling', 'Claude Code CLI', _claude, missing='WARN')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- model
|
||||
|
||||
def _saved():
|
||||
if not doc.PathName:
|
||||
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
|
||||
return 'OK', doc.PathName
|
||||
|
||||
|
||||
def _data_dir():
|
||||
from vibe import designspec
|
||||
return 'OK', designspec.root_dir(doc)
|
||||
|
||||
|
||||
def _knowledge():
|
||||
"""Every knowledge layer, in the order load() merges them.
|
||||
|
||||
Which layers exist decides what the tool actually believes, so the answer
|
||||
is the list, not a yes/no: naming conventions that should be plant-wide
|
||||
sitting only in a project file is a real and invisible mistake.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
layers = knowledge.sources(doc)
|
||||
detail = ' | '.join(
|
||||
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
|
||||
for label, path, ok in layers])
|
||||
|
||||
# A project config that has gone missing is worse than one never set: the
|
||||
# run looks entirely normal and uses a configuration nobody chose.
|
||||
gone = [path for label, path, ok in layers
|
||||
if label == 'project config' and not ok]
|
||||
if gone:
|
||||
return 'FAIL', ('the project config this model runs on is not there: '
|
||||
'{0} || {1}'.format(gone[0], detail))
|
||||
if not any(ok for _label, _p, ok in layers):
|
||||
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _knowledge_parses():
|
||||
"""Read every layer file that IS there, exactly the way load() reads it.
|
||||
|
||||
The layer list above says which files exist. It does not say whether any
|
||||
of them can be READ, and those are different questions with the same
|
||||
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
|
||||
mapping raises `Duplicate key` and it returns nothing -- and a layer that
|
||||
fails to load looks exactly like a layer that agrees with the defaults.
|
||||
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
|
||||
layer, every parameter role came back None, the sweep enumerated 0
|
||||
assemblies, and every report rendered normally.
|
||||
|
||||
So this re-reads each file through the same reader and prints ok or the
|
||||
parser's own message. `knowledge.config_problems()` reports the same
|
||||
failure from the merge itself; this is the early warning, on the layer
|
||||
list, before a button is pressed. `python tools/knowledgelint.py` is the
|
||||
same check on the host, where the edit is made.
|
||||
"""
|
||||
from pyrevit.coreutils import yaml as pyyaml
|
||||
from vibe import knowledge
|
||||
|
||||
notes = []
|
||||
bad = []
|
||||
for label, path, ok in knowledge.sources(doc):
|
||||
if not ok:
|
||||
notes.append('{0}: absent'.format(label))
|
||||
continue
|
||||
try:
|
||||
pyyaml.load_as_dict(path)
|
||||
notes.append('{0}: ok'.format(label))
|
||||
except Exception as ex:
|
||||
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
|
||||
label, path, type(ex).__name__, ex))
|
||||
if bad:
|
||||
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
|
||||
'was used, and the run fell back to the layers under '
|
||||
'it. Fix the file, then run '
|
||||
'tools/knowledgelint.py to confirm.')
|
||||
if not notes:
|
||||
return 'WARN', 'no knowledge layer to read yet'
|
||||
return 'OK', ' | '.join(notes)
|
||||
|
||||
|
||||
def _specs():
|
||||
from vibe import designspec
|
||||
specs = designspec.list_specs(doc)
|
||||
if not specs:
|
||||
return 'WARN', 'none yet - run Extract Design'
|
||||
unreviewed = [d for d, s in specs.items()
|
||||
if not (s.get('source') or {}).get('reviewed_by')]
|
||||
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
|
||||
if unreviewed:
|
||||
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _scope():
|
||||
"""What the job declares this engineer is answerable for.
|
||||
|
||||
Never a FAIL. An undeclared scope is a legitimate state -- it means every
|
||||
button judges every piece -- and it is reported as such rather than as a
|
||||
misconfiguration, because 'judge everything' is the honest default and
|
||||
the only wrong answer here would be a scope the tool chose for itself.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
from vibe import scope as scopelib
|
||||
the_scope = scopelib.of(knowledge.load(doc))
|
||||
if not the_scope.is_declared:
|
||||
return 'WARN', ('no scope declared -- every button judges every '
|
||||
'piece on the job, including work whose plant '
|
||||
'knowledge has never been validated. Set '
|
||||
'scope.mark_prefixes in the job knowledge file to '
|
||||
'be told what is being taken on trust.')
|
||||
detail = the_scope.label()
|
||||
if the_scope.declared_by:
|
||||
detail += ' (declared by {0})'.format(the_scope.declared_by)
|
||||
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
|
||||
|
||||
|
||||
probe('model', 'saved', _saved)
|
||||
probe('model', 'declared scope', _scope, missing='WARN')
|
||||
probe('model', 'data folder', _data_dir)
|
||||
probe('model', 'knowledge layers', _knowledge, missing='WARN')
|
||||
probe('model', 'knowledge layers parse', _knowledge_parses)
|
||||
probe('model', 'design specs', _specs, missing='WARN')
|
||||
|
||||
|
||||
# --------------------------------------------------- project conventions
|
||||
# These are the assumptions most likely to be wrong on a different job: they
|
||||
# are naming and parameter conventions, not Revit API.
|
||||
|
||||
def _mapping():
|
||||
"""Which parameter roles this office has told us the names for.
|
||||
|
||||
Nothing in the code knows what a panel's length parameter is called, so
|
||||
an unmapped role is not a detail -- it is a check that will not run. It
|
||||
reports before the convention probes below, because they depend on it.
|
||||
"""
|
||||
from vibe import knowledge, designspec
|
||||
know = knowledge.load(doc)
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
total = len(knowledge.PARAMETER_ROLES)
|
||||
if missing:
|
||||
# The template is the plant's, so that is where the names belong; a
|
||||
# project file may still override one.
|
||||
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
|
||||
len(missing), total, ', '.join(missing),
|
||||
knowledge.plant_file_in_use(know),
|
||||
'; {0} declared not applicable: {1}'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
if not_applicable else '')
|
||||
mapped = ['{0}={1}'.format(r.split('.')[-1],
|
||||
knowledge.param_names(know, r)[0])
|
||||
for r in knowledge.PARAMETER_ROLES
|
||||
if knowledge.param_names(know, r)]
|
||||
extra = ''
|
||||
if not_applicable:
|
||||
extra = '; {0} declared not applicable at this plant ({1})'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
|
||||
|
||||
|
||||
def _plant_gaps():
|
||||
"""Plant-owned data the buttons need, checked at setup time.
|
||||
|
||||
The per-button refusals gate on `handling.lifter_groups`,
|
||||
`handling.stages` and friends, so a gap is normally found by the user
|
||||
as a per-piece NOT RUN. This probe runs the same gate off the merged
|
||||
tree and says which plant file to edit, so the developer finds it here
|
||||
instead -- see knowledge.plant_layer_gaps for the wording.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
know = knowledge.load(doc)
|
||||
gaps = knowledge.plant_layer_gaps(know)
|
||||
if not gaps:
|
||||
return 'OK', 'plant layer names everything the buttons need: {0}'.format(
|
||||
knowledge.plant_file_in_use(know))
|
||||
texts = [g['text'] for g in gaps]
|
||||
state = 'FAIL' if any(g['severity'] == 'FAIL' for g in gaps) else 'WARN'
|
||||
return state, '{0} gap(s): {1}'.format(len(gaps), ' || '.join(texts))
|
||||
|
||||
|
||||
def _designs():
|
||||
from vibe import ticketqc
|
||||
from vibe import knowledge
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
|
||||
if not grouped:
|
||||
# Two states that used to share one FAIL (issue #20). A model with
|
||||
# NO assemblies at all is a job that has not reached the reinforcing
|
||||
# stage -- owner, 2026-09-05: "还没进入reinforce阶段,所以就是没有
|
||||
# assembly。这个情况下这玩意应该是warn不是fail" -- so that is a WARN.
|
||||
# Assemblies present but none carrying a design number is the
|
||||
# convention failure this probe exists for.
|
||||
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
|
||||
n_asm = FilteredElementCollector(doc).OfClass(AssemblyInstance).GetElementCount()
|
||||
if n_asm == 0:
|
||||
return 'WARN', ('no assemblies in this model yet - the job has not '
|
||||
'reached the reinforcing stage; the design-number '
|
||||
'grouping is checked once assemblies exist')
|
||||
return 'FAIL', ('{0} assemblies, none carries a design number - either '
|
||||
'the panel.design_number role is mapped to the wrong '
|
||||
'parameter, or this model does not group pieces so'
|
||||
.format(n_asm))
|
||||
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
|
||||
return 'OK', '{0} designs, {1} pieces: {2}'.format(
|
||||
len(grouped), sum([len(v) for v in grouped.values()]),
|
||||
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
|
||||
|
||||
|
||||
def _summaries():
|
||||
from vibe import ticketqc
|
||||
summaries, sheets = ticketqc.collect_context(doc)
|
||||
if not summaries:
|
||||
return 'FAIL', ('no drafting view is named after a design number - '
|
||||
'Extract Design has nothing to read')
|
||||
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
|
||||
len(sheets))
|
||||
|
||||
|
||||
probe('conventions', 'parameter role mapping', _mapping)
|
||||
probe('conventions', 'plant layer completeness', _plant_gaps)
|
||||
probe('conventions', 'design number groups assemblies', _designs)
|
||||
probe('conventions', 'design summary views', _summaries)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ verdict
|
||||
#
|
||||
# The whole report body is wrapped so an unexpected exception still leaves a
|
||||
# run-log record (outcome='error') before pyRevit shows its own error dialog.
|
||||
|
||||
try:
|
||||
output.print_md('# Self check')
|
||||
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
|
||||
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
|
||||
|
||||
fails = len([r for r in rows if r[2] == 'FAIL'])
|
||||
warns = len([r for r in rows if r[2] == 'WARN'])
|
||||
|
||||
if fails:
|
||||
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
|
||||
output.print_md('A **FAIL** means a QC button will not work here. Start '
|
||||
'with the first one - they cascade.')
|
||||
elif warns:
|
||||
output.print_md('## Ready, with {0} warning(s)'.format(warns))
|
||||
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
|
||||
else:
|
||||
output.print_md('## Everything checks out')
|
||||
|
||||
outcome = 'ok' if not fails else 'fail'
|
||||
runlog.record(
|
||||
'Self Check', doc_title=doc.Title,
|
||||
outcome=outcome,
|
||||
report_text='\n'.join(
|
||||
['{0} | {1} | {2} | {3}'.format(*row) for row in rows]))
|
||||
except Exception as exc:
|
||||
runlog.record('Self Check',
|
||||
doc_title=doc.Title if doc is not None else None,
|
||||
outcome='error', error=str(exc),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,10 @@
|
||||
title: Takeoff
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
#
|
||||
# Back on the ribbon on 2026-08-30 -- see JobRecon, same story.
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,210 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# The takeoff, for the whole job: what it contains, per piece and in total.
|
||||
#
|
||||
# The counting lives in `vibe.takeoffcore` (classification and both totals),
|
||||
# `vibe.takeoffcount` (the control-number multiplier) and `vibe.takeoffreport`
|
||||
# (the two renderings), all three Revit-free and unit-tested on host CPython.
|
||||
# `vibe.takeoffgather` is the only part that touches a document. This file is
|
||||
# the button and nothing more.
|
||||
#
|
||||
# READ ONLY. No Transaction is opened here or in anything this calls. A takeoff
|
||||
# reports what a model contains; it has no business changing it.
|
||||
#
|
||||
# WHAT A PRESS COSTS, AND WHY IT DOES NOT REFRESH THE INDEX
|
||||
# -----------------------------------------------------------
|
||||
# It reads the cached reinforcing index (~1.4 s, no geometry, no view) and
|
||||
# makes one cheap pass over the assembly instances for the multiplier (~0.3 s).
|
||||
# It deliberately does NOT rebuild the index. A takeoff off a stale index is a
|
||||
# takeoff of the model as it was, and the report says when the index was built
|
||||
# so a reader who has been editing can see the stamp predates his edits. A
|
||||
# button that silently spends ten seconds rebuilding is a button that sometimes
|
||||
# freezes Revit and never says why; refreshing is Reinforcing Suggestion's job
|
||||
# and it has the progress bar for it.
|
||||
#
|
||||
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
||||
# -----------------------------------------------------
|
||||
# 1. Whole job per press, not one piece. A takeoff is a job-level document --
|
||||
# ordering is done once for the job, and a per-piece takeoff is already in
|
||||
# the by-piece CSV this writes.
|
||||
# 2. It multiplies by control number, and prints both figures. Per-mark and
|
||||
# per-job answer different questions and a reader will want to check one
|
||||
# against the other, so neither is hidden behind the other.
|
||||
# 3. The multiplier comes from the control numbers on framing elements, via
|
||||
# the job reconciliation's own gatherer -- not from assembly names, which
|
||||
# were measured through the bridge and found to be 755 unique names for 755
|
||||
# assemblies, i.e. no multiplier at all. An unmapped control-number role is
|
||||
# printed as a refusal at the top of the multiplier section, never as a job
|
||||
# where every mark happens to be one piece.
|
||||
# 4. An empty index is a refusal, not an empty takeoff. A takeoff of nothing
|
||||
# that looks like a takeoff of a job with nothing in it is the worst
|
||||
# possible output, so it says the index has not been built and names the
|
||||
# button that builds it.
|
||||
# 5. The reports go beside the model, never into the repository. They name
|
||||
# marks, part numbers and quantities for one client's job.
|
||||
#
|
||||
# WHAT THIS PAGE LOOKS LIKE, AND WHOSE RULE THAT IS
|
||||
# --------------------------------------------------
|
||||
# The owner's standing rule for the whole tab, and it lives in
|
||||
# `vibe.foldreport` rather than here: the answer is the headline, biggest and
|
||||
# first and never folded; everything else follows FOLDED; a section with a
|
||||
# problem in it opens itself and says on its header why. The contract that
|
||||
# buys is "if nothing below the headline is open, nothing needs me", and it
|
||||
# is worth more than any one section being visible.
|
||||
#
|
||||
# Here the headline is the ORDER -- hardware by part number, reinforcing by
|
||||
# bar mark, both figures on every line, with what multiplied them written
|
||||
# above. Coverage, provenance, the piece-by-piece breakdown and the basis
|
||||
# tallies fold. The refusal does not: an unmapped control-number role means
|
||||
# the model was never asked how many pieces a mark is, every per-job figure
|
||||
# is an undercount, and that renders OPEN as plain HTML.
|
||||
#
|
||||
# WHY THE FOLD IS IE5-ERA DOM AND IS DESIGNED TO FAIL SAFE
|
||||
# ---------------------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine,
|
||||
# in IE7 document mode, since pyRevit sets no FEATURE_BROWSER_EMULATION.
|
||||
# `<details>` does not exist there and fails SILENTLY. So the fold is an
|
||||
# inline `onclick` plus `style.display`, and it has not been exercised inside
|
||||
# Revit by the agent that wrote it. Two things make that survivable:
|
||||
# a section with a problem renders open with no script at all, and the whole
|
||||
# report, nothing folded, is the `.md` written beside the model every press
|
||||
# -- which this page names. A dead toggle costs a file open, never a
|
||||
# sentence.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Takeoff'
|
||||
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import foldreport as fold
|
||||
from vibe import knowledge
|
||||
from vibe import runlog
|
||||
from vibe import takeoffgather as gather
|
||||
from vibe import takeoffreport as report
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Takeoff'
|
||||
|
||||
|
||||
def not_judged(headline, message):
|
||||
"""'Could not judge' is a RESULT and it gets printed, not swallowed into
|
||||
a dialog that vanishes. It must never read as 'judged and found
|
||||
nothing'."""
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
output.print_md('## {0}'.format(headline))
|
||||
output.print_md(message)
|
||||
runlog.record('Takeoff', doc_title=doc.Title, outcome='not-run',
|
||||
report_text='{0}: {1}'.format(headline, message))
|
||||
script.exit()
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
runlog.record('Takeoff', doc_title=doc.Title, outcome='refused',
|
||||
report_text='\n'.join(problems))
|
||||
forms.alert('\n'.join(problems), title=TITLE, exitscript=True)
|
||||
|
||||
takeoff, counts, extras = gather.run(doc, know=know)
|
||||
|
||||
coverage = takeoff.get('coverage') or {}
|
||||
if not coverage.get('indexed_marks'):
|
||||
not_judged(
|
||||
'The reinforcing index has not been built for this model',
|
||||
'A takeoff is built from that index and there is nothing in it '
|
||||
'yet. This is not a job with no hardware in it -- it is a '
|
||||
'question nobody has asked the model.\n\n'
|
||||
'Run **Reinforcing Suggestion** once; it builds the index. Then '
|
||||
'press this again.')
|
||||
|
||||
sections = report.sections(takeoff, counts, title=TITLE, extras=extras)
|
||||
|
||||
# FOLDED IS NOT GONE, so the unfolded copy is written BEFORE anything is
|
||||
# printed -- the file then exists even if the printing throws, and the
|
||||
# page can name it. `takeoff.md` is that copy: `report.markdown` renders
|
||||
# every section in full, folded or not, so it can never say less than
|
||||
# this page does.
|
||||
paths, write_error = {}, None
|
||||
try:
|
||||
paths = gather.write_reports(doc, takeoff, counts, extras=extras)
|
||||
except Exception as exc:
|
||||
write_error = exc
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded, and the one line that says whether any of
|
||||
# it needs him. That line IS the contract; without it a page of shut
|
||||
# headers looks the same whether or not something is wrong.
|
||||
tail = sections[1:]
|
||||
if tail:
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md(
|
||||
'**{0} of the {1} section(s) below opened themselves — '
|
||||
'they have something in them that needs you.** The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every section is folded; click '
|
||||
'a header to open one.')
|
||||
if paths.get('.md'):
|
||||
output.print_md('The whole takeoff, nothing folded, is in `{0}`.'
|
||||
.format(paths['.md']))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
output.print_md('---')
|
||||
if write_error is not None:
|
||||
# The page is still complete, but only while the toggle works -- so
|
||||
# this says which, rather than the old flat report's promise that
|
||||
# what is on the page is all of it.
|
||||
output.print_md(
|
||||
'**The report could not be written to disk:** {0}. Every section '
|
||||
'is still on this page, but the folded ones need the click to '
|
||||
'open; there is no file copy of them this press.'.format(
|
||||
write_error))
|
||||
else:
|
||||
output.print_md('Written beside the model:')
|
||||
for suffix in sorted(paths):
|
||||
output.print_md('- `{0}`'.format(paths[suffix]))
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md(
|
||||
'_Read only. Nothing was written to the model. Index built '
|
||||
'{0}._'.format(coverage.get('built_at') or 'never'))
|
||||
|
||||
# The takeoff is on the page and (usually) in the files beside the model
|
||||
# -- the record is 'ok' with the paths that were written.
|
||||
runlog.record('Takeoff', doc_title=doc.Title, outcome='ok',
|
||||
report_path=paths.get('.md'),
|
||||
report_text=('Takeoff ran; index built {0}. Reports: {1}'
|
||||
.format(coverage.get('built_at') or 'never',
|
||||
', '.join(sorted(paths)) or 'not '
|
||||
'written')))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Takeoff', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,31 @@
|
||||
# A `.pulldown` is a large button with a downward arrow: one icon on the
|
||||
# panel, and every item below opens as a row when it is clicked
|
||||
# (`components.PullDownButtonGroup`, drawn by
|
||||
# `uimaker._produce_ui_pulldown`). Panels, pulldowns and pushbuttons each
|
||||
# keep their own name table, so the "Project Config" row inside this button
|
||||
# does not collide with the button itself.
|
||||
#
|
||||
# The pulldown's own label. Unlike a stack, a pulldown container does read a
|
||||
# `title:` -- `GenericUIComponent._read_bundle_metadata` sets `_ui_title`
|
||||
# from it, and `_produce_ui_pulldown` passes that straight to
|
||||
# `create_pulldown_button`.
|
||||
title: Config
|
||||
|
||||
# Row order, top to bottom. Rows are text beside a 16px icon and the
|
||||
# dropdown is as wide as it needs to be, so these titles are free to say
|
||||
# what they mean -- unlike the four large buttons on Tools, which are
|
||||
# rationed to ~9 characters a line.
|
||||
#
|
||||
# Job Recon and Takeoff came back here on 2026-08-30 ("recon 跟 takeoff 加回
|
||||
# 来吧, 放到 Config 里面去") after a day parked outside the tab. As of
|
||||
# 2026-09-02, at the owner's request, they are parked again: pulled out of
|
||||
# `layout:` below so neither renders, with `JobRecon.pushbutton/` and
|
||||
# `Takeoff.pushbutton/` left untouched on disk. Unparking is just re-adding
|
||||
# the two lines -- the owner will decide later how to handle them.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ProjectConfig
|
||||
- ExtractDesign
|
||||
- SelfCheck
|
||||
- HotReload
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,10 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Setup").
|
||||
#
|
||||
# One pulldown, on purpose. These are the commands you run to get a job
|
||||
# ready or to look at it sideways -- not the four you press all day. They
|
||||
# were taking two stack slots and a whole panel between them; behind one
|
||||
# arrow they take none.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- Config
|
||||
@@ -0,0 +1,11 @@
|
||||
title: Reinforce Suggestion
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# MOVED 2026-09-05 into the new Modeling.pulldown (see
|
||||
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
|
||||
# four large icons on Tools.panel. A pulldown row renders as text beside a
|
||||
# 16px icon and the dropdown grows to fit the longest row, so the title is
|
||||
# free to say what it means, unlike a large button rationed to ~9 characters
|
||||
# a line.
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,459 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Reinforcing suggestion for one assembly: which already-reinforced panels,
|
||||
# between them, account for every feature of this one.
|
||||
#
|
||||
# Owner's framing (QNA/reinforcing-suggestion-brief.md): this is a jigsaw, not
|
||||
# a similarity ranking. Every detail of the target has to be covered by
|
||||
# something, and the number of donors should be as small as possible. The
|
||||
# search lives in `vibe.reinforcematch`, the candidate index in
|
||||
# `vibe.reinforceindex` / `vibe.reinforcecore`, the join between them in
|
||||
# `vibe.reinforcebind`, and the wording in `vibe.reinforcereport`. This file is
|
||||
# the Revit end and nothing more: it resolves the selection, decides how
|
||||
# current the index has to be, and prints.
|
||||
#
|
||||
# READ ONLY, AND THAT IS THE POINT
|
||||
# --------------------------------
|
||||
# No Transaction is opened anywhere in this file or anything it calls. Nothing
|
||||
# is written to the model -- not a parameter, not a rebar, not a copy of a
|
||||
# donor's cage. The tool proposes; a human decides and a human draws it. This
|
||||
# is the single most important constraint on the feature, because a suggestion
|
||||
# tool is exactly the thing somebody will ask to "just copy it in for me", and
|
||||
# the middle band of a wall panel's vertical reinforcing is explicitly PER
|
||||
# DESIGN -- an engineer's calculation for that one piece. Carrying it across
|
||||
# automatically would look like an ordinary suggestion and would be the most
|
||||
# dangerous thing this feature could do.
|
||||
#
|
||||
# WHERE THE REFRESH DECISION LIVES, AND WHY IT IS HERE
|
||||
# ----------------------------------------------------
|
||||
# In this file, deliberately. `reinforceindex` offers mechanism -- `read()`,
|
||||
# `refresh()`, `refresh(rebuild_all=True)`, `coverage()` -- and takes no view
|
||||
# on which a button press deserves. Which one to spend is a UI policy question
|
||||
# (how many seconds may a click cost, and what does a modifier key mean), so it
|
||||
# belongs at the UI, in one readable place, not buried in the library where two
|
||||
# callers would end up disagreeing.
|
||||
#
|
||||
# The policy, with the measured numbers it rests on:
|
||||
#
|
||||
# * ordinary press -- the cheap signature pass over every assembly (~0.7 s,
|
||||
# no geometry, no view), then a rebuild of ONLY the pieces whose signature
|
||||
# moved. Nothing moved is the common case and then nothing is rebuilt and
|
||||
# nothing is rewritten: about a second. A full rebuild on every press is
|
||||
# 10 s of frozen UI to answer a question that usually has not changed.
|
||||
# * cold or empty index -- a full build regardless, ~10 s, with a progress
|
||||
# bar. There is no cheaper honest answer available.
|
||||
# * SHIFT-CLICK -- `rebuild_all=True` plus a re-run of the outline audit.
|
||||
#
|
||||
# KNOWN LIMITATION, AND WHY SHIFT-CLICK EXISTS AT ALL
|
||||
# ---------------------------------------------------
|
||||
# MASTER is a tie-break, never a filter. A donor typed MASTER in the model has
|
||||
# been REVIEWED rather than merely reinforced, and the ranking prefers it -- but
|
||||
# only between donor sets the geometry cannot separate. Owner's ruling: fit
|
||||
# wins. The report marks it and says so.
|
||||
#
|
||||
# The staleness signature is `reinforced | on_hold | master | member-count |
|
||||
# checksum(member ids)`. It catches a member added, removed or swapped, a piece
|
||||
# newly marked reinforced, and a piece put on or taken off hold. It does NOT
|
||||
# catch a geometry edit that leaves the member set alone -- an opening moved or
|
||||
# resized in place, a panel stretched. Those pieces serve a stale measurement
|
||||
# until something else about them moves.
|
||||
#
|
||||
# So an ordinary press can be wrong in exactly that one way, and the report
|
||||
# says so every time, in `reinforcereport.staleness_note`, along with the
|
||||
# instruction to shift-click. The report also always prints when the index was
|
||||
# built, so a reader who knows he has been editing geometry can tell whether
|
||||
# the stamp predates his edits. Shift-click is cheap because a full rebuild of
|
||||
# this model's 690 eligible pieces measured 6-10 s: the escape hatch is an
|
||||
# inconvenience, not a last resort.
|
||||
#
|
||||
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
||||
# -----------------------------------------------------
|
||||
# 1. One target per press. The engineer is reinforcing one piece; a batch of
|
||||
# suggestions is a report nobody reads. Several assemblies preselected asks
|
||||
# which one rather than fanning out.
|
||||
# 2. Shift-click is the force-rebuild gesture. It is invisible unless you are
|
||||
# told, so it is in the footer of every report and in the staleness note
|
||||
# the report prints when the index may be out of date. It used to be in the
|
||||
# hover tooltip too; the owner had every tooltip stripped (2026-08-30), so
|
||||
# the report is now the only place that says it. A dialog
|
||||
# on every press would be worse: the answer is the same 99 times out of 100.
|
||||
# 3. The outline audit runs automatically the first time only. It costs one
|
||||
# geometry read per indexed piece, and its answer changes only when the
|
||||
# model does. Never-audited and audited-and-found-none are different
|
||||
# answers, so the report says which one it has.
|
||||
# 4. At most FOUR donors in one proposal (owner's ruling, 2026-08-30). When
|
||||
# four cannot account for everything, the report prints the best four and
|
||||
# names what they miss -- it does not relax to a fifth. That overflow rule
|
||||
# is INFERRED from the ruling, not stated by the owner, and is the thing to
|
||||
# overrule first if this behaves wrongly on a real piece.
|
||||
# 5. Every measurement printed is in INCHES to two decimals, as ruled. The
|
||||
# ranking is quantized to 1/8 in so the two decimals cannot be mistaken for
|
||||
# 0.01 in precision -- see `reinforcebind.GRAIN_IN`.
|
||||
# 6. A target that is itself already reinforced, or on hold, is answered
|
||||
# anyway, with a note. Neither flag disqualifies a TARGET -- they are donor
|
||||
# rules -- but silently treating an already-reinforced piece as a fresh
|
||||
# request would hide a misclick.
|
||||
# 7. EVERYTHING BELOW THE ANSWER IS FOLDED, and a section opens itself only
|
||||
# when it has a problem in it (owner's ruling, 2026-08-30). Which sections
|
||||
# count as a problem is `reinforcereport`'s decision and is stated there,
|
||||
# section by section; the one to argue with first is the staleness rule,
|
||||
# which opens at 24 hours old and is otherwise folded. Nothing is removed:
|
||||
# a folded header names what it holds, and the whole report is written
|
||||
# unfolded to `last-report.txt` beside the index on every press.
|
||||
# 8. A COULD-NOT-JUDGE TARGET NOW PRINTS THE WHOLE PAGE rather than a short
|
||||
# "not judged" and an exit. It used to lose the index stamp and the
|
||||
# staleness note on the way out, which are exactly what a reader needs in
|
||||
# order to know whether to believe a refusal.
|
||||
# 9. A DONOR MUST BE THE SAME DESIGN AS THE TARGET, and that is the one rule
|
||||
# on this page that is NOT a judgement call. Owner, 2026-09-01: "选择母版的
|
||||
# 目标,必须得是跟自己的design number一样的panel ... 给我俩WP1.0的母版,压根
|
||||
# 用不了。" So it is a hard filter in `reinforcebind.DonorFilter`, applied
|
||||
# before a single comparison is made, and when no same-design donor exists
|
||||
# this button REFUSES -- it names the design, counts what the pool actually
|
||||
# held, and says the way out. It never falls back to the nearest other
|
||||
# design, not even labelled as one. Packet suffixes do not split a design:
|
||||
# `WP1.0`, `WP1.A` and `WP1` are one family, through the same
|
||||
# `specformat.design_key` reduction Ticket QC already uses.
|
||||
# A design number nobody could read is UNJUDGED, never a match -- an index
|
||||
# whose design numbers are all empty (which is what a build with no
|
||||
# knowledge layer produces) refuses and asks for a rebuild.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Reinforce Suggestion'
|
||||
# Shift-click runs the same script with EXEC_PARAMS.config_mode set; that is
|
||||
# the force-full-rebuild gesture. See the notes at the top of this file.
|
||||
__shiftclick__ = True
|
||||
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import compat
|
||||
from vibe import foldreport as fold
|
||||
from vibe import knowledge
|
||||
from vibe import runlog
|
||||
from vibe import scope as scopelib
|
||||
from vibe import reinforcebind as bind
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import reinforcereport as R
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = R.TITLE
|
||||
|
||||
|
||||
def die(message):
|
||||
"""A usage error: the user has not given us something yet. Not a refusal
|
||||
to judge -- those go in the report, where they survive the dialog."""
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='refused', report_text=message)
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
# `not_judged` used to live here: a short page and an exit, for a target that
|
||||
# could not be measured. It is gone, and deliberately -- 'could not judge' is
|
||||
# a RESULT, and printing it on a stripped-down page dropped the index stamp
|
||||
# and the staleness note, which are precisely what tells a reader whether to
|
||||
# believe the refusal. `reinforcereport.render` now returns the whole page for
|
||||
# that case too, with the headline saying NOT JUDGED, and this file prints it
|
||||
# the same way it prints an answer.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
# ------------------------------------------------------------ force rebuild
|
||||
|
||||
def _forced():
|
||||
"""True when the button was shift-clicked. Never fatal if unavailable."""
|
||||
try:
|
||||
from pyrevit import EXEC_PARAMS
|
||||
return bool(EXEC_PARAMS.config_mode)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the selection
|
||||
#
|
||||
# The same resolution Ticket QC and Panel Lift Check use: assemblies picked
|
||||
# straight through, a picked member resolves to its owning assembly, nothing
|
||||
# picked offers a list. The one difference is that this tool answers about
|
||||
# ONE piece, so several preselected assemblies ask which.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
picked_assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not picked_assemblies:
|
||||
owners = []
|
||||
for el in selection:
|
||||
try:
|
||||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
||||
owners.append(owner)
|
||||
picked_assemblies = owners
|
||||
|
||||
if selection and not picked_assemblies:
|
||||
die('What is selected is not part of an assembly, so there is no piece '
|
||||
'to reinforce.\n\nSelect the assembly you want suggestions for -- or '
|
||||
'any member of it -- and run this again.')
|
||||
|
||||
all_assemblies = index.assemblies(doc)
|
||||
by_name = dict(all_assemblies)
|
||||
|
||||
# The knowledge file is read here rather than further down, because the pick
|
||||
# list below needs the DECLARED SCOPE before it can offer anything.
|
||||
try:
|
||||
know = knowledge.load(doc)
|
||||
except Exception as exc:
|
||||
know = None
|
||||
output.print_md('> The knowledge file could not be read ({0}). No scope '
|
||||
'is declared, so every assembly in the model is offered '
|
||||
'— and **design numbers will be blank**, which '
|
||||
'since 2026-09-01 means this run will REFUSE rather than '
|
||||
'answer: a donor has to carry the same design number as '
|
||||
'the target, and an unread design number is never assumed '
|
||||
'to match. Fix the knowledge file, or shift-click to '
|
||||
'rebuild the index once it reads.'.format(exc))
|
||||
|
||||
the_scope = scopelib.of(know) if know else scopelib.Scope()
|
||||
|
||||
if not picked_assemblies:
|
||||
# The list this button OFFERS is scoped: this is the TOOL choosing, and
|
||||
# it chooses inside the declared scope. What the user picks HIMSELF is
|
||||
# never filtered -- see the branches below and the report's annotation.
|
||||
names, left_alone = the_scope.split_names(
|
||||
sorted(n for n, _a in all_assemblies))
|
||||
if not names:
|
||||
die('There is nothing here to pick from.\n\n{0}'
|
||||
.format(the_scope.line(left_alone, total=len(all_assemblies),
|
||||
unit='assembly')))
|
||||
chosen = forms.SelectFromList.show(
|
||||
names, multiselect=False, title=TITLE + ' - which piece',
|
||||
button_name='Suggest donors')
|
||||
if not chosen:
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Piece picker closed -- no target chosen.')
|
||||
script.exit()
|
||||
target_name = chosen
|
||||
elif len(picked_assemblies) > 1:
|
||||
names = sorted(str(a.Name) for a in picked_assemblies)
|
||||
chosen = forms.SelectFromList.show(
|
||||
names, multiselect=False,
|
||||
title=TITLE + ' - one piece at a time',
|
||||
button_name='Suggest donors')
|
||||
if not chosen:
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Piece picker closed -- no target chosen.')
|
||||
script.exit()
|
||||
target_name = chosen
|
||||
else:
|
||||
target_name = str(picked_assemblies[0].Name)
|
||||
|
||||
target_asm = by_name.get(target_name)
|
||||
if target_asm is None:
|
||||
die('{0} is not an assembly in this model.'.format(target_name))
|
||||
|
||||
|
||||
def _flag(el, pname):
|
||||
"""1 / 0 / None off the assembly. READ ONLY -- LookupParameter + read."""
|
||||
try:
|
||||
p = el.LookupParameter(pname)
|
||||
return None if p is None else int(p.AsInteger())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
target_reinforced = _flag(target_asm, index.P_REINFORCED)
|
||||
target_on_hold = _flag(target_asm, index.P_ON_HOLD)
|
||||
|
||||
# ------------------------------------------------------------------ the index
|
||||
#
|
||||
# See the notes at the top of this file for the policy. The cheap plan is
|
||||
# computed here rather than inside `refresh` so that the commonest press --
|
||||
# nothing changed -- can skip `refresh` entirely and avoid a second
|
||||
# signature pass, and so the progress bar knows how many pieces it is about
|
||||
# to build.
|
||||
|
||||
forced = _forced()
|
||||
|
||||
# `know` was loaded above, for the pick list. Past the scope it is used for
|
||||
# one optional thing -- reading each piece's design number for the report --
|
||||
# so a missing or broken one costs a label, not the answer. It is not worth
|
||||
# stopping the run over, unlike in Ticket QC where every check depends on it.
|
||||
|
||||
cache = index.read(doc)
|
||||
if cache.get('_load_error'):
|
||||
output.print_md('> The index on disk could not be read ({0}); it is being '
|
||||
'rebuilt from the model. A cache is derived data, so this '
|
||||
'costs time and nothing else.'
|
||||
.format(cache['_load_error']))
|
||||
forced = True
|
||||
|
||||
live = index.live_signatures(doc, all_assemblies, know=know)
|
||||
the_plan = core.plan(cache, live)
|
||||
todo = len(the_plan['build'])
|
||||
refreshed = bool(todo or the_plan['drop']) or forced
|
||||
|
||||
refresh_report = None
|
||||
if refreshed:
|
||||
total = len(the_plan['build']) + len(the_plan['fresh']) if forced else todo
|
||||
bar = 'Indexing donors ({value} of {max_value})'
|
||||
if forced:
|
||||
bar = 'Full rebuild ({value} of {max_value}) - all donors re-measured'
|
||||
with forms.ProgressBar(title=bar, cancellable=False) as pb:
|
||||
state = {'n': 0}
|
||||
|
||||
def _tick(_name):
|
||||
state['n'] += 1
|
||||
pb.update_progress(state['n'], max(total, 1))
|
||||
|
||||
pb.update_progress(0, max(total, 1))
|
||||
cache, refresh_report = index.refresh(
|
||||
doc, rebuild_all=forced, know=know, on_piece=_tick)
|
||||
|
||||
# The outline audit: once, unless forced. It is what keeps an approximated
|
||||
# edge condition from ever being printed as a measured exact match.
|
||||
audit_needed = forced or (bind.load_audit(doc) is None)
|
||||
|
||||
with forms.ProgressBar(title='Matching {0}...'.format(target_name),
|
||||
indeterminate=True):
|
||||
suggestion, info = bind.suggest_for_piece(
|
||||
doc, target_name, refresh=False, audit=audit_needed, know=know)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- report
|
||||
#
|
||||
# THE SHAPE, AND WHOSE IT IS. Owner, 2026-08-30:
|
||||
#
|
||||
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
|
||||
# 可以折叠的样子。"
|
||||
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
|
||||
# 给做成默认折叠。"
|
||||
#
|
||||
# So the target and the donor proposals are the page, printed big and first
|
||||
# through pyRevit's own markdown and tables -- which is also what can linkify
|
||||
# an assembly id. Everything else follows, smaller, FOLDED BY DEFAULT, and a
|
||||
# section that has a problem in it opens itself. That turns the page into a
|
||||
# contract: if nothing below the answer is open, nothing needs him.
|
||||
#
|
||||
# The rule lives in `vibe.foldreport`, not here, and the sections come from
|
||||
# `reinforcereport.render` -- the same sections the offline CLI prints as
|
||||
# plain text, so the two cannot say different things.
|
||||
#
|
||||
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED
|
||||
# -----------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser`: the IE engine,
|
||||
# hosted in WinForms, running in IE7 document mode unless the host process
|
||||
# carries a FEATURE_BROWSER_EMULATION entry, which pyRevit does not set.
|
||||
# `<details>` / `<summary>` DO NOT EXIST there and fail silently -- an unknown
|
||||
# element renders inline and the "collapsed" content is simply always visible.
|
||||
# So the fold is an inline `onclick` plus `style.display`, which is IE5-era
|
||||
# DOM. JavaScript does run in this panel (pyRevit injects Chart.js into it),
|
||||
# but the toggle has NOT been exercised inside Revit, so it is designed for
|
||||
# failure rather than trusted:
|
||||
#
|
||||
# * a section with a problem in it renders OPEN, as plain HTML, and does not
|
||||
# need the script to run at all;
|
||||
# * the whole report, nothing folded, is written to `last-report.txt` beside
|
||||
# the index every press, and the page says where. A fold that will not
|
||||
# open therefore costs a file open, never a lost sentence.
|
||||
|
||||
full_path = os.path.join(os.path.dirname(index.index_path(doc)),
|
||||
'last-report.txt')
|
||||
|
||||
|
||||
def _donor_link(_name, record):
|
||||
"""pyRevit's clickable element id, for a donor's line in the headline."""
|
||||
if not (record or {}).get('asm_id'):
|
||||
return None
|
||||
try:
|
||||
return output.linkify(ElementId(record['asm_id']), 'select assembly')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
best = suggestion.best if suggestion is not None else None
|
||||
rows = bind.explain(suggestion, info, best) if best is not None else []
|
||||
|
||||
sections = R.render(suggestion, info, rows, cache,
|
||||
model=doc.Title,
|
||||
refresh_report=refresh_report,
|
||||
rebuilt_all=forced, refreshed=refreshed,
|
||||
offline=False,
|
||||
reinforced=target_reinforced, on_hold=target_on_hold,
|
||||
full_path=full_path, donor_link=_donor_link)
|
||||
|
||||
# FOLDED IS NOT GONE. Written before anything is printed, so the file exists
|
||||
# even if the printing throws.
|
||||
try:
|
||||
# `as_text` first, then encode: under IronPython 2.7 the report is a byte
|
||||
# string, and a piece mark or design number carrying a non-ASCII character
|
||||
# would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
||||
payload = compat.as_text(fold.text(sections, show_all=True))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception as exc:
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold headers.'
|
||||
.format(exc))
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded.
|
||||
tail = sections[1:]
|
||||
if tail:
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
if opened:
|
||||
output.print_md('---')
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md('---')
|
||||
output.print_md('**Nothing below needs you.** Every section is folded; '
|
||||
'click a header to open one. The whole report is also '
|
||||
'in `{0}`.'.format(full_path))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
# The report is on the page and the full text is on disk -- record the run
|
||||
# as 'ok'. The headline (target + donors or the NOT JUDGED refusal) is what
|
||||
# the page led with; keep the record short, since report_text is capped.
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title, outcome='ok',
|
||||
report_path=full_path,
|
||||
report_text=('Target {0}; suggestion {1}'
|
||||
.format(target_name,
|
||||
'found' if best is not None
|
||||
else 'not judged')))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,28 @@
|
||||
title: Reinf QC
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses (it shares
|
||||
# `lib/vibe/ticketqc_run.py` and `ticketchecks.py` with Ticket QC) is
|
||||
# visible without a Revit restart. See TicketQC.pushbutton/bundle.yaml for
|
||||
# the full explanation; this one carries the same key for the same reason.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Added 2026-09-05, on the owner's own instruction: a cut-down TicketQC for
|
||||
# a panel whose reinforcing is finished (the model already has everything it
|
||||
# should have) but whose ticketing has not started. Shares its engine with
|
||||
# TicketQC -- both call into `lib/vibe/ticketqc_run.py`, which takes a
|
||||
# `mode` of 'ticket' or 'reinforcing' -- and reads the same evidence one
|
||||
# stage earlier: no ticket sheet is required, and any check whose evidence
|
||||
# is a ticket artefact (a hatch, a callout, a BOM table, a sheet name...)
|
||||
# does not run here. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` for
|
||||
# exactly which of the 41 checks that is, and why, one by one.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means -- kept
|
||||
# short anyway so it reads at a glance beside "Ticket QC".
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Check a panel's MODELLED reinforcing against its design spec -- for a
|
||||
# piece whose reinforcing is finished (the model already has everything it
|
||||
# should have) but whose ticketing has not started yet.
|
||||
#
|
||||
# A cut-down Ticket QC: same engine, same evidence-first discipline, but
|
||||
# only the checks whose evidence is the panel's modelled content run here
|
||||
# (connectors/embeds, lifters, strands, bars, WWF, solid zones as modelled,
|
||||
# member interference, weight, cast extras, release ratio, the handling
|
||||
# result, lift positions/points). Anything that needs a ticket artefact --
|
||||
# a sheet, a placed view, a printed call-out, a hatch pattern, a BOM table,
|
||||
# a bed-plan note -- does not run here; it is listed, with its reason, in
|
||||
# the report's own "Not checked at this stage" section rather than left
|
||||
# silent. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` /
|
||||
# `REINFORCING_EXCLUDED` for the check-by-check classification, and
|
||||
# `lib/vibe/ticketqc_run.py` for the shared engine this and Ticket QC both
|
||||
# call.
|
||||
#
|
||||
# Read-only. Nothing here opens a transaction.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Reinf QC'
|
||||
|
||||
from pyrevit import revit
|
||||
|
||||
from vibe import ticketqc_run
|
||||
|
||||
ticketqc_run.run(revit.doc, revit.uidoc, mode='reinforcing')
|
||||
@@ -0,0 +1,27 @@
|
||||
title: Ticket QC
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom, and it is the hottest button here --
|
||||
# 51 commits to its six modules in the seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# MOVED 2026-09-05 into the new Modeling.pulldown (see
|
||||
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
|
||||
# four large icons on Tools.panel. One line, no `\n`: a pulldown row renders
|
||||
# as text beside a 16px icon and the dropdown grows to fit the longest row,
|
||||
# so the title is free to say what it means -- unlike a large button, which
|
||||
# is as wide as its widest line.
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Check shop tickets against their design spec.
|
||||
#
|
||||
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
|
||||
# then compares every piece of the chosen design numbers against them.
|
||||
#
|
||||
# Every CHECK here is pure arithmetic - the same model always gives the same
|
||||
# answer, and every row of the checklist table is computed. One thing in the
|
||||
# report is not: with the run-time option `vision_escalation` on, the checks
|
||||
# code cannot settle (5.5) are escalated to a small model reading one rendered
|
||||
# view. That result is quarantined - its own list, its own codes, its own
|
||||
# section, and it can never set a checklist row. See vibe.ticketvision and
|
||||
# docs/ticket-checklist.md section 8bis.
|
||||
#
|
||||
# Read-only. Nothing here opens a transaction, including the image export.
|
||||
#
|
||||
# THE WHOLE BUTTON MOVED into `lib/vibe/ticketqc_run.py` on 2026-09-05, so
|
||||
# the new Reinforcing QC button could reuse it instead of these 765 lines
|
||||
# being copy-pasted -- see that module's own header for exactly what is
|
||||
# shared and what differs. This script is `mode='ticket'`, which is BYTE
|
||||
# FOR BYTE what used to live here: nothing about Ticket QC's own behaviour
|
||||
# changed in the move.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Ticket QC'
|
||||
|
||||
from pyrevit import revit
|
||||
|
||||
from vibe import ticketqc_run
|
||||
|
||||
ticketqc_run.run(revit.doc, revit.uidoc, mode='ticket')
|
||||
@@ -0,0 +1,27 @@
|
||||
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
|
||||
# why a container may carry a `title:`.
|
||||
title: Modeling
|
||||
|
||||
# THE MODELLING-STAGE TOOLS, gathered under one arrow (2026-09-05, on the
|
||||
# owner's own instruction: "加一个 Modeling pulldown,把 Reinforcing 和
|
||||
# TicketQC 放进去"). ReinforceSuggest and TicketQC moved here from being two
|
||||
# of the four large buttons this panel used to show side by side; the fourth
|
||||
# tool that lived here, Panel Lift Check, moved the other way -- onto
|
||||
# Design.panel/DesignHub.pulldown, see that bundle's own comment. Snapshot
|
||||
# stays put as the one remaining large icon.
|
||||
#
|
||||
# ReinforcingQC is new: a cut-down TicketQC for a panel whose reinforcing is
|
||||
# finished but whose ticketing has not started yet -- see
|
||||
# lib/vibe/ticketqc_run.py and lib/vibe/ticketchecks.REINFORCING_CHECKS for
|
||||
# what it does and does not judge at that stage. It sits beside TicketQC
|
||||
# because it shares the engine and reads the same evidence, one stage
|
||||
# earlier.
|
||||
#
|
||||
# Order is the natural order of the work: suggest the reinforcing, check the
|
||||
# reinforcing once it is modelled, check the ticket once it is drawn.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ReinforceSuggest
|
||||
- ReinforcingQC
|
||||
- TicketQC
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,11 @@
|
||||
title: Snapshot
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# One word, so one line, and it is shorter than "Reinforce" beside it -- the
|
||||
# four large buttons on this panel all come out the same width.
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,889 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Index the model as it stands, then hand the sandbox off -- or close Revit.
|
||||
#
|
||||
# WHAT THIS BUTTON IS NOW, 2026-09-01
|
||||
# ------------------------------------
|
||||
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
|
||||
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
|
||||
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
|
||||
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
|
||||
#
|
||||
# So the whole cycle moved out of this button and into
|
||||
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
|
||||
# it, and can be started by Claude. What is left here is the one job that
|
||||
# genuinely requires the live session and cannot be done anywhere else:
|
||||
# handing over the model's IDENTITY. Only the open document can cheaply report
|
||||
# its cloud region and GUIDs, and without those nothing else can find the
|
||||
# model to copy.
|
||||
#
|
||||
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
|
||||
# ----------------------------------------------
|
||||
# The design this replaced expected an in-session background open --
|
||||
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
|
||||
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
|
||||
# does not exist for this model, and the reason is not a version guard:
|
||||
#
|
||||
# * a cloud model cannot be opened detached at all. Any
|
||||
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
|
||||
# is not valid for cloud model"; Autodesk's answer is that the Revit team
|
||||
# does not allow it and that SaveAs is the way; and the UI agrees --
|
||||
# "Detach from Central" is greyed out for anything from Autodesk Docs.
|
||||
# * Revit holds one Document per model per session, so the live session
|
||||
# cannot open the model it already has open.
|
||||
# * the only call that could copy the document already in memory is
|
||||
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
|
||||
# here it would leave HIS Revit sitting in a local copy, disconnected
|
||||
# from the cloud. There is no SaveCopyAs.
|
||||
#
|
||||
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
|
||||
# The copy is therefore made by a Revit that is not his, and this button's
|
||||
# phase C is a spawn rather than an open.
|
||||
#
|
||||
# THE OLD RITUAL, KEPT
|
||||
# ---------------------
|
||||
# `snapshot.after_close` is NOT retired. The close-model path below is
|
||||
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
|
||||
# day, where the answer really is "close everything first". It is simply no
|
||||
# longer the only way to get a copy, and it is no longer the default.
|
||||
#
|
||||
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
|
||||
# ------------------------------------------------
|
||||
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
|
||||
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
|
||||
# session and a detached sandbox ran side by side all morning. What nobody has
|
||||
# measured is three, so the pipeline sequences to keep it to two and this
|
||||
# button never opens a model at all.
|
||||
#
|
||||
# The close-model ritual, still here as one of the options:
|
||||
#
|
||||
# he syncs his act, outside this button. This button NEVER
|
||||
# calls SynchronizeWithCentral and never asks about it.
|
||||
# phase A this button indexes the OPEN document. The model is
|
||||
# already in RAM and already paid for; a snapshot read
|
||||
# from the live document at sync state and one read
|
||||
# from a detached copy of that same state contain the
|
||||
# same numbers. Seconds, not minutes, and no copy.
|
||||
# phase B Revit closes. The press IS the authorisation for the
|
||||
# close -- that is what the button is called.
|
||||
# phase C AFTER Revit has exited and the 10 GB is free, a
|
||||
# deferred host process opens the model detached, saves
|
||||
# a timestamped copy and quits. Optional, off unless
|
||||
# the job layer configures it, and its outcome lands in
|
||||
# a log this button reads out on the next press.
|
||||
#
|
||||
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
|
||||
# --------------------------------------------------------------------
|
||||
# No Transaction is opened in this file or in anything it calls. Nothing is
|
||||
# written to the model -- not a parameter, not a workset, not a save. The only
|
||||
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
|
||||
# file, in another process, after this one has exited. The sync that used to
|
||||
# live here has been removed entirely at the owner's instruction.
|
||||
#
|
||||
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
|
||||
# ----------------------------------------------------
|
||||
# Revit is closed LAST and only after phase A has reported complete. A crash
|
||||
# halfway through the index must leave the session open, with the traceback on
|
||||
# screen, because the alternative is a closed Revit and no way to find out
|
||||
# what went wrong. `PostCommand` is therefore reached on exactly one path.
|
||||
#
|
||||
# The other half of that discipline is phase C. It runs when nobody is
|
||||
# watching, in a process this one has already outlived, and a day it silently
|
||||
# did not run looks exactly like a day it worked. So the request is journalled
|
||||
# BEFORE the close and the deferred process journals its own outcome; a
|
||||
# request with nothing after it is reported, in capitals, at the top of the
|
||||
# next press. Silence is not an acceptable report.
|
||||
#
|
||||
# JUDGEMENT CALLS, for the owner to overrule
|
||||
# -------------------------------------------
|
||||
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
|
||||
# press elsewhere trusts the cheap signature, which cannot see an opening
|
||||
# moved or resized in place. This is the daily archival snapshot and it is
|
||||
# the one that gets trusted months later, so it pays the measured 6-10 s
|
||||
# for an answer that owes nothing to yesterday's cache.
|
||||
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
|
||||
# hang on a mis-click, and "snapshot but stay open" is a real want (a
|
||||
# lunchtime checkpoint). Cancel writes nothing at all.
|
||||
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
|
||||
# declared scope and no parameter-role mapping, so the index would measure
|
||||
# the whole model and mark every piece could-not-judge on the review
|
||||
# signal -- and would overwrite a good index to do it. That is worth a
|
||||
# dialog. It is not worth a refusal on a machine that legitimately has no
|
||||
# job layer yet.
|
||||
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
|
||||
# default and None means not configured, never "pick somewhere". The button
|
||||
# says so instead of guessing a folder for a 300 MB file.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Snapshot'
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import knowledge
|
||||
from vibe import liftexport
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import runlog
|
||||
from vibe import snapshotmeta as S
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Snapshot & Close'
|
||||
|
||||
# Where this button sits, and therefore where the repo is. Hoisted to the top
|
||||
# because three separate places need it now (the deferred tool, the cycle
|
||||
# pipeline and the runner) and computing it three times is how two of them end
|
||||
# up one directory apart.
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
||||
REPO_ROOT = os.path.dirname(EXT_ROOT)
|
||||
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
|
||||
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
|
||||
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
|
||||
|
||||
CYCLE = 'Snapshot, then run the sandbox cycle'
|
||||
CLOSE = 'Snapshot, then close Revit'
|
||||
STAY = 'Snapshot only, stay open'
|
||||
CANCEL = 'Cancel'
|
||||
|
||||
|
||||
def die(message):
|
||||
runlog.record('Snapshot Close',
|
||||
doc_title=doc.Title if doc is not None else None,
|
||||
outcome='refused', report_text=message)
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
if doc is None:
|
||||
# `doc.Title` would itself fail on a None document -- record what there is
|
||||
# to record, then hand the refusal to the dialog.
|
||||
runlog.record('Snapshot Close', doc_title=None, outcome='refused',
|
||||
report_text='No model is open, so there is nothing to '
|
||||
'snapshot.')
|
||||
die('No model is open, so there is nothing to snapshot.')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- versions
|
||||
|
||||
def _revit_version():
|
||||
try:
|
||||
app = doc.Application
|
||||
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pyrevit_version():
|
||||
try:
|
||||
from pyrevit import versionmgr
|
||||
return versionmgr.get_pyrevit_version().get_formatted()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _revit_pid():
|
||||
try:
|
||||
from System.Diagnostics import Process
|
||||
return Process.GetCurrentProcess().Id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
# ------------------------------------------------------ what already happened
|
||||
#
|
||||
# Read and printed FIRST, before anything is decided, because the one thing
|
||||
# this design can lose is a phase C that never ran -- and the only moment
|
||||
# anybody is looking is the moment they press this button again.
|
||||
|
||||
know = knowledge.load(doc)
|
||||
job_folder = None
|
||||
try:
|
||||
from vibe import designspec
|
||||
job_folder = designspec.root_dir(doc)
|
||||
except Exception:
|
||||
job_folder = None
|
||||
|
||||
log = S.log_path(job_folder) if job_folder else None
|
||||
history = S.read(log) if log else []
|
||||
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
for line in S.summary(history):
|
||||
if line.startswith('WARNING'):
|
||||
output.print_md('**{0}**'.format(line))
|
||||
else:
|
||||
output.print_md('- {0}'.format(line))
|
||||
|
||||
|
||||
# ---------------------------------------------------- is this job set up
|
||||
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
die('\n\n'.join(problems))
|
||||
|
||||
has_job_layer = False
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
has_job_layer = True
|
||||
|
||||
if not has_job_layer:
|
||||
keep_going = forms.alert(
|
||||
'This model has no job knowledge file.\n\n'
|
||||
'Without it there is no declared scope and no parameter-name '
|
||||
'mapping, so the index will measure every assembly in the model and '
|
||||
'will record "could not judge" for the review signal on all of them '
|
||||
'-- and it will overwrite whatever index is there now.\n\n'
|
||||
'Snapshot anyway?',
|
||||
title=TITLE, yes=True, no=True)
|
||||
if not keep_going:
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='No job knowledge file; user declined to '
|
||||
'snapshot anyway.')
|
||||
script.exit()
|
||||
|
||||
|
||||
# --------------------------------------------------------- phase C settings
|
||||
#
|
||||
# Resolved BEFORE the confirmation so the dialog can say what will actually
|
||||
# happen after the close, rather than promising a detached copy that turns out
|
||||
# not to be configured.
|
||||
|
||||
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
|
||||
after_close = knowledge.get(know, 'snapshot.after_close', False)
|
||||
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
|
||||
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
|
||||
# TEXT, so two call sites spelling the same value differently are reported as
|
||||
# disagreeing about what "unset" means.
|
||||
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
|
||||
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
|
||||
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
|
||||
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
|
||||
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
|
||||
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
|
||||
or S.revit_exe_guess(revit_year)
|
||||
if not revit_year:
|
||||
# `pyrevit run` (both the cycle route's detach step and the close-model
|
||||
# route) takes an optional `--revit=<year>`, and a job layer that states
|
||||
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
|
||||
# job's shape -- used to leave it off entirely. On a machine with more
|
||||
# than one Revit version attached that is not a neutral omission: see
|
||||
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
|
||||
# is named for. `revit_exe`'s own path almost always carries the year.
|
||||
revit_year = S.revit_year_from_exe(revit_exe)
|
||||
batch = knowledge.get(know, 'snapshot.batch', []) or []
|
||||
|
||||
|
||||
def _pyrevit_cli():
|
||||
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
|
||||
if pyrevit_exe and os.path.isfile(pyrevit_exe):
|
||||
return pyrevit_exe
|
||||
if pyrevit_exe:
|
||||
return None # stated and wrong: never fall back
|
||||
return S.pyrevit_exe_guess()
|
||||
|
||||
|
||||
def _copy_blocker():
|
||||
"""What stops ANY detached copy being made, whichever route. Or None."""
|
||||
if not detached_dir:
|
||||
return ('not configured -- set snapshot.detached_dir in the job '
|
||||
'knowledge file to switch it on')
|
||||
if not os.path.isdir(detached_dir):
|
||||
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
|
||||
if _pyrevit_cli() is None:
|
||||
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
|
||||
if liftexport.find_python(know) is None:
|
||||
return 'no host Python was found to run the deferred step'
|
||||
return None
|
||||
|
||||
|
||||
def phase_c_blocker():
|
||||
"""Why the OLD after-close route cannot run, or None.
|
||||
|
||||
`snapshot.after_close` is deliberately still consulted here and nowhere
|
||||
else: it gates the close-model ritual, which is the thing it has always
|
||||
meant. The cycle route below does not read it -- it is not chained to an
|
||||
exit, so "after close" has nothing to say about it, and making one key
|
||||
mean two things is how a setting becomes untrustworthy.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not after_close:
|
||||
return ('switched off -- snapshot.after_close is false in the job '
|
||||
'knowledge file')
|
||||
return None
|
||||
|
||||
|
||||
def cycle_blocker():
|
||||
"""Why the sandbox cycle cannot be started, or None.
|
||||
|
||||
Note what is NOT in here: `after_close`, and any check on other Revits.
|
||||
The cycle runs beside this session on purpose -- that is the whole point
|
||||
of it -- and it sequences its own steps so that at most two Revits are
|
||||
ever resident.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not revit_exe:
|
||||
return ('no Revit.exe to open the sandbox with -- set '
|
||||
'snapshot.revit_exe or snapshot.revit_year')
|
||||
if not os.path.isfile(revit_exe):
|
||||
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
|
||||
if not batch:
|
||||
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
|
||||
if not os.path.isfile(CYCLE_TOOL):
|
||||
return 'tools/snapshot_cycle.py is not where this button expects it'
|
||||
return None
|
||||
|
||||
|
||||
blocker = phase_c_blocker()
|
||||
cycle_stop = cycle_blocker()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the decision
|
||||
|
||||
if cycle_stop is None:
|
||||
cycle_tail = (
|
||||
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
|
||||
'own, then opened in a sandbox session, then {1} runs inside it, then '
|
||||
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
|
||||
.format(detached_dir, ' + '.join(batch)))
|
||||
else:
|
||||
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
|
||||
|
||||
if blocker is None:
|
||||
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
|
||||
'copy is written to:\n{0}'.format(detached_dir))
|
||||
else:
|
||||
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
|
||||
|
||||
choice = forms.alert(
|
||||
'Sync first if you have not -- this button does not sync, and it never '
|
||||
'touches the model.\n\n'
|
||||
'It rebuilds the reinforcing index from the model as it stands now and '
|
||||
'stamps it with where it came from.' + cycle_tail + close_tail,
|
||||
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
|
||||
|
||||
if choice not in (CYCLE, CLOSE, STAY):
|
||||
output.print_md('---')
|
||||
output.print_md('Cancelled. Nothing was read, written or closed.')
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='User cancelled the snapshot dialog.')
|
||||
script.exit()
|
||||
|
||||
closing = (choice == CLOSE)
|
||||
cycling = (choice == CYCLE)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase A
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase A - indexing the open model')
|
||||
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
|
||||
'an earlier press. Measured at 6-10 s on this model.')
|
||||
|
||||
# The index AS IT STOOD BEFORE this press, captured so the update statistics
|
||||
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
|
||||
# only loads the file, it never touches the model. Absent or damaged is fine:
|
||||
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
|
||||
try:
|
||||
old_cache = index.read(doc)
|
||||
except Exception:
|
||||
old_cache = None
|
||||
|
||||
report = None
|
||||
cache = None
|
||||
failure = None
|
||||
try:
|
||||
# An upper bound for the bar: a full rebuild measures at most every
|
||||
# assembly in the model. The cheap collector pass costs ~0.3 s here and
|
||||
# buys a progress bar that moves, instead of one pinned at 100%.
|
||||
total = max(len(index.assemblies(doc)), 1)
|
||||
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
|
||||
cancellable=False) as bar:
|
||||
state = {'n': 0}
|
||||
|
||||
def tick(_name):
|
||||
state['n'] += 1
|
||||
bar.update_progress(state['n'], total)
|
||||
|
||||
bar.update_progress(0, total)
|
||||
cache, report = index.refresh(doc, rebuild_all=True, know=know,
|
||||
on_piece=tick)
|
||||
except Exception:
|
||||
failure = traceback.format_exc()
|
||||
|
||||
if failure is not None:
|
||||
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
|
||||
output.print_md('## Phase A FAILED - Revit has not been closed')
|
||||
output.print_md('```\n{0}\n```'.format(failure))
|
||||
if log:
|
||||
S.append(log, {'event': 'phase-a-failed',
|
||||
'error': failure.splitlines()[-1]})
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Phase A failed - Revit has not been closed.',
|
||||
error=failure.splitlines()[-1])
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------- stamp it with where it came from
|
||||
#
|
||||
# `refresh` writes the index; this adds the block that says whose numbers
|
||||
# those are, and rewrites. It is written on EVERY press, including the press
|
||||
# where nothing changed and `refresh` therefore did not save -- an index whose
|
||||
# provenance is a week older than its numbers is worse than none.
|
||||
|
||||
source_path = None
|
||||
try:
|
||||
source_path = doc.PathName or None
|
||||
except Exception:
|
||||
source_path = None
|
||||
|
||||
prov = S.provenance(
|
||||
'A', S.LIVE_DOC,
|
||||
source_path=source_path,
|
||||
source_stamp=None, # a live document has no snapshot stamp
|
||||
source_title=index.doc_key(doc),
|
||||
revit=_revit_version(),
|
||||
pyrevit=_pyrevit_version(),
|
||||
job_folder=job_folder,
|
||||
note=('read from the open document immediately after the engineer synced; '
|
||||
'no detached copy was involved in these numbers'))
|
||||
|
||||
pieces = len(cache.get('pieces') or {})
|
||||
try:
|
||||
cache['provenance'] = prov
|
||||
core.save(report['path'], cache)
|
||||
stamped = True
|
||||
except Exception:
|
||||
stamped = False
|
||||
|
||||
output.print_md('- {0} assemblies seen, {1} pieces in the index'
|
||||
.format(report['assemblies'], pieces))
|
||||
output.print_md('- scope: {0}'.format(report['scope']))
|
||||
output.print_md('- built in {0} s, written to `{1}`'
|
||||
.format(report['seconds_total'], report['path']))
|
||||
if stamped:
|
||||
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
|
||||
else:
|
||||
output.print_md('- **the provenance stamp could not be written** -- this '
|
||||
'index cannot say where it came from')
|
||||
|
||||
missing = (report.get('coverage') or {}).get('missing') or []
|
||||
if missing:
|
||||
output.print_md('- **{0} pieces could not be measured** and are not in '
|
||||
'this snapshot'.format(len(missing)))
|
||||
|
||||
if log:
|
||||
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
|
||||
'path': report['path'], 'scope': report['scope'],
|
||||
'seconds': report['seconds_total'],
|
||||
'provenance': prov})
|
||||
|
||||
|
||||
# ------------------------------------------------- update statistics
|
||||
#
|
||||
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
|
||||
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
|
||||
# press changed: which pieces are newly donors, how many features each carries,
|
||||
# and which feature-IDs went into the shared vocabulary for the first time
|
||||
# versus which were already known. READ ONLY -- this only reads the two caches
|
||||
# and one cheap signature pass; nothing about the model is written.
|
||||
#
|
||||
# `index.refresh` already grew the registry (via `core.register_features`), so
|
||||
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
|
||||
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
|
||||
# runs, redone here so the diff sees the model exactly as it stands now.
|
||||
|
||||
output.print_md('## Update statistics -- what this snapshot marked')
|
||||
try:
|
||||
live = index.live_signatures(doc, know=know)
|
||||
stats = core.snapshot_stats(old_cache, cache, live)
|
||||
except Exception:
|
||||
stats = None
|
||||
output.print_md('- **the update statistics could not be computed** -- the '
|
||||
'index was still written; only this summary is missing')
|
||||
|
||||
if stats is not None:
|
||||
newly = stats['newly']
|
||||
if stats['first_snapshot']:
|
||||
output.print_md('- **first snapshot for this index** -- there was '
|
||||
'nothing to diff against, so every reinforced piece '
|
||||
'below is new to the donor pool, not a change since '
|
||||
'last press.')
|
||||
if not newly:
|
||||
output.print_md('- no piece was newly marked reinforced since the last '
|
||||
'snapshot. (This is "nothing changed", not "nothing '
|
||||
'is reinforced".)')
|
||||
else:
|
||||
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
|
||||
'{1}'.format(len(newly), ', '.join(newly)))
|
||||
output.print_md('- feature-IDs new to the vocabulary this press: '
|
||||
'**{0}**; already known: **{1}**'
|
||||
.format(len(stats['new_ids']),
|
||||
len(stats['known_ids'])))
|
||||
for panel in stats['panels']:
|
||||
if panel['feature_count'] is None:
|
||||
output.print_md(' - **{0}** -- could not be measured; no '
|
||||
'features recorded'.format(panel['name']))
|
||||
continue
|
||||
bits = ['{0} feature(s)'.format(panel['feature_count'])]
|
||||
if panel['new_ids']:
|
||||
bits.append('new IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
|
||||
if panel['known_ids']:
|
||||
bits.append('known IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
|
||||
if panel['unregistered']:
|
||||
bits.append('{0} unregistered'.format(panel['unregistered']))
|
||||
output.print_md(' - **{0}** -- {1}'.format(
|
||||
panel['name'], '; '.join(bits)))
|
||||
|
||||
if log:
|
||||
# Journal the same figures. Per-panel id lists are kept so the ledger
|
||||
# can be replayed without the model; `first_snapshot` distinguishes a
|
||||
# cold index from a genuine no-change press.
|
||||
S.append(log, {'event': 'phase-a-update-stats',
|
||||
'first_snapshot': stats['first_snapshot'],
|
||||
'newly': newly,
|
||||
'new_ids': stats['new_ids'],
|
||||
'known_ids': stats['known_ids'],
|
||||
'panels': stats['panels']})
|
||||
|
||||
|
||||
# ---------------------------------------------- handing the model over
|
||||
#
|
||||
# Both routes below need the same two things: WHERE the copy goes (a plan) and
|
||||
# HOW to find the model again (its identity). Built once, here, so the cycle
|
||||
# and the close-model ritual cannot drift into naming files differently.
|
||||
|
||||
|
||||
def _source_block():
|
||||
"""How another session is to find this model again.
|
||||
|
||||
Cloud GUIDs and file path both, when both are available: a cloud model is
|
||||
reopened from its GUIDs, a file-based one from its path, and which one a
|
||||
job is was never something to assume. A cloud model reports no usable
|
||||
local path, which is the ordinary case here, not an error.
|
||||
"""
|
||||
src = {'path': source_path, 'title': index.doc_key(doc)}
|
||||
try:
|
||||
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
|
||||
mp = doc.GetCloudModelPath()
|
||||
src['cloud'] = {
|
||||
'region': str(mp.Region),
|
||||
'project_guid': str(mp.GetProjectGUID()),
|
||||
'model_guid': str(mp.GetModelGUID()),
|
||||
}
|
||||
except Exception as exc:
|
||||
# Not a cloud model, or this Revit does not expose the GUIDs. The file
|
||||
# path is then the only handle and the runner says so if it is absent.
|
||||
src['cloud'] = None
|
||||
src['cloud_error'] = str(exc)
|
||||
return src
|
||||
|
||||
|
||||
def _write_request(run_id, plan, source, extra=None):
|
||||
"""The handoff file. Same schema for both routes -- one runner reads it."""
|
||||
request = {
|
||||
'run': run_id,
|
||||
'base': plan['title'],
|
||||
'layout': layout,
|
||||
'worksets': worksets,
|
||||
'detached_dir': detached_dir,
|
||||
'plan': plan,
|
||||
'log': log,
|
||||
'job_folder': job_folder,
|
||||
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'requested_by_revit': _revit_version(),
|
||||
'source': source,
|
||||
}
|
||||
request.update(extra or {})
|
||||
req_dir = os.path.join(detached_dir, '_requests')
|
||||
req_path = os.path.join(req_dir, run_id + '.json')
|
||||
if not os.path.isdir(req_dir):
|
||||
os.makedirs(req_dir)
|
||||
import json
|
||||
handle = open(req_path, 'wb')
|
||||
try:
|
||||
handle.write(json.dumps(request, indent=2).encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
# And the identity on its own, where the host pipeline looks for it.
|
||||
# A cloud model is found by its region and two GUIDs, and NOTHING on this
|
||||
# machine knows those except a session with the model open -- this one.
|
||||
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
|
||||
# later with no Revit anywhere and still find the model to copy. It is
|
||||
# identity, never state: it says which model, never anything about a copy.
|
||||
try:
|
||||
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
|
||||
try:
|
||||
cache.write(json.dumps(source, indent=2,
|
||||
sort_keys=True).encode('utf-8'))
|
||||
finally:
|
||||
cache.close()
|
||||
except Exception:
|
||||
pass # a lost cache costs a flag, not a snapshot
|
||||
return req_path
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
"""Start a host process that outlives this one. Returns its pid.
|
||||
|
||||
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
|
||||
minutes with nobody watching, and a console window popping up over the
|
||||
engineer's Revit is the kind of thing that gets a tool switched off.
|
||||
"""
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
psi = ProcessStartInfo()
|
||||
psi.FileName = argv[0]
|
||||
psi.Arguments = S.quote(argv[1:])
|
||||
psi.UseShellExecute = False
|
||||
psi.CreateNoWindow = True
|
||||
psi.WorkingDirectory = REPO_ROOT
|
||||
return Process.Start(psi).Id
|
||||
|
||||
|
||||
def _job_knowledge_path():
|
||||
"""The job knowledge file, so the host pipeline reads the same layer.
|
||||
|
||||
Handed over as a path rather than as resolved values: the pipeline needs
|
||||
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
|
||||
block, and a button that copies six settings across is a button that
|
||||
forgets the seventh the day one is added.
|
||||
"""
|
||||
try:
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
return path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------- the sandbox cycle
|
||||
#
|
||||
# The new route, and the one the commission is about. Nothing here opens,
|
||||
# saves or closes a model: it writes the handoff and starts a host process
|
||||
# that does all of it in Revits that are not this one.
|
||||
|
||||
run_id = None
|
||||
if cycling and cycle_stop is None:
|
||||
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
started_pid = None
|
||||
argv = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block(),
|
||||
{'requested_by': 'SnapshotClose button, '
|
||||
'cycle route'})
|
||||
argv = [liftexport.find_python(know), CYCLE_TOOL,
|
||||
'--fresh-detach',
|
||||
'--request', req_path,
|
||||
'--detached-dir', detached_dir,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--revit-exe', revit_exe]
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
if log:
|
||||
argv.extend(['--log', log])
|
||||
job_knowledge = _job_knowledge_path()
|
||||
if job_knowledge:
|
||||
argv.extend(['--knowledge', job_knowledge])
|
||||
for command in batch:
|
||||
argv.extend(['--run', command])
|
||||
started_pid = _spawn(argv)
|
||||
except Exception:
|
||||
started_pid = None
|
||||
cycle_stop = 'the pipeline could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - running now, beside this session')
|
||||
if started_pid:
|
||||
output.print_md(
|
||||
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
|
||||
'-- it is not closed, not saved, and not read again.'
|
||||
.format(run_id, started_pid))
|
||||
output.print_md('It writes:')
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('then opens that copy in a Revit of its own, runs '
|
||||
'`{0}`, and closes it again. Its log lands beside the '
|
||||
'copy and its outcome in `{1}`.'
|
||||
.format(' + '.join(batch), log))
|
||||
output.print_md('The command it is running:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'cycle', 'host_pid': started_pid})
|
||||
else:
|
||||
output.print_md('**Not started: {0}**'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'cycle', 'reason': cycle_stop})
|
||||
elif cycling:
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - not run')
|
||||
output.print_md('Not started: {0}'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
|
||||
'reason': cycle_stop})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase C
|
||||
#
|
||||
# The OLD route, kept: requested here, run later, by a process that outlives
|
||||
# this one. Everything it needs goes into a request file -- `pyrevit run`
|
||||
# passes no arguments to the script it runs, so a file named by an environment
|
||||
# variable is the only handoff there is.
|
||||
#
|
||||
# This is the only place `snapshot.after_close` still means anything.
|
||||
|
||||
if closing and blocker is None:
|
||||
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
launched = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block())
|
||||
argv = [liftexport.find_python(know), DEFER_TOOL,
|
||||
'--request', req_path,
|
||||
'--runner', RUNNER,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--wait-pid', str(_revit_pid()),
|
||||
'--wait-minutes', str(wait_minutes),
|
||||
# The close-model ritual is the one case where waiting for the
|
||||
# whole machine to be free is still the right answer: nobody
|
||||
# is at the keyboard and nothing else is queued behind it.
|
||||
'--machine-free', 'require']
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
_spawn(argv)
|
||||
launched = argv
|
||||
except Exception:
|
||||
launched = None
|
||||
blocker = 'the deferred process could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - the detached copy, after Revit exits')
|
||||
if launched:
|
||||
output.print_md('Requested as `{0}`. It waits for this Revit '
|
||||
'(pid {1}) to exit -- and then for the machine to be '
|
||||
'free of Revit altogether -- then opens the model in a '
|
||||
'session of its own and writes:'
|
||||
.format(run_id, _revit_pid()))
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('The command it will run:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(
|
||||
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
|
||||
output.print_md('Its outcome is journalled to `{0}` and read out the '
|
||||
'next time this button is pressed.'.format(log))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'after-close', 'wait_pid': _revit_pid()})
|
||||
else:
|
||||
output.print_md('**Not requested: {0}**'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'after-close', 'reason': blocker})
|
||||
elif closing:
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - not run')
|
||||
output.print_md('No detached copy: {0}'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
|
||||
'reason': blocker})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase B
|
||||
|
||||
output.print_md('---')
|
||||
if not closing:
|
||||
if cycling:
|
||||
output.print_md('Revit stays open. The index is written and stamped, '
|
||||
'and the cycle is running in processes of its own -- '
|
||||
'nothing here was closed and your model was never '
|
||||
'touched.')
|
||||
else:
|
||||
output.print_md('Revit stays open, as asked. The index is written and '
|
||||
'stamped; nothing was closed.')
|
||||
# A normal completion for the STAY / cycle choices: Revit stays open,
|
||||
# the snapshot index is on disk. The CLOSE path below records its own
|
||||
# completion (Revit is about to exit, so this is the last write).
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
|
||||
report_path=report['path'] if report else None,
|
||||
report_text=('Revit stays open. Index written: {0}'
|
||||
.format(report['path'] if report else '?')))
|
||||
script.exit()
|
||||
|
||||
output.print_md('## Phase B - closing Revit')
|
||||
output.print_md('Revit will ask you about unsaved changes if it has any. '
|
||||
'That question is yours to answer -- this button never syncs '
|
||||
'and never saves the model.')
|
||||
|
||||
posted = False
|
||||
# There is no PostableCommand for quitting, so the internal command id is
|
||||
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
|
||||
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
|
||||
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
|
||||
# helper is a version-dependent convenience and this is the one step of the
|
||||
# button that cannot be retried by pressing it again.
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
HOST_APP.post_command('ID_APP_EXIT')
|
||||
posted = True
|
||||
except Exception:
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
from Autodesk.Revit.UI import RevitCommandId
|
||||
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
|
||||
if cmd is not None:
|
||||
HOST_APP.uiapp.PostCommand(cmd)
|
||||
posted = True
|
||||
except Exception:
|
||||
posted = False
|
||||
|
||||
if not posted:
|
||||
output.print_md('**Revit would not accept the close command.** The '
|
||||
'snapshot is written and stamped; close Revit yourself.')
|
||||
|
||||
# The CLOSE path's completion. If the close command was accepted Revit is
|
||||
# about to exit, so this record is the button's last write; if not, the
|
||||
# run still produced its snapshot and says so.
|
||||
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
|
||||
report_path=report['path'] if report else None,
|
||||
report_text=('Close posted: {0}. Index written: {1}'
|
||||
.format(posted,
|
||||
report['path'] if report else '?')))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Snapshot Close',
|
||||
doc_title=doc.Title if doc is not None else None,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
@@ -0,0 +1,28 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Tools"), not a
|
||||
# `title:` here -- `uimaker._produce_ui_panels` calls
|
||||
# `create_ribbon_panel(panel.name)`. Rename the folder to rename the panel.
|
||||
#
|
||||
# REORGANISED 2026-09-05, on the owner's own instruction: "把 ribbon 重新
|
||||
# organize 一下,加一个 Modeling pulldown,把 Reinforcing 和 TicketQC 放进去".
|
||||
# This panel used to hold four large icons side by side (reinforce, LIFT,
|
||||
# ticketqc, snapshot -- see the 2026-08-30 note this comment replaces). Two
|
||||
# of those four moved into a new `Modeling.pulldown` (ReinforceSuggest and
|
||||
# TicketQC, joined there by the new ReinforcingQC), and the third,
|
||||
# PanelLiftCheck, moved the other way onto Design.panel/DesignHub.pulldown --
|
||||
# see that bundle's own comment for why. SnapshotClose is the one large icon
|
||||
# still on this panel: it never belonged to the modelling/ticketing group,
|
||||
# it closes and archives a session, so it keeps its own button rather than
|
||||
# joining a pulldown about a different kind of work.
|
||||
#
|
||||
# What is left is one pulldown and one large button, side by side. Nothing
|
||||
# half-built may join them -- that is what Setup and Design are for.
|
||||
#
|
||||
# Every title here keeps each line under ~9 characters. A large ribbon button
|
||||
# is as wide as its widest line of text, so one long title makes one button
|
||||
# dwarf its neighbours, which is exactly what the owner objected to when
|
||||
# these four were spread across three panels.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- Modeling
|
||||
- SnapshotClose
|
||||
@@ -0,0 +1,20 @@
|
||||
# Panel order on the tab, left to right.
|
||||
#
|
||||
# Without this key pyRevit falls back to whatever order the filesystem hands
|
||||
# it, which is alphabetical -- that is how "Agent" ended up left of "QC".
|
||||
# The tab's own name on the ribbon is the FOLDER name (WillTools.tab):
|
||||
# `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)` and never
|
||||
# looks at a `title:` here, so do not add one expecting it to render.
|
||||
#
|
||||
# Four panels, and the split between them is what the owner asked for on
|
||||
# 2026-08-30: the four finished tools stand together as large icons, the
|
||||
# set-up commands collapse behind one arrow, the unbuilt design work
|
||||
# collapses behind another, and GOD_MOD keeps its own corner.
|
||||
#
|
||||
# A panel not listed below is not created at all
|
||||
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
|
||||
layout:
|
||||
- Tools
|
||||
- Setup
|
||||
- Design
|
||||
- Lab
|
||||