Clash panel
Clash Grouper
Turn a test's results into reviewable issues, grouping by root cause, element, level, grid or proximity, with a live preview.
What it does#
Three hundred clashes between the same two runs of pipework are one problem. This tool says so before anyone opens a report. It groups a test's results into issues, either by the element that causes them or by a chain of rules you choose, shows you the groups it would make, and only then writes them into Clash Detective.
How to use it#
- Click Clash Grouper. The dialog lists the tests in the document and fills in their result counts as it counts them.
- Tick the tests to group.
- Leave smart grouping on to group by the root-cause element, or switch it off and chain your own rules: element on side A or side B, proximity cluster, nearest level, grid intersection, source model, status, or assigned to. Rules apply in order, so "level, then element" gives one issue per element per level.
- Watch the preview. It shows the groups the current rules would produce, largest first, before anything is written.
- Click Group. The output window reports each test: how the grouping was decided, the largest groups with a link that shows the first clash in each, how many clashes were left in existing groups and how many stayed individual.
Limitations#
- Ungroup removes only the groups this tool made. Groups made by hand are never touched, which also means the tool cannot tidy up someone else's grouping.
- Grid and level rules need grids and levels in the model. A model exported without them falls back to proximity.
- The proximity cluster distance starts at two metres and is the setting most worth
tuning: too small and one duct gets ten groups, too large and two rooms merge. It is shown
and typed in the document's units, like any pyNavis length:
6' 6"in a feet-and-inches document, or a value with its unit such as2manywhere.
Other uses#
- Group by assigned to before a meeting so each trade sees only its own pile.
- Group by source model to find out which link is generating most of the noise this week.
Good to know#
Shift+Click stores presets in smart_clash_grouper.json; every run can still
change all of them in the dialog. The groups it writes are ordinary Clash Detective groups
and behave like any other in the report.
Source#
The tool's own files, word for word as shipped in Clash.panel/Clash_Grouper.pushbutton. Every pyNavis tool is an ordinary bundle: copy these into your own extension and change what you like.
script.py Runs on a click. View on GitHub"""Groups clash results into reviewable issues: one click Smart grouping by
root-cause element, or chain custom rules (level, grid, proximity, model,
status), with a live preview before anything is written."""
# Everything above the guard at the bottom imports without Navisworks, so the
# pure reporting can be tested (src/PyNavis.Tests/ClashGrouperScriptTests.cs).
import clr
from pynavis import clashgroup, forms, output, settings, toast
from pynavis._markdown import escape as _escape
try:
from pynavis import clash
except ImportError:
clash = None
clr.AddReference('PyNavis.Runtime')
# Int32 explicitly, never the builtin int: IronPython 3 maps int to
# BigInteger, so Func[int, TestCounts] builds a delegate type the CLR does not
# recognise ("expected Func[Int32, TestCounts], got Func[int, TestCounts]").
from System import Func, Int32
from System.Collections.Generic import List
from PyNavis.Runtime.Forms import ClashGrouperDialog, ProgressScope
TestRow = ClashGrouperDialog.TestRow
RuleChoice = ClashGrouperDialog.RuleChoice
GrouperConfig = ClashGrouperDialog.GrouperConfig
PreviewData = ClashGrouperDialog.PreviewData
PreviewGroup = ClashGrouperDialog.PreviewGroup
TestCounts = ClashGrouperDialog.TestCounts
PreviewProgress = ClashGrouperDialog.PreviewProgress
MAX_SUMMARY_ROWS = 20
# Shift+Click (config.py) saves these; a plain run opens the dialog preset.
SETTINGS_DEFAULTS = {'smart': True, 'rules': [], 'tolerance_m': 2.0,
'keep_existing': True}
def saved_defaults():
prefs = settings.load('smart_clash_grouper', SETTINGS_DEFAULTS)
defaults = GrouperConfig()
defaults.Smart = bool(prefs['smart'])
defaults.KeepExisting = bool(prefs['keep_existing'])
# The cluster distance is shown and typed in the document's units. The
# import lives here, not at the top: pynavis.app needs the Navisworks API,
# and the rest of this file must import without it.
try:
from pynavis import app
defaults.Units = str(app.get_doc().Units)
except Exception:
pass
try:
tolerance = float(prefs['tolerance_m'])
if tolerance > 0:
defaults.ToleranceMeters = tolerance
except (TypeError, ValueError):
pass
known = set(rule_id for rule_id, _ in clashgroup.RULES)
for rule_id in prefs['rules']:
if rule_id in known:
defaults.RuleIds.Add(rule_id)
return defaults
def build_test_rows(tests):
"""Rows with no counts yet: counting walks every result, and the dialog
fills the numbers in after it is on screen."""
rows = List[TestRow]()
for i, test in enumerate(tests):
row = TestRow()
row.Index = i
row.Name = test.DisplayName
row.Total = -1
row.Grouped = -1
row.Checked = False
rows.Add(row)
return rows
def build_rule_choices():
grid_hint = '' if clash.has_grid() else ' (model has no grids)'
rows = List[RuleChoice]()
for rule_id, label in clashgroup.RULES:
choice = RuleChoice()
choice.Id = rule_id
if rule_id in ('level', 'grid'):
label += grid_hint
choice.Label = label
rows.Add(choice)
return rows
def compute_plan(snapshot, config):
tolerance = config.ToleranceMeters / clash.units_to_meters()
if config.Smart:
return clashgroup.smart_plan(snapshot, keep_existing=config.KeepExisting,
tolerance=tolerance)
return clashgroup.plan(snapshot, list(config.RuleIds),
keep_existing=config.KeepExisting, tolerance=tolerance)
def run():
tests = list(clash.walk_tests())
if not tests:
forms.alert('This document has no clash tests.\n\n'
'Run a test in Clash Detective first, then group its results here.')
return
snapshots = {}
totals = {} # test index -> (total, grouped), memo for counts()
plans = {} # (signature, test index) -> plan, so Apply never replans
def counts(index):
"""The dialog's lazy per-test count. Also the preview's denominator.
Memoised, because two callers ask: the dialog's lazy counting pass and
preview()'s pre-pass for tests it has not seen yet. Counting walks
every leaf result, so a test previewed first and then reached by the
resumed counting pass would otherwise be walked twice - up to 67,000
results on the UI thread, with no progress bar and no cancel.
"""
if index in totals:
total, grouped = totals[index]
else:
total, grouped = clash.count_results(tests[index])
totals[index] = (total, grouped)
row = TestCounts()
row.Total = total
row.Grouped = grouped
return row
def signature_of(config):
return (bool(config.Smart), tuple(config.RuleIds),
round(config.ToleranceMeters, 6), bool(config.KeepExisting))
def preview(config, report):
indexes = list(config.TestIndexes)
for index in indexes:
if index not in totals:
# Paint the progress panel and give the user a cancel
# checkpoint before each uncounted test's enumeration, rather
# than walking every uncounted test before the first report:
# that pre-pass used to freeze the dialog with the old panel
# still on screen. count_results has no progress callback of
# its own, so this is a checkpoint between tests, not
# per-result cancellation the way the read below gets.
#
# == False, not "not x": one convention for a progress
# callback's answer across this file and clash.py (see
# clash.Cancelled) - only an explicit False means stop, so a
# callback that answers None carries on.
if report(0.0, 'Counting clashes') == False:
return None
counts(index)
grand = sum(totals.get(index, (0, 0))[0] for index in indexes) or 1
signature = signature_of(config)
done = [0]
def tick(read_here):
# False from the dialog means the user hit Cancel; snapshot_results
# turns that into Cancelled and unwinds.
return report(float(done[0] + read_here) / grand, 'Reading clashes')
rows = []
total = grouped = skipped = 0
explanation = ''
multi = len(indexes) > 1
try:
for index in indexes:
if index not in snapshots:
# Assigned only on success: a cancelled read is partial and
# must never be cached.
snapshots[index] = clash.snapshot_results(tests[index],
progress=tick)
done[0] += totals.get(index, (0, 0))[0]
if report(1.0, 'Grouping') == False:
return None
# Only this run's plans can ever be reached: Apply is gated on the
# dialog's Done state, which requires the config's signature to
# match the signature of the most recent successful preview.
# Dropping earlier entries keeps a model with many result ids from
# holding several complete, unreachable plan copies at once - the
# bound still holds, because this clears immediately before the one
# loop that repopulates it. It waits until the read is done so a
# cancelled re-preview of unchanged settings leaves the plans it
# was about to rewrite intact, instead of pushing Apply onto
# plan_for's recompute path.
plans.clear()
for index in indexes:
snapshot = snapshots[index]
plan = compute_plan(snapshot, config)
plans[(signature, index)] = plan
prefix = (tests[index].DisplayName + ': ') if multi else ''
for group in plan['groups']:
rows.append((prefix + group['name'], len(group['ids'])))
grouped += len(group['ids'])
total += len(snapshot)
skipped += plan['skipped_existing']
explanation = explanation or plan['explanation']
except clash.Cancelled:
return None
data = PreviewData()
data.TotalClashes = total
data.TotalGroups = len(rows)
# Single test: the engine's own explanation reads best. Several tests:
# per-test counts would contradict the totals, so aggregate instead.
parts = [] if multi else [explanation]
loose = total - grouped - skipped
if loose > 0:
parts.append('%d stay individual.' % loose)
if skipped:
parts.append('%d stay in their existing groups.' % skipped)
data.Note = ' '.join(parts)
for name, count in sorted(rows, key=lambda pair: -pair[1]):
row = PreviewGroup()
row.Name = name
row.Count = count
data.Groups.Add(row)
return data
def plan_for(index, config):
"""The plan the user previewed, or a fresh one if it went missing."""
cached = plans.get((signature_of(config), index))
if cached is not None:
return cached
# Unreachable today: Apply is only enabled in the dialog's Done state,
# which means the most recent preview succeeded with exactly this
# config, so it left a plan here for every selected test. Kept as a
# correctness backstop, not a routine path - note it calls
# snapshot_results with progress=None, so if the C# staleness rules
# ever loosen this becomes a silent, uncancellable full read of the
# test with the dialog already gone. Anything that can reach it should
# thread a progress callback through first.
if index not in snapshots:
snapshots[index] = clash.snapshot_results(tests[index])
return compute_plan(snapshots[index], config)
config = ClashGrouperDialog.Show(
build_test_rows(tests), build_rule_choices(),
Func[GrouperConfig, PreviewProgress, PreviewData](preview),
Func[Int32, TestCounts](counts), saved_defaults())
if config is None:
return
if config.Action == 'apply':
apply_grouping(tests, plan_for, config)
elif config.Action == 'ungroup':
run_ungroup(tests, config)
def apply_grouping(tests, plan_for, config):
"""Writes the plans, then reports. The write runs behind a ProgressScope:
it holds the UI thread, so without one Navisworks paints a white
"Not Responding" frame for the duration and the user cannot tell a working
Apply from the hang this whole feature used to be."""
indexes = list(config.TestIndexes)
scope = ProgressScope.Begin('Grouping clashes', 'Starting')
try:
written = _write_plans(tests, plan_for, config, indexes, scope)
finally:
# Never leave this to the runtime's backstop: a live scope means
# Navisworks stays unclickable.
scope.Dispose()
_report_plans(written)
def _write_plans(tests, plan_for, config, indexes, scope):
"""Commits every selected test, returning [(name, plan, links)] to report.
Nothing prints here. The output window cannot paint while this holds the UI
thread, and materialising it mid-write would leave an empty window sitting
behind the progress one.
"""
written = []
for n, index in enumerate(indexes):
test = tests[index]
plan = plan_for(index, config)
# Read everything the report needs BEFORE committing: apply_plan swaps
# the test for a restructured copy, which leaves this wrapper and all of
# its results disposed. ModelItems live in the model, so the links
# captured here stay valid afterwards.
name = test.DisplayName
# Only the first result of each reported group is wanted, so they are
# picked out of the walk instead of materialising it: a list of every
# result means holding 350,000 live wrappers for the sake of 20 links.
wanted = {}
for group in plan['groups'][:MAX_SUMMARY_ROWS]:
wanted[group['ids'][0]] = None
remaining = len(wanted)
if remaining:
for rid, result in enumerate(clash.walk_results(test)):
if rid in wanted and wanted[rid] is None:
wanted[rid] = result.Item1
remaining -= 1
if remaining == 0:
break
links = [(group['name'], len(group['ids']), wanted[group['ids'][0]])
for group in plan['groups'][:MAX_SUMMARY_ROWS]]
def tick(fraction):
scope.Report((n + fraction) / len(indexes), 'Grouping %s' % name)
clash.apply_plan(test, plan, keep_existing=config.KeepExisting,
progress=tick)
written.append((name, plan, links))
scope.Report(1.0, 'Done')
return written
def _report_plans(written):
for name, plan, links in written:
output.print_md('## %s' % name)
print(plan['explanation'])
if links:
# Hand-built table (table_html would escape the link markup):
# plain names for reading, one small Show link per row.
cells = ''.join(
'<tr><td>%s</td><td>%d</td><td>%s</td></tr>'
% (_escape(name), count,
output.element_link(item, 'Show') if item is not None else '')
for name, count, item in links)
output.print_html(
'<table class="pynavis"><tr><th>Group</th><th>Clashes</th>'
'<th></th></tr>%s</table>' % cells)
hidden = len(plan['groups']) - len(links)
if hidden > 0:
print('... and %d more group(s).' % hidden)
if plan['skipped_existing']:
print('%d clash(es) kept in their existing groups.'
% plan['skipped_existing'])
if plan['ungrouped_ids']:
print('%d clash(es) left individual.' % len(plan['ungrouped_ids']))
# No output.progress here: progress belongs to the ProgressScope now, and a
# bar that appears already finished only to fade out is noise.
print('\nDone. Groups are ready in Clash Detective.')
def run_ungroup(tests, config):
indexes = list(config.TestIndexes)
scope = ProgressScope.Begin('Removing groups', 'Starting')
removed_by_test = []
try:
for n, index in enumerate(indexes):
# Read the name first: a commit leaves the old test wrapper disposed.
name = tests[index].DisplayName
def tick(fraction):
scope.Report((n + fraction) / len(indexes),
'Ungrouping %s' % name)
removed_by_test.append(
(name, clash.ungroup(tests[index], progress=tick)))
scope.Report(1.0, 'Done')
finally:
scope.Dispose()
level, message, detail, lines = ungroup_summary(removed_by_test)
for line in lines:
print(line)
toast.show(level, message, detail)
def ungroup_summary(removed_by_test):
"""What Ungroup reports, from [(test name, groups removed)]: a toast as
(level, message, detail), plus the per-test lines for the output window.
Those only come when groups came out of more than one test; one test is a
single line, and the toast already says it."""
removed = [(name, count) for name, count in removed_by_test if count]
total = sum(count for _, count in removed)
if not total:
return ('info', 'No groups to remove',
'None of the selected tests has groups this tool made. '
'Hand-made groups are never touched.', [])
message = 'Removed %d group(s)' % total
if len(removed) == 1:
return ('success', message, 'From %s.' % removed[0][0], [])
lines = ['%s: removed %d group(s).' % pair for pair in removed]
return ('success', message,
'From %d tests; see the output window.' % len(removed), lines)
if '__commandpath__' in globals():
if clash is None:
forms.alert('Clash Detective is not available in this Navisworks edition.\n'
'Clash Grouper needs Navisworks Manage.')
else:
run()config.py Runs on Shift+Click. View on GitHub"""Defaults for Clash Grouper (the Shift+Click settings).
Presets what the grouper dialog opens with: mode, custom rule chain, cluster
distance, and whether existing groups are kept. Every run can still change
them in the dialog itself. The distance is shown and typed in the document's
units, the way any pyNavis length is (pynavis.lengths: 6' 6", 2m), and kept
in metres.
"""
import clr
clr.AddReference('PresentationFramework')
clr.AddReference('PresentationCore')
clr.AddReference('WindowsBase')
clr.AddReference('PyNavis.Runtime')
from System.Windows import (CornerRadius, FontWeights, HorizontalAlignment,
ResizeMode, SizeToContent, TextWrapping, Thickness,
VerticalAlignment, Visibility, Window,
WindowStartupLocation)
from System.Windows.Controls import (Border, CheckBox, ComboBox, Grid,
Orientation, StackPanel, TextBox)
from System.Windows.Media import FontFamily
from PyNavis.Runtime.Forms import DesignSystem, FluentChrome
from pynavis import clashgroup, lengths, settings, toast
TOOL = 'smart_clash_grouper'
DEFAULTS = {'smart': True, 'rules': [], 'tolerance_m': 2.0, 'keep_existing': True}
T = DesignSystem.Tokens.Current
RULE_IDS = [rule_id for rule_id, _ in clashgroup.RULES]
RULE_LABELS = [label for _, label in clashgroup.RULES]
values = settings.load(TOOL, DEFAULTS)
units = 'Meters' # no document open: the stored unit
try:
from pynavis import app
units = str(app.get_doc().Units)
except Exception:
pass
window = Window()
window.Title = 'Grouper Defaults'
window.Width = 420
window.SizeToContent = SizeToContent.Height
window.ResizeMode = ResizeMode.NoResize
window.WindowStartupLocation = WindowStartupLocation.CenterScreen
window.FontFamily = FontFamily('Segoe UI Variable Text, Segoe UI')
window.Background = DesignSystem.Brush(T.Paper)
window.ShowInTaskbar = False
FluentChrome.Apply(window)
body = StackPanel()
body.Margin = Thickness(20, 16, 20, 16)
title = DesignSystem.Text('Clash Grouper defaults', 16, T.Ink, FontWeights.SemiBold)
body.Children.Add(title)
caption = DesignSystem.Text(
'These preset the dialog; every run can still change them.', 12, T.Muted)
caption.Margin = Thickness(0, 4, 0, 14)
caption.TextWrapping = TextWrapping.Wrap
body.Children.Add(caption)
def section(label):
text = DesignSystem.Text(label, 12, T.Muted, FontWeights.Medium)
text.Margin = Thickness(0, 10, 0, 6)
body.Children.Add(text)
section('Mode')
# A segmented control, the same affordance the grouper itself uses for this
# exact choice. Two radio buttons said the same thing in a different language.
state = {'smart': bool(values['smart'])}
seg_smart = DesignSystem.Secondary(T, 'Smart', None)
seg_custom = DesignSystem.Secondary(T, 'Custom rules', None)
for seg in (seg_smart, seg_custom):
seg.Height = 30
seg.MinWidth = 0
seg.Padding = Thickness(16, 0, 16, 0)
seg.BorderThickness = Thickness(0)
seg_row = StackPanel()
seg_row.Orientation = Orientation.Horizontal
seg_row.Children.Add(seg_smart)
seg_row.Children.Add(seg_custom)
seg_shell = Border()
seg_shell.CornerRadius = CornerRadius(4)
seg_shell.BorderBrush = DesignSystem.Brush(T.LineStrong)
seg_shell.BorderThickness = Thickness(1)
seg_shell.HorizontalAlignment = HorizontalAlignment.Left
seg_shell.Child = seg_row
body.Children.Add(seg_shell)
mode_caption = DesignSystem.Text('', 12, T.Muted)
mode_caption.Margin = Thickness(0, 8, 0, 0)
mode_caption.TextWrapping = TextWrapping.Wrap
body.Children.Add(mode_caption)
rules_label = DesignSystem.Text('Custom rule chain', 12, T.Muted, FontWeights.Medium)
rules_label.Margin = Thickness(0, 10, 0, 6)
body.Children.Add(rules_label)
rule_rows = StackPanel()
body.Children.Add(rule_rows)
empty_chain = DesignSystem.Text('Add at least one rule to group by.', 12, T.Muted)
empty_chain.Margin = Thickness(0, 0, 0, 6)
body.Children.Add(empty_chain)
def paint_mode():
"""Selected segment carries the accent; the caption states what the mode
does; the rule chain only exists in Custom, exactly as in the grouper."""
for seg, on in ((seg_smart, state['smart']), (seg_custom, not state['smart'])):
seg.Background = DesignSystem.Brush(
DesignSystem.Accent if on else T.Paper)
seg.Foreground = DesignSystem.Brush(
DesignSystem.OnAccent(DesignSystem.Accent) if on else T.Ink)
mode_caption.Text = (
'Smart finds the elements causing the clashes and groups them for you.'
if state['smart'] else
'Chain rules; each one splits the groups made by the one before.')
shown = Visibility.Collapsed if state['smart'] else Visibility.Visible
rules_label.Visibility = shown
rule_rows.Visibility = shown
add_btn.Visibility = shown
empty_chain.Visibility = (
Visibility.Visible
if not state['smart'] and rule_rows.Children.Count == 0
else Visibility.Collapsed)
def set_mode(smart):
state['smart'] = smart
paint_mode()
seg_smart.Click += lambda s, e: set_mode(True)
seg_custom.Click += lambda s, e: set_mode(False)
def add_rule_row(rule_id=None):
row = StackPanel()
row.Orientation = Orientation.Horizontal
row.Margin = Thickness(0, 0, 0, 6)
# The chain is ordered, so each row carries its position, as in the grouper.
ordinal = DesignSystem.TabularNumber('', 12, T.Muted)
ordinal.Width = 18
ordinal.VerticalAlignment = VerticalAlignment.Center
row.Children.Add(ordinal)
combo = ComboBox()
combo.Width = 230
combo.ItemsSource = RULE_LABELS
combo.SelectedIndex = RULE_IDS.index(rule_id) if rule_id in RULE_IDS else 0
row.Children.Add(combo)
def remove():
rule_rows.Children.Remove(row)
renumber()
paint_mode()
remove_btn = DesignSystem.Quiet(T, 'Remove', remove)
remove_btn.Margin = Thickness(8, 0, 0, 0)
row.Children.Add(remove_btn)
rule_rows.Children.Add(row)
renumber()
paint_mode()
def renumber():
for index in range(rule_rows.Children.Count):
rule_rows.Children[index].Children[0].Text = '%d.' % (index + 1)
# Before the saved rows below: add_rule_row paints the mode, which shows or
# hides this button, so it has to exist first. The rows go into rule_rows,
# which is already in place above it, so the layout is unchanged.
add_btn = DesignSystem.Quiet(T, 'Add rule', lambda: add_rule_row())
add_btn.HorizontalAlignment = HorizontalAlignment.Left
body.Children.Add(add_btn)
for saved_id in values['rules']:
if saved_id in RULE_IDS:
add_rule_row(saved_id)
section('Cluster distance')
tolerance_box = TextBox()
tolerance_box.Text = lengths.format_input(
lengths.convert(float(values['tolerance_m']), 'Meters', units), units)
tolerance_box.Width = 110
field = DesignSystem.InputField(T, tolerance_box, lengths.suffix(units) or None)
field.HorizontalAlignment = HorizontalAlignment.Left
body.Children.Add(field)
tolerance_error = DesignSystem.Text(
'Enter a distance greater than 0, %s.' % lengths.hint(units), 12, T.Error)
tolerance_error.TextWrapping = TextWrapping.Wrap
tolerance_error.Margin = Thickness(0, 4, 0, 0)
tolerance_error.Visibility = Visibility.Collapsed
body.Children.Add(tolerance_error)
keep_box = CheckBox()
keep_box.Content = 'Keep existing groups'
keep_box.IsChecked = bool(values['keep_existing'])
keep_box.Foreground = DesignSystem.Brush(T.Ink)
keep_box.Margin = Thickness(0, 12, 0, 0)
body.Children.Add(keep_box)
keep_caption = DesignSystem.Text(
'Groups you already made by hand stay untouched.', 12, T.Muted)
keep_caption.Margin = Thickness(0, 4, 0, 0)
body.Children.Add(keep_caption)
def tolerance_value():
"""The typed distance in metres; 0 when it does not read."""
typed = lengths.parse(tolerance_box.Text, units)
return 0.0 if typed is None else lengths.convert(typed, units, 'Meters')
def validate(*args):
"""Live: the error appears as you type and clears the same way, instead of
waiting for Save to tell you."""
bad = tolerance_value() <= 0
tolerance_error.Visibility = Visibility.Visible if bad else Visibility.Collapsed
field.BorderBrush = DesignSystem.Brush(T.Error if bad else T.LineStrong)
save_btn.IsEnabled = not bad
def save():
if tolerance_value() <= 0:
validate()
return
rules = []
for row in rule_rows.Children:
rules.append(RULE_IDS[row.Children[1].SelectedIndex])
settings.save(TOOL, {
'smart': state['smart'],
'rules': rules,
'tolerance_m': tolerance_value(),
'keep_existing': keep_box.IsChecked == True,
})
toast.success('Grouper defaults saved.')
window.Close()
footer = StackPanel()
footer.Orientation = Orientation.Horizontal
footer.HorizontalAlignment = HorizontalAlignment.Right
footer.Margin = Thickness(0, 18, 0, 0)
cancel_btn = DesignSystem.Secondary(T, 'Cancel', lambda: window.Close())
cancel_btn.Margin = Thickness(0, 0, 8, 0)
footer.Children.Add(cancel_btn)
save_btn = DesignSystem.Primary(T, 'Save', save)
footer.Children.Add(save_btn)
body.Children.Add(footer)
tolerance_box.TextChanged += validate
paint_mode()
validate()
window.Content = body
window.ShowDialog()bundle.yaml The bundle contract: the keys pyNavis reads for this tool. View on GitHubtitle: Clash\nGrouper