Practice
Cookbook
Complete, runnable recipes for the jobs an AutoCAD tool actually has to do, each with its sharp edge named.
Every recipe on this page is a whole file. Unless it says otherwise, it is the
script.py of a .pushbutton bundle: make the folder, paste the code,
click Reload, and the button is on the ribbon and its command (PY plus the title's
letters) is ready to type. Nothing is elided and nothing is pseudocode. Every
pyacad function called here exists, with the signature used; the site build checks
that against the library.
They are built the same way, because tools that touch a drawing have a shape. Read something, guard against the case where there is nothing to read, work the answer out in plain Python, write it back in as few calls as possible, then say what happened in one line.
A one-line status belongs in a toast. print() opens the output
window, so a script whose only output is one print gives the user a window with one
line in it. Use the output window when you have a report to show, a toast when you have a
sentence, and a banner when you have one number.
Selecting and editing entities#
Select everything on chosen layers#
A layer picker, then every object on the picked layers becomes the selection, ready for the
next command. The one subtlety is the filter: sel.get(layer=...) hands the text to
AutoCAD's selection filter, where #, @, .,
*, ?, ~, [, ],
- and , are wildcards. A layer called A.WALL would also
match A-WALL. A backquote in front of a character makes it literal, and a comma
separates alternatives, so several layers go in one call.
"""Selects every object on the layers you pick."""
from pyacad import forms, layers, sel, toast
SPECIAL = '#@.*?~[]-,`'
def literal(name):
"""A layer name as an AutoCAD wildcard that matches only that name."""
return ''.join('`' + c if c in SPECIAL else c for c in name)
names = forms.select_from_list(layers.names(), title='Select by layer',
multiselect=True, prompt='Select objects on which layers?')
if names:
handles = sel.get(layer=','.join(literal(name) for name in names))
if handles:
sel.set_current(handles)
toast.success('%d objects selected' % len(handles), ', '.join(names))
else:
toast.info('Nothing is on those layers')sel.get looks at model space and every layout, but never inside block
definitions: an object that only exists inside a block is not selectable on its own anyway.
Add space='model' to keep to model space.
Change the colour of the selected objects#
Works on the pickfirst set when there is one and asks for a selection when there is not, the
behaviour people expect from a native command. Each ents.set call is its own
transaction, so an object AutoCAD refuses to change (one on a locked layer) fails alone and the
rest still change.
"""Sets the colour of the selected objects."""
from pyacad import ents, forms, sel, toast
COLOURS = [('Red', 1), ('Yellow', 2), ('Green', 3), ('Cyan', 4), ('Blue', 5),
('Magenta', 6), ('White', 7), ('Grey', 8), ('ByLayer', 256), ('ByBlock', 0)]
handles = sel.current() or sel.pick_many('Select objects to recolour: ')
if handles:
colour = forms.select_from_list(COLOURS, title='Change colour')
if colour is not None:
changed = refused = 0
for handle in handles:
try:
ents.set(handle, color=colour)
changed += 1
except Exception: # AutoCAD's eOnLockedLayer, most often
refused += 1
if refused:
toast.warning('%d recoloured, %d refused' % (changed, refused),
'Objects on locked layers cannot change')
else:
toast.success('%d objects recoloured' % changed)The guard is colour is not None, not if colour:, because ByBlock
is colour 0 and would read as false.
Find and replace text#
Replaces text in single-line and multiline text, in the selection or else the whole
drawing. ents.get gives a TEXT's string and an MTEXT's raw contents, and
ents.set(text=...) writes the same field back.
"""Find and replace in TEXT and MTEXT."""
from pyacad import ents, forms, sel, toast
TITLE = 'Replace text'
find = forms.ask_string('Find:', title=TITLE)
if find:
replace = forms.ask_string('Replace "%s" with:' % find, title=TITLE)
if replace is not None:
handles = sel.current() or sel.get(type=['TEXT', 'MTEXT'])
changed = 0
for handle in handles:
if ents.type_of(handle) not in ('TEXT', 'MTEXT'):
continue
text = ents.get(handle)['text']
new = text.replace(find, replace)
if new != text:
ents.set(handle, text=new)
changed += 1
if changed:
toast.success('Replaced in %d texts' % changed)
else:
toast.info('"%s" was not found' % find)An MTEXT's contents are the raw string, with codes such as \P for a new
paragraph and {\fArial|b1;...} for a font change. A word that changes formatting
half way through is not one run of text in that string, so it will not be found, and a find
string that looks like a code can damage the formatting. Keep find strings to plain words.
Count entities by type#
A quick census of model space, as a table and a bar chart in the output window.
ents.type_of needs no transaction, so it is the cheap way to classify thousands of
handles.
"""Counts the entities in model space by DXF type."""
from collections import Counter
from pyacad import ents, output, toast
counts = Counter(ents.type_of(handle) for handle in ents.iter())
if not counts:
toast.info('Model space is empty')
else:
rows = counts.most_common()
output.print_md('## %d entities in model space' % sum(counts.values()))
output.print_table(rows, ['Type', 'Count'])
top = rows[:12]
output.chart_bar([r[0] for r in top], [r[1] for r in top], 'Most common types')Pass a layout name to ents.iter to count a sheet instead, or
'BLOCK:Name' to see what a block definition is made of.
Select blocks by attribute value#
Pick a tag, type part of a value, and every block reference whose attribute contains it is
selected. blocks.snapshots() reads every reference in one transaction, so the
filtering is plain Python over dicts.
"""Selects block references whose attribute value contains some text."""
from pyacad import blocks, forms, sel, toast
TITLE = 'Select by attribute'
def value_of(ref, tag):
"""The ref's value for tag, matching the tag in any case; '' when it has none."""
for name, value in ref['attributes'].items():
if name.upper() == tag.upper():
return value
return ''
refs = blocks.snapshots()
tags = blocks.attribute_tags(refs)
if not tags:
toast.info('No block in this drawing has attributes')
else:
tag = forms.select_from_list(tags, title=TITLE, prompt='Which attribute?')
if tag:
text = forms.ask_string('Select blocks whose %s contains:' % tag, title=TITLE)
if text:
needle = text.lower()
hits = [r['handle'] for r in refs if needle in value_of(r, tag).lower()]
if hits:
sel.set_current(hits)
toast.success('%d blocks selected' % len(hits), '%s contains "%s"' % (tag, text))
else:
toast.info('No %s contains "%s"' % (tag, text))Layers#
Rename layers by pattern#
Pick the layers with a wildcard, say what to replace in their names, see the whole plan,
then rename. Layer 0, Defpoints and xref layers (the ones with a |) are left out
before the plan is shown, because AutoCAD would refuse them anyway.
"""Renames layers: replaces text in every name that matches a pattern."""
from pyacad import forms, layers, toast
TITLE = 'Rename layers'
pattern = forms.ask_string('Rename layers matching (* and ? allowed):', '*', TITLE)
find = pattern and forms.ask_string('Text to replace in those names:', '', TITLE)
if find:
replace = forms.ask_string('Replace "%s" with:' % find, '', TITLE)
if replace is not None:
plan = []
for name in layers.names():
if '|' in name or name.lower() in ('0', 'defpoints'):
continue
if layers.matches(name, pattern):
new = name.replace(find, replace)
if new != name:
plan.append((name, new))
if not plan:
toast.info('No layer name would change')
else:
preview = '\n'.join('%s -> %s' % pair for pair in plan[:25])
if len(plan) > 25:
preview += '\n...and %d more' % (len(plan) - 25)
if forms.confirm('Rename %d layers?\n\n%s' % (len(plan), preview), TITLE):
failed = []
for old, new in plan:
try:
layers.rename(old, new)
except ValueError as error:
failed.append(str(error))
if failed:
forms.alert('%d renamed, %d not:\n\n%s'
% (len(plan) - len(failed), len(failed), '\n'.join(failed)), TITLE)
else:
toast.success('%d layers renamed' % len(plan))The pattern match ignores case but str.replace does not, so
wall will not replace WALL. Two layers whose new names collide fail
with "already exists", and the alert lists them; merging is a different job
(layers.merge).
Freeze layers by prefix#
Asks for a prefix, remembers the last one, and freezes every layer that starts with it. The
answer goes back into the tool's settings with script.save_config, so the next
run offers it again.
"""Freezes every layer whose name starts with a prefix."""
from pyacad import forms, layers, script, toast
cfg = script.get_config({'prefix': 'Z-'})
prefix = forms.ask_string('Freeze every layer starting with:', cfg['prefix'], 'Freeze by prefix')
if prefix:
script.save_config({'prefix': prefix})
frozen = layers.freeze(prefix + '*')
if frozen:
toast.success('%d layers frozen' % frozen, 'Starting with ' + prefix)
else:
toast.info('Nothing to freeze', 'No thawed layer starts with ' + prefix)Freezing a layer freezes it everywhere: in model space and in every viewport of every
layout, which is usually what "freeze it in all layouts" means. The current layer is skipped
(AutoCAD will not freeze it) and does not count. Freezing in some viewports only is
VPLAYER's job, which pyacad does not wrap. A prefix that contains * or
? works as a wildcard, which is a feature.
Create a standard set of layers#
Creates whichever layers of an office standard are missing and leaves existing ones alone. The list lives at the top of the script, where the next person can see and change it.
"""Adds the office standard layers this drawing is missing."""
from pyacad import forms, layers, toast
STANDARD = [
# name colour linetype
('A-WALL', 1, 'Continuous'),
('A-DOOR', 3, 'Continuous'),
('A-GLAZ', 4, 'Continuous'),
('A-ANNO-TEXT', 2, 'Continuous'),
('A-ANNO-DIMS', 2, 'Continuous'),
('A-HIDDEN', 8, 'HIDDEN'),
]
created, problems = [], []
for name, colour, linetype in STANDARD:
if layers.exists(name):
continue
try:
layers.create(name, colour, linetype)
created.append(name)
except ValueError as error:
problems.append(str(error))
if problems:
forms.alert('Created %d layers. Not created:\n\n%s' % (len(created), '\n'.join(problems)),
'Standard layers')
elif created:
toast.success('%d standard layers added' % len(created), ', '.join(created))
else:
toast.info('Every standard layer is already here')layers.create needs the linetype to be loaded in the drawing already and says
No linetype is named HIDDEN. when it is not. Loading one with
doc.send_command does not help inside the same run, because the command only runs
after the script finishes; load linetypes in your template instead.
Save and restore a layer state per drawing#
A two-button layer state: save every layer's on, frozen and locked flags for this drawing,
and put them back later. per_document=True keeps one saved state per drawing, so
saving in one drawing never overwrites another's.
"""Saves this drawing's layer state, or restores the saved one."""
from pyacad import forms, layers, script, toast
TITLE = 'Layer state'
choice = forms.ask_options('Layer state for this drawing', ['Save', 'Restore'], TITLE)
if choice == 'Save':
script.store_data('state', layers.snapshot(), per_document=True)
toast.success('Layer state saved', '%d layers' % len(layers.names()))
elif choice == 'Restore':
saved = script.load_data('state', per_document=True)
if not saved:
toast.info('No layer state saved for this drawing yet')
else:
changed = layers.restore(saved)
toast.success('Layer state restored', '%d layers changed' % changed)The per-drawing file is keyed on the drawing's full path, so a drawing that is moved or
renamed starts with nothing saved, and every untitled drawing shares one file. The data lands in
%APPDATA%\pyACAD\data, per Windows user. layers.restore skips layers
that have been deleted since and never freezes the current layer.
Report layers in the output window#
Every layer with its state and how many objects are on it, as a table you can save as HTML.
The object count is one filtered select-all per layer, so it reuses the literal
helper from the first recipe to keep layer
names from being read as wildcards.
"""Layer report: state and object count for every layer."""
from pyacad import doc, layers, output, sel
SPECIAL = '#@.*?~[]-,`'
def literal(name):
return ''.join('`' + c if c in SPECIAL else c for c in name)
def yes(flag):
return 'yes' if flag else ''
snapshot = layers.snapshot()
rows = []
for name in sorted(snapshot, key=str.lower):
state = snapshot[name]
rows.append([name, state['color'], state['linetype'], yes(state['is_off']),
yes(state['is_frozen']), yes(state['is_locked']),
len(sel.get(layer=literal(name)))])
output.set_title('Layers: ' + doc.path())
output.print_md('## %d layers' % len(rows))
output.print_table(rows, ['Layer', 'Colour', 'Linetype', 'Off', 'Frozen', 'Locked', 'Objects'])
empty = [r[0] for r in rows if r[-1] == 0]
if empty:
output.print_md('**%d layers hold nothing** in model space or the layouts.' % len(empty))An empty layer here may still be used inside a block definition, which select-all does not
see, so "holds nothing" is not the same as "can be purged". audit.purge knows the
difference.
Blocks and attributes#
Count blocks by name to Excel#
One row per block name with its count, written to a workbook beside the drawing.
blocks.schedule_rows does the counting and sorting; xl.write needs
no Excel and no extra package.
"""Writes a block count (name and number of references) to Excel."""
import os
from pyacad import blocks, forms, script, toast, xl
refs = blocks.snapshots()
if not refs:
toast.info('No block references in this drawing')
else:
headers, rows = blocks.schedule_rows(refs)
drawing = script.get_document_path()
if drawing and drawing != 'untitled':
suggested = os.path.splitext(drawing)[0] + ' - Block count.xlsx'
else:
suggested = 'Block count.xlsx'
path = forms.save_file('Excel workbook (*.xlsx)|*.xlsx', suggested, 'Save block count')
if path:
try:
if os.path.exists(path):
os.remove(path) # the Save dialog already asked
xl.write(path, rows, headers=headers, sheet='Block count')
toast.success('%d block names counted' % len(rows), path)
except (IOError, OSError) as error:
toast.error('Could not write the workbook', 'Is it open in Excel? %s' % error)The os.remove matters: xl.write into an existing workbook keeps
its other sheets and replaces only the named one, which is right for a log and wrong for a
fresh export. blocks.schedule_rows(refs, by_layer=True) adds a Layer column.
Snapshots leave xref attachments out; blocks.counts() would include them.
Export the attributes of one block to CSV#
Pick a block, get a CSV with one row per reference and one column per attribute tag, in the order the block defines them. CSV opens in Excel, imports into anything, and diffs well in version control.
"""Exports every reference of one block, with its attributes, to CSV."""
import csv
import io
from pyacad import blocks, forms, toast
name = forms.select_from_list(blocks.definitions(), title='Export attributes',
prompt='Export which block?')
if name:
refs = blocks.snapshots(blocks.references(name))
tags = blocks.attribute_tags(refs)
if not refs:
toast.info('%s is not used in this drawing' % name)
elif not tags:
toast.info('%s has no attributes' % name)
else:
path = forms.save_file('CSV files (*.csv)|*.csv', name + ' attributes.csv',
'Export attributes')
if path:
with io.open(path, 'w', newline='', encoding='utf-8-sig') as stream:
writer = csv.writer(stream)
writer.writerow(['Handle', 'Layer', 'Space'] + tags)
for ref in refs:
values = ref['attributes']
writer.writerow([ref['handle'], ref['layer'], ref['space']]
+ [values.get(tag, '') for tag in tags])
toast.success('%d %s references exported' % (len(refs), name), path)utf-8-sig writes a byte order mark, which is what makes Excel read accented
characters correctly when it opens a CSV by double-click. Keep the Handle column: it is how a
later tool matches rows back to references (the shipped Excel to Blocks tool does exactly that
with blocks.diff_rows).
Insert a block at picked points#
Choose a block, then click as many insertion points as you like; Enter or Esc stops. A banner holds the instruction on screen while the command line asks for each point.
"""Inserts the chosen block at every point you pick."""
from pyacad import banner, blocks, forms, layers, sel, toast
name = forms.select_from_list(blocks.definitions(), title='Insert block',
prompt='Insert which block?')
if name:
layer = layers.current()
placed = 0
banner.prompt('Placing %s on %s' % (name, layer), 'Pick points; Enter or Esc to finish')
try:
while True:
point = sel.pick_point('Insertion point for %s: ' % name)
if point is None:
break
blocks.insert(name, point, layer=layer)
placed += 1
finally:
banner.clear()
if placed:
toast.success('%d %s placed' % (placed, name))The try/finally is there because a banner from
banner.prompt stays until it is cleared: without it an error half way would leave
the instruction on screen. The block goes into the current space with the definition's default
attribute values; pass attributes={'TAG': 'value'} to fill them, and
rotation in radians (math.radians(90)).
Put a block schedule table in the drawing#
Counts the drawing's block references and draws the result as an AutoCAD table at a picked point, in the current table style.
"""Draws a block count as an AutoCAD table."""
from pyacad import blocks, sel, toast
refs = blocks.snapshots()
if not refs:
toast.info('No block references to schedule')
else:
headers, rows = blocks.schedule_rows(refs)
corner = sel.pick_point('Top left corner of the schedule: ')
if corner is not None:
blocks.insert_table(corner, headers, rows, title='Block schedule')
toast.success('Schedule of %d blocks placed' % len(rows))The table goes into the current space, so run it on a layout tab to put the schedule on a sheet. Columns are sized from the text, and the text height comes from the table style, so set up the style (TABLESTYLE) once in your template and every schedule matches.
Renumber room tags in reading order#
Numbers every TAG-ROOM reference from a starting number, top row first and left
to right within a row. All the changes go into the drawing in one
blocks.apply_changes call, which reports any reference it could not change instead
of stopping.
"""Renumbers room tags top to bottom, left to right."""
from pyacad import blocks, forms, toast
BLOCK = 'TAG-ROOM'
TAG = 'ROOM_NO'
ROW = 1000.0 # tags within this height of each other count as one row
refs = [r for r in blocks.snapshots(blocks.references(BLOCK)) if TAG in r['attributes']]
if not refs:
toast.info('No %s with a %s attribute' % (BLOCK, TAG))
else:
start = forms.ask_number('First number', default=101, min_value=0, title='Renumber rooms')
if start is not None:
refs.sort(key=lambda r: (-round(r['position'][1] / ROW), r['position'][0]))
changes = {}
for offset, ref in enumerate(refs):
changes[ref['handle']] = {'attributes': {TAG: str(int(start) + offset)}}
result = blocks.apply_changes(changes)
if result['failed']:
toast.warning('%d renumbered, %d not' % (result['updated'], len(result['failed'])),
'References on locked layers cannot change')
else:
toast.success('%d rooms renumbered' % result['updated'])result['updated'] counts references whose value actually changed, so running
the tool twice reports 0 the second time. The tag name must be spelled as the drawing spells it
(attribute tags are almost always upper case).
Measuring#
Total length of polylines on a layer#
Pick a layer and get the total length of every polyline on it, in the drawing's own units,
as a banner across the bottom of the window. ents.lengths measures them all in one
transaction.
"""Total length of the polylines on one layer."""
from pyacad import banner, ents, forms, layers, sel, toast, units
SPECIAL = '#@.*?~[]-,`'
def literal(name):
return ''.join('`' + c if c in SPECIAL else c for c in name)
name = forms.select_from_list(layers.names(), title='Polyline length', prompt='Which layer?')
if name:
handles = sel.get(type=['LWPOLYLINE', 'POLYLINE'], layer=literal(name))
measured = [r['length'] for r in ents.lengths(handles) if r['length'] is not None]
if not measured:
toast.info('No polylines on %s' % name)
else:
unit = units.current()
total = sum(measured)
detail = '%d polylines on %s' % (len(measured), name)
if unit != 'Undefined':
detail += ' (%.2f m)' % units.to_metres(total, unit)
banner.success(units.format_length(total, unit), detail)units.format_length writes feet and inches for an Inches or Feet drawing
(148'-3.25") and the number with its unit otherwise (45230.00 mm).
A drawing whose INSUNITS is 0 has no units, so it gets the bare number and no metres.
Length by layer for a selection#
Select pipes, ducts or cable runs, and get a takeoff: pieces and total length per layer.
ents.length_by_layer does the grouping, so the script is three calls and a
table.
"""Length takeoff by layer for the selected lines, arcs, polylines and splines."""
from pyacad import ents, output, sel, toast, units
handles = sel.current() or sel.pick_many('Select the runs to measure: ')
totals = ents.length_by_layer(ents.lengths(handles))
if not totals:
toast.info('Nothing measurable selected', 'Lines, arcs, polylines and splines count')
else:
unit = units.current()
rows = [[t['layer'], t['count'], units.format_length(t['length'], unit)] for t in totals]
grand = sum(t['length'] for t in totals)
output.print_md('## Length takeoff')
output.print_table(rows, ['Layer', 'Pieces', 'Length'])
output.print_md('**Total:** %s' % units.format_length(grand, unit))Circles and ellipses are not in ents.LENGTH_TYPES, because a takeoff counts
runs, not outlines; pass types=('LINE', 'ARC', 'CIRCLE') to
ents.lengths when you want them.
Xrefs, layouts and plotting#
List xrefs with broken paths#
Every xref AutoCAD could not find, with its saved path, in the output window. When all of them resolve, a toast says so and no window opens.
"""Lists the xrefs whose files cannot be found."""
from pyacad import output, toast, xrefs
rows = xrefs.list()
broken = [r for r in rows if r['status'] in ('not-found', 'unresolved')]
if not rows:
toast.info('This drawing has no xrefs')
elif not broken:
toast.success('All %d xrefs resolve' % len(rows))
else:
output.print_md('## %d of %d xrefs cannot be found' % (len(broken), len(rows)))
output.print_table(
[[r['name'], xrefs.status_label(r['status']), r['path'] or '(no path)',
'nested' if r['nested'] else ''] for r in broken],
['Xref', 'Status', 'Saved path', ''])
output.print_md('Nested xrefs follow their parent: fix the parent first.')An unloaded xref is not broken (someone unloaded it on purpose), so 'unloaded'
is left out. path is what is saved in the drawing, which may be relative;
found_at is where AutoCAD actually loaded a resolved one from.
Repath xrefs after a server move#
Replace part of every xref path (an old server or drive) with something new. The plan is
worked out first by the pure xrefs.plan_repath, shown to the user, and only then
applied. Afterwards xrefs.check_loaded turns "repathed, but the file is still not
there" into a reported failure, because AutoCAD accepts a path it cannot resolve without a
word.
"""Repaths xrefs: replaces text in their saved paths, after a preview."""
from pyacad import forms, toast, xrefs
TITLE = 'Repath xrefs'
def ask_replacement():
"""(find, replace) from the user, or None when they cancel."""
find = forms.ask_string('Replace this in every xref path:', '', TITLE)
if not find:
return None
replace = forms.ask_string('Replace "%s" with:' % find, '', TITLE)
return None if replace is None else (find, replace)
def apply(rows, plan):
"""Repaths every row the plan changes; (name, error) per xref, error None on success."""
results = []
for row, (old, new, note) in zip(rows, plan):
if note:
continue
try:
xrefs.repath(row['name'], new)
results.append((row['name'], None))
except Exception as error:
results.append((row['name'], str(error) or 'failed'))
return xrefs.check_loaded(results, xrefs.list())
rows = xrefs.list()
answer = ask_replacement() if rows else None
if not rows:
toast.info('This drawing has no xrefs')
elif answer:
find, replace = answer
plan = xrefs.plan_repath(rows, 'replace', find=find, replace=replace)
if all(note for old, new, note in plan):
toast.info('No xref path contains "%s"' % find)
elif forms.confirm(xrefs.format_plan(rows, plan, limit=15), TITLE):
results = apply(rows, plan)
if any(error for name, error in results):
forms.alert(xrefs.summarize(results, 'Repathed', limit=15), TITLE)
else:
toast.success(xrefs.headline(results, 'Repathed'))The replace ignores case, as Windows paths do. Nested xrefs are never changed: their path is
saved in the parent drawing, not in this one. For a whole-drawing move to relative paths,
xrefs.to_relative() is one call.
Batch plot layouts to PDF#
Tick the layouts, pick a folder, and each one is plotted to its own PDF named after the drawing and the layout, with a progress window that can be cancelled between sheets. A sheet that fails is reported at the end rather than stopping the batch.
"""Plots the chosen layouts to PDF, one file each."""
import os
from pyacad import doc, forms, layouts, plot, toast
TITLE = 'Batch plot'
names = layouts.names()
picked = forms.select_from_list(names, title=TITLE, multiselect=True, checked=names,
prompt='Plot which layouts?')
if picked:
here = doc.path()
start = None if here == 'untitled' else os.path.dirname(here)
folder = forms.pick_folder('Save the PDFs to', start)
if folder:
jobs = plot.batch_names(here, picked, folder)
done, failed = [], []
try:
with forms.progress(TITLE) as bar:
for index, (layout, target) in enumerate(jobs):
bar.check()
bar.update(float(index) / len(jobs), 'Plotting %s' % layout)
try:
plot.to_pdf(layout, target)
done.append(target)
except Exception as error:
failed.append('%s: %s' % (layout, error))
except forms.Cancelled:
failed.append('Cancelled after %d of %d' % (len(done), len(jobs)))
if failed:
forms.alert('%d PDFs written to %s\n\nNot plotted:\n%s'
% (len(done), folder, '\n'.join(failed)), TITLE)
else:
toast.success('%d PDFs written' % len(done), folder)plot.to_pdf starts from each layout's own page setup and only swaps the device
to DWG To PDF, so a layout set up for A1 plots on A1. When the layout's paper is not one
DWG To PDF offers, it keeps the device's default paper; pass paper= (a name from
plot.papers('DWG To PDF.pc3')) or page_setup= to be explicit. The
plot is in the foreground, so AutoCAD is busy until each sheet is written.
Drawing health#
Purge, clean and audit in one click#
Deletes zero-length lines and polylines, purges everything unused, then queues AUDIT, and
reports with the same wording as the shipped Drawing Cleanup tool by handing the counts to
audit.summary and audit.format_report.
"""Zero-length cleanup, purge, then AUDIT."""
from pyacad import audit, forms, toast
if forms.confirm('Delete zero-length geometry, purge everything unused, then run AUDIT?',
'Clean drawing'):
erased, skipped = audit.erase(audit.zero_length())
purged = audit.purge()
audit.audit(fix=True)
steps = [('zero_length', {'count': erased, 'skipped': skipped}),
('purge', purged),
('audit', None)]
toast.success(audit.summary(steps), audit.format_report(steps))The order matters: erasing first lets the purge remove a layer or block that only the
zero-length geometry was keeping alive. audit.audit only queues the command, so it
runs after the toast, as its own command, and reports on the command line. Everything before
it is one undo step.
Delete duplicate geometry, with a confirmation#
Finds entities stacked on top of each other (same space, type, layer and geometry), says how many, and deletes every copy but one. Copies on locked layers are left and counted.
"""Deletes duplicate entities, keeping one of each."""
from pyacad import audit, forms, toast
groups = audit.duplicates()
extras = audit.duplicate_extras(groups)
if not extras:
toast.success('No duplicates found')
elif forms.confirm('%d duplicate copies in %d groups. Delete them, keeping one of each?'
% (len(extras), len(groups)), 'Duplicates'):
erased, skipped = audit.erase(extras)
if skipped:
toast.warning('%d duplicates deleted' % erased, '%d on locked layers left' % skipped)
else:
toast.success('%d duplicates deleted' % erased)Two block references count as duplicates only when their attribute values match too, so two
tags placed on one point with different numbers are both kept. Only lines, circles, arcs,
lightweight polylines, text, mtext and block references are compared; see
audit.geometry_key.
Button kinds, hooks and panes#
The next four recipes are one small extension: a toggle that switches a save log on and off, the hook that writes the log, a smartbutton, and a dock pane. Laid out together it looks like this:
MyTools.extension\
hooks\
doc-saved.py # the save log hook
MyTools.tab\
Drawing.panel\
Save_Log.toggle\
bundle.yaml
script.py
icon.on.png
icon.off.png
Apply_Standard.smartbutton\
script.py
Layer_Picker.dockpane\
bundle.yaml
pane.xaml
script.pyA toggle that remembers its state#
A *.toggle bundle's on or off state (script.get_toggle_state) lasts
only for the AutoCAD session and starts off. To remember a choice across restarts, keep the real
value in pyacad.settings under a key you choose, and use the toggle state only to
show it. A fixed key also lets the hook below read the same value.
title: Save Log
tooltip: Logs every save of every drawing to %APPDATA%\pyACAD\save-log.csv while on."""Switches the save log on or off; hooks\\doc-saved.py reads the same setting."""
from pyacad import script, settings, toast
cfg = settings.load('save_log', {'on': False})
cfg['on'] = not cfg['on']
settings.save('save_log', cfg)
script.set_toggle_state(cfg['on'])
toast.success('Save log is on' if cfg['on'] else 'Save log is off')The click flips the saved value, not the icon's, so the setting is always what the user last
chose. The sharp edge is the first session after a restart: the icon starts off even when the
saved value is on (nothing but the toggle's own script can set its state), and the first click
turns the log off. Say so in the tooltip if your users will notice, or use
title_on: and tooltip_on: in bundle.yaml so the on state reads
differently.
A hook that logs every save#
A file in the extension's hooks folder named after an event runs every time the
event fires, with no click. This one appends a CSV line per save while the toggle above is on.
Hooks run outside any command and must be fast; this one does a settings read and one file
append.
"""Appends one CSV line per save while the Save Log toggle is on."""
import csv
import datetime
import io
import os
from pyacad import script, settings
if settings.load('save_log', {'on': False})['on']:
folder = os.path.join(os.environ.get('APPDATA', ''), 'pyACAD')
if not os.path.isdir(folder):
os.makedirs(folder)
drawing = script.get_document_path() or '(no drawing)'
with io.open(os.path.join(folder, 'save-log.csv'), 'a', newline='', encoding='utf-8') as stream:
csv.writer(stream).writerow([
datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
os.environ.get('USERNAME', ''),
drawing,
])Three things make this hook safe. It reads its switch with settings.load and a
fixed key, not script.get_config, which in a hook would answer for whichever
button ran last. It uses script.get_document_path(), which never raises. And it
touches nothing in the drawing, so it needs no doc.lock(). The hook's globals tell
you the event (__event__['name'] is 'doc-saved') but not which drawing
was saved; the active drawing is the one SAVE and QSAVE just wrote. A hook that raises three
times in a row is switched off until the next Reload, and its tracebacks go to the pyACAD
log.
A smartbutton that disables itself#
A *.smartbutton runs its script once more while the ribbon is built, with
__selfinit__ set to True and a __button__ to change its
own title, tooltip, icon and enabled state. This one applies an office layer standard from a
CSV on the network, and greys itself out when the CSV is not reachable, so nobody clicks a
button that cannot work.
"""Applies the office layer standard (renames, merges and creates layers)."""
import io
import os
STANDARD = r'\\server\cad\standards\layers.csv'
if __selfinit__:
if not os.path.isfile(STANDARD):
__button__.Enabled = False
__button__.Tooltip = 'Layer standard not found at %s. Reload once it is back.' % STANDARD
else:
from pyacad import forms, layers, toast
with io.open(STANDARD, encoding='utf-8') as stream:
mapping = layers.parse_mapping(stream.read())
plan = layers.plan_mapping(layers.names(), mapping)
work = [step for step in plan if step[0] != 'skip']
if not work:
toast.success('Layers already follow the standard')
elif forms.confirm('\n'.join(layers.describe_action(step) for step in plan[:30]),
'Apply layer standard (%d steps)' % len(work)):
failed = []
for step in work:
try:
if step[0] == 'rename':
layers.rename(step[1], step[2])
elif step[0] == 'merge':
layers.merge(step[1], step[2])
elif step[0] == 'create':
layers.create(step[1], step[2] or 7, step[3] or 'Continuous')
except ValueError as error:
failed.append(str(error))
if failed:
forms.alert('\n'.join(failed), 'Some steps failed')
else:
toast.success('Layer standard applied', '%d steps' % len(work))The self-init run happens at startup and at every Reload, never again in between, so a file
that comes back later needs a Reload. Keep self-init cheap: it holds up the ribbon, and a
check against a server that is down can take seconds. The pyacad import sits in the
else branch because self-init needs none of it. The CSV format is the one
layers.parse_mapping reads: old,new[,colour][,linetype], with
wildcards in the old name.
A dock pane with a live layer list#
A *.dockpane is an AutoCAD palette: pane.xaml is its content and
script.py runs once against it with the loaded root as __pane__
(Find(name), Content, Visible, Title). The
ribbon gets a toggle that shows and hides it. This pane lists the drawing's layers with a filter
box and makes the chosen one current.
<DockPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="10">
<TextBox x:Name="Filter" DockPanel.Dock="Top" Padding="4,3" />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button x:Name="MakeCurrent" Content="Make current" Padding="10,3" Margin="0,0,6,0" />
<Button x:Name="Refresh" Content="Refresh" Padding="10,3" />
</StackPanel>
<ListBox x:Name="Names" Margin="0,8,0,0" />
</DockPanel>"""Layer picker palette: filter the layer list, double-click to make one current."""
from pyacad import doc, layers, toast
pane = __pane__
box = pane.Find('Filter')
listing = pane.Find('Names')
known = []
def show(sender=None, args=None):
listing.Items.Clear()
for name in layers.filter_names(known, box.Text):
listing.Items.Add(name)
def refresh(sender=None, args=None):
del known[:]
try:
with doc.lock():
known.extend(layers.names())
except RuntimeError:
pass # no drawing open
show()
def make_current(sender, args):
name = listing.SelectedItem
if name is None:
return
try:
with doc.lock():
layers.set_current(str(name))
toast.success('Current layer: %s' % name)
except Exception as error:
toast.error('Could not make %s current' % name, str(error))
box.TextChanged += show
listing.MouseDoubleClick += make_current
pane.Find('MakeCurrent').Click += make_current
pane.Find('Refresh').Click += refresh
refresh()Every handler runs long after script.py returned, outside any command, which is
why each one takes doc.lock() before touching the drawing. The list is read when
the pane is built and on Refresh; it does not follow you to another drawing on its own. For the
same reason, do not use script.get_config in a pane: use
pyacad.settings with your own key. AutoCAD remembers the palette's size, dock side
and visibility under the folder name, so renaming the folder forgets them.
A XAML dialog bundle#
When a tool needs three or four answers at once, chained prompts get tedious; a small WPF
window is the next step. forms.WPFWindow loads a XAML file from the bundle folder,
gives it pyACAD's window chrome and shows it modally over AutoCAD. This one creates a layer.
New_Layer.pushbutton\
layout.xaml
script.py<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="New layer" Width="320" SizeToContent="Height"
WindowStartupLocation="CenterScreen" ResizeMode="NoResize">
<StackPanel Margin="16">
<TextBlock Text="Name" />
<TextBox x:Name="LayerName" Margin="0,4,0,10" />
<TextBlock Text="Colour (1 to 255)" />
<TextBox x:Name="Colour" Text="7" Margin="0,4,0,10" Width="60" HorizontalAlignment="Left" />
<CheckBox x:Name="MakeCurrent" Content="Make it the current layer" IsChecked="True" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
<Button x:Name="OkButton" Content="Create" Width="80" Margin="0,0,8,0" IsDefault="True" />
<Button Content="Cancel" Width="80" IsCancel="True" />
</StackPanel>
</StackPanel>
</Window>"""Creates a layer from a small dialog."""
from pyacad import forms, layers, toast
win = forms.WPFWindow('layout.xaml')
def on_create(sender, args):
win.close(True)
win['OkButton'].Click += on_create
if win.show_dialog():
name = win['LayerName'].Text.strip()
try:
layers.create(name, int(win['Colour'].Text))
if win['MakeCurrent'].IsChecked:
layers.set_current(name)
toast.success('Layer %s created' % name)
except ValueError as error:
toast.error('Layer not created', str(error))Cancel needs no handler: IsCancel="True" closes the window with a false result,
and so does Esc. int() of something that is not a number raises
ValueError too, so one except covers a bad colour and a bad name.
Read the fields after show_dialog() returns; the window's elements stay readable
after it closes.
Beyond the ribbon#
Call a tool from AutoLISP#
Every pushbutton is an AutoCAD command, and AutoLISP can call it as a function:
(pyacad "PYNAME" args...) runs the script with the arguments in
__args__ and returns whatever the script put in __result__. This
bundle, Layer_Stats.pushbutton, becomes the command PYLAYERSTATS.
"""Counts layers matching a pattern. From LISP: (pyacad "PYLAYERSTATS" "A-*")"""
from pyacad import layers, script, toast
args = script.get_args()
pattern = str(args[0]) if args else '*'
snapshot = layers.snapshot()
matching = sorted(name for name in snapshot if layers.matches(name, pattern))
frozen = [name for name in matching if snapshot[name]['is_frozen']]
__result__ = {'pattern': pattern, 'count': len(matching),
'frozen': len(frozen), 'names': matching}
if not args: # clicked on the ribbon: nobody reads __result__
toast.info('%d layers match %s' % (len(matching), pattern), '%d frozen' % len(frozen))On the LISP side a dict comes back as an association list of two-element lists, so
assoc and cadr read it:
(setq stats (pyacad "PYLAYERSTATS" "A-*"))
;; (("pattern" "A-*") ("count" 12) ("frozen" 3) ("names" ("A-ANNO" "A-DOOR" ...)))
(cadr (assoc "frozen" stats)) ; 3__result__ must be something JSON can hold (dicts, lists, strings, numbers,
True, False, None); anything else, or a script that
fails, returns nil and logs why. A single value comes back bare: a script that sets
__result__ = 12 returns 12. Strings from LISP arrive as str, integers
as int and reals as float. Typed at the command line through
PYACAD (PYACAD, then PYLAYERSTATS A-*), every argument
arrives as a string.
A CPython tool with pandas#
The default engine is IronPython, which runs inside AutoCAD with no install but cannot load
compiled packages such as numpy and pandas. A bundle that needs them declares
engine: cpython and runs on the 64-bit Python installed on the machine; the
pyacad library works the same there. This one builds a block-by-layer pivot with
pandas and saves it to Excel.
title: Block Pivot
tooltip: Block counts by layer as an Excel pivot. Needs pandas and openpyxl.
engine: cpython"""Block references counted by block and layer, saved to Excel with pandas."""
import pandas as pd
from pyacad import blocks, forms, toast
refs = blocks.snapshots()
if not refs:
toast.info('No block references in this drawing')
else:
frame = pd.DataFrame([{'Block': r['name'], 'Layer': r['layer']} for r in refs])
pivot = pd.crosstab(frame['Block'], frame['Layer'])
pivot['Total'] = pivot.sum(axis=1)
pivot = pivot.sort_values('Total', ascending=False)
path = forms.save_file('Excel workbook (*.xlsx)|*.xlsx', 'Block pivot.xlsx',
'Save block pivot')
if path:
pivot.to_excel(path, sheet_name='Pivot')
toast.success('%d blocks by %d layers' % (len(pivot), len(pivot.columns) - 1), path)Install the packages into the same Python pyACAD finds (py -m pip install pandas
openpyxl), then Reload. The first CPython tool in a session takes a moment while the
interpreter starts; after that it stays loaded until AutoCAD closes, so changes to an installed
package need an AutoCAD restart. Values from the library are plain Python
(blocks.snapshots returns dicts of str and float), so they
go straight into a DataFrame. A CPython bundle cannot share module state with IronPython
ones: each engine has its own interpreter, so pass data between them through files or
pyacad.settings, not module globals.
A long job with progress and cancel#
The batch functions take progress and cancelled callbacks and call
them every 200 items. Wire them to forms.progress and a long measurement gets a
moving bar and a working Cancel button, with no loop of your own.
"""Measures every line, arc, polyline and spline in the drawing, with a cancellable bar."""
from pyacad import ents, forms, output, sel, toast, units
handles = sel.get(type=list(ents.LENGTH_TYPES))
if not handles:
toast.info('Nothing to measure')
else:
stopped = []
with forms.progress('Measuring %d curves' % len(handles)) as bar:
def report(index, total):
if not bar.update(float(index) / total, 'Measured %d of %d' % (index, total)):
stopped.append(True)
rows = ents.lengths(handles, progress=report, cancelled=lambda: bool(stopped))
totals = ents.length_by_layer(rows)
unit = units.current()
output.print_md('## Length by layer' + (' (cancelled part way)' if stopped else ''))
output.print_table([[t['layer'], t['count'], units.format_length(t['length'], unit)]
for t in totals], ['Layer', 'Pieces', 'Length'])
if stopped:
toast.info('Cancelled', 'Measured %d of %d before you stopped' % (len(rows), len(handles)))bar.update both moves the bar and lets the window handle a Cancel click, and
it returns False once Cancel was clicked; the lambda passes that on, and
ents.lengths stops at its next check and returns what it measured so far. Functions
that write behave the same way and keep what they did, except layers.merge, which
rolls back. When your own loop does the work, call bar.check() each time round and
catch forms.Cancelled instead, as the batch plot recipe does.