Skip to content

Commit 74bcbf0

Browse files
committed
#548 Some vallidation
1 parent 01130f2 commit 74bcbf0

12 files changed

Lines changed: 985 additions & 31 deletions

File tree

__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
importlib.reload(rigid_body)
1414
importlib.reload(presets)
1515
importlib.reload(preferences)
16+
importlib.reload(validation)
1617

1718

1819
else:
@@ -27,6 +28,7 @@
2728
from . import rigid_body
2829
from . import presets
2930
from . import preferences
31+
from . import validation
3032

3133
def register():
3234
# call the register function of the submodules.
@@ -44,8 +46,14 @@ def register():
4446
groups.register()
4547
properties.register()
4648

49+
# depends on collider_shapes, pyshics_materials and preferences being
50+
# registered already (naming/material checks read from them)
51+
validation.register()
52+
4753

4854
def unregister():
55+
validation.unregister()
56+
4957
properties.unregister()
5058
groups.unregister()
5159
# call unregister function of the submodules.

bmesh_operations/voxel_generation.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,17 @@ def _clamped_voxel_size(bbox_min, bbox_max, voxel_size, padding):
5151
def _grid_dims_and_origin(bbox_min, bbox_max, voxel_size, padding):
5252
"""Grid cell counts per axis, and the world position of cell (0, 0, 0).
5353
54-
Rounds each axis to the *nearest* whole number of cells rather than
55-
always up, then centers that core region on the mesh's bbox. Any leftover
56-
slack (or shortfall) from the rounding is split evenly between the two
57-
sides -- so the grid hugs the source surface as closely as possible
58-
instead of always growing outward to guarantee full containment.
54+
`voxel_size` rarely divides the bbox evenly, so a whole number of cells
55+
always has to round up to at least `ceil(size / voxel_size)` -- that part
56+
can't shrink, or the surface marking below would need more cells than the
57+
grid has room for. What we *can* control is where that unavoidable slack
58+
goes: centering the core region on the bbox (instead of anchoring it flush
59+
with bbox_min the way a naive grid would) splits it evenly between both
60+
sides, so the collider hugs the source surface on both sides equally
61+
instead of sitting flush on one side and bulging outward on the other.
5962
"""
6063
size = bbox_max - bbox_min
61-
core_cells = np.maximum(np.round(size / voxel_size).astype(int), 1)
64+
core_cells = np.maximum(np.ceil(size / voxel_size).astype(int), 1)
6265
dims = np.minimum(core_cells + 2 * padding, MAX_GRID_AXIS_CELLS)
6366
slack = core_cells * voxel_size - size
6467
origin = bbox_min - padding * voxel_size - slack / 2
@@ -105,11 +108,11 @@ def _mark_triangle(occupancy, origin, voxel_size, threshold, v0, v1, v2, max_dep
105108
# "Belongs to the cell above" and "belongs to the cell below" readings
106109
# of the piece's span. These agree for any ordinary span; they can
107110
# only disagree (lo > hi) when the piece is flat exactly on a grid
108-
# line along that axis -- which is guaranteed at the mesh's own
109-
# bounding-box extremes, since the grid origin is defined from
110-
# bbox_min in exact voxel-size steps. Marking both cells there is
111-
# what pushes the whole collider outward by up to one voxel around
112-
# every flat, grid-aligned face (e.g. any box-like source mesh).
111+
# line along that axis -- notably whenever voxel_size divides the
112+
# mesh's own bbox evenly, since the grid is then aligned so bbox_min
113+
# and bbox_max land exactly on grid lines. Marking both cells there is
114+
# what used to push the whole collider outward by up to one voxel
115+
# around every flat, grid-aligned face (e.g. any box-like source mesh).
113116
lo = np.floor(rel_min + eps).astype(int)
114117
hi = np.ceil(rel_max - eps).astype(int) - 1
115118

collider_shapes/add_bounding_primitive.py

Lines changed: 88 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
from ..pyshics_materials.material_functions import assign_physics_material, create_default_material, \
1717
set_active_physics_material, set_material
1818

19+
# How long the viewport-navigation HUD dimming stays on after the view was
20+
# last seen changing (see draw_viewport_overlay). Short enough to feel
21+
# responsive once navigation actually stops, long enough to bridge the gap
22+
# between individual redraw callbacks.
23+
NAVIGATION_HOLD_SECONDS = 0.2
24+
1925

2026
def alignObjects(new, old):
2127
"""Align two objects"""
@@ -110,11 +116,14 @@ def set_origin_to_center_of_mass(obj, depsgraph=None):
110116
print(f"Object '{obj.name}' has no vertices. Cannot calculate center of mass.")
111117
return
112118

113-
# Use numpy for faster vertex operations
114-
import numpy as np
115-
verts_local = np.array([v.co for v in mesh.vertices])
116-
verts_world = verts_local @ obj.matrix_world
117-
com = np.mean(verts_world, axis=0)
119+
# Use numpy for faster vertex operations. obj.matrix_world is a 4x4
120+
# mathutils.Matrix; numpy's `@` can't multiply it directly against an
121+
# (N, 3) array (shape mismatch), so the rotation/scale submatrix and
122+
# translation are applied explicitly instead.
123+
verts_local = numpy.array([v.co for v in mesh.vertices])
124+
mat_world = numpy.array(obj.matrix_world)
125+
verts_world = verts_local @ mat_world[:3, :3].T + mat_world[:3, 3]
126+
com = numpy.mean(verts_world, axis=0)
118127

119128
# Calculate the offset
120129
offset = obj.matrix_world.inverted() @ mathutils.Vector(com)
@@ -247,6 +256,25 @@ def draw_viewport_overlay(self, context):
247256
"""Draw 3D viewport overlay for the modal operator"""
248257
items = []
249258

259+
# Detecting "is the user currently navigating" from event types seen in
260+
# modal() doesn't work reliably: an MMB orbit drag hands its MOUSEMOVE
261+
# events - and usually even the terminating release - to Blender's own
262+
# view3d.rotate modal operator before they ever reach this operator's
263+
# modal(), so nothing observed there can tell whether a drag is still
264+
# in progress. This draw callback runs on every actual repaint though,
265+
# and Blender keeps repainting continuously for as long as the view is
266+
# visibly changing - so comparing the region's view matrix frame to
267+
# frame is a direct, reliable "is navigation actually happening" signal
268+
# instead of an event-based guess.
269+
region_3d = getattr(context.space_data, 'region_3d', None)
270+
if region_3d is not None:
271+
view_snapshot = (region_3d.view_matrix.copy(), region_3d.view_distance)
272+
if self.navigation_view_snapshot is not None and view_snapshot != self.navigation_view_snapshot:
273+
self.navigation_hold_until = time.time() + NAVIGATION_HOLD_SECONDS
274+
self.arm_navigation_timer()
275+
self.navigation_view_snapshot = view_snapshot
276+
self.navigation = time.time() < self.navigation_hold_until
277+
250278
self.valid_input_selection = True if len(self.new_colliders_list) > 0 else False
251279
if self.use_space:
252280
label = "Global/Local"
@@ -849,6 +877,43 @@ def force_redraw():
849877
bpy.context.space_data.overlay.show_text = not bpy.context.space_data.overlay.show_text
850878
pass
851879

880+
def arm_navigation_timer(self):
881+
"""Make sure poke_navigation_redraw() is scheduled. Only one timer
882+
is ever in flight - it re-arms itself via its own return value for
883+
as long as navigation_hold_until keeps getting pushed out, so this
884+
just needs to kick it off once."""
885+
if not self.navigation_timer_scheduled:
886+
self.navigation_timer_scheduled = True
887+
bpy.app.timers.register(self.poke_navigation_redraw, first_interval=NAVIGATION_HOLD_SECONDS)
888+
889+
def poke_navigation_redraw(self):
890+
"""bpy.app.timers callback: force a repaint once the navigation hold
891+
window elapses, even if no further event ever reaches modal() or
892+
draw_viewport_overlay() to notice on its own (e.g. the user stops
893+
touching mouse/keyboard right after navigating, and Blender doesn't
894+
repaint an idle viewport by itself).
895+
896+
This deliberately does NOT set self.navigation directly - it only
897+
prompts a fresh repaint, and draw_viewport_overlay() re-derives the
898+
real answer from the live view_matrix each time it draws. That
899+
means this can never falsely clear the dimming while navigation is
900+
still genuinely in progress: if the view is still changing, Blender
901+
is already generating real repaints on its own, each of which
902+
pushes the hold window out again before this timer's turn comes.
903+
Re-arms itself via its return value, so only one timer is ever in
904+
flight.
905+
"""
906+
try:
907+
remaining = self.navigation_hold_until - time.time()
908+
if remaining > 0:
909+
return remaining
910+
self.navigation_timer_scheduled = False
911+
self.navigation_area.tag_redraw()
912+
except ReferenceError:
913+
# operator has already finished/cancelled and its RNA was freed
914+
pass
915+
return None
916+
852917
def set_collisions_wire_preview(self, mode):
853918
"""Show wireframe for colliders"""
854919
if mode in ['PREVIEW', 'ALWAYS']:
@@ -1628,6 +1693,12 @@ def set_modal_state(self, cylinder_segments_active=False, displace_active=False,
16281693
self.height_active = height_active
16291694
self.width_active = width_active
16301695

1696+
# A keypress alone doesn't make Blender repaint the viewport (only
1697+
# mouse motion over the region does), so the new highlight color set
1698+
# above wouldn't show up until the next MOUSEMOVE. Force a repaint
1699+
# now so activating a parameter highlights it immediately.
1700+
self.force_redraw()
1701+
16311702
def invoke(self, context, event):
16321703
colSettings = context.scene.simple_collider
16331704

@@ -1649,6 +1720,10 @@ def invoke(self, context, event):
16491720

16501721
# INITIAL STATE
16511722
self.navigation = False
1723+
self.navigation_hold_until = 0.0 # grace window keeping navigation coloring on after the view last changed
1724+
self.navigation_timer_scheduled = False
1725+
self.navigation_area = context.area
1726+
self.navigation_view_snapshot = None # (view_matrix, view_distance) as of the last draw call
16521727
self.selected_objects = context.selected_objects.copy()
16531728
self.active_obj = context.view_layer.objects.active
16541729
self.obj_mode = context.object.mode
@@ -1787,28 +1862,21 @@ def invoke(self, context, event):
17871862
def modal(self, context, event):
17881863
colSettings = context.scene.simple_collider
17891864

1790-
self.navigation = False
1791-
17921865
# Ignore if Alt is pressed
17931866
if event.alt:
17941867
self.ignore_input = True
17951868
self.force_redraw()
17961869
return {'RUNNING_MODAL'}
17971870

17981871
if event.type in {'MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
1799-
# allow navigation
1800-
self.navigation = True
1801-
1802-
self.opacity_active = False
1803-
self.displace_active = False
1804-
self.decimate_active = False
1805-
self.cylinder_segments_active = False
1806-
self.remesh_active = False
1807-
self.height_active = False
1808-
self.width_active = False
1809-
self.sphere_segments_active = False
1810-
self.capsule_segments_active = False
1811-
1872+
# Whether the view is actually navigating is now detected in
1873+
# draw_viewport_overlay() by watching the region's view_matrix
1874+
# (see there for why: this handler doesn't reliably see events
1875+
# for the duration of an MMB orbit drag at all, since Blender's
1876+
# own view3d.rotate modal operator consumes them). This branch
1877+
# only needs to cancel any in-progress parameter drag so
1878+
# navigating doesn't fight with an active (S)/(D)/(A)/etc. edit.
1879+
self.set_modal_state()
18121880
return {'PASS_THROUGH'}
18131881

18141882
# User Input

preferences/preferences.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,22 @@ def draw_vhacd_panel(self, layout, context):
383383
row.prop(self, propName)
384384
row.operator("wm.url_open", text="", icon='QUESTION').url = f"https://github.com/kmammou/v-hacd#{propName}"
385385

386+
def draw_validation_panel(self, layout):
387+
"""Draw the validation panel"""
388+
box = layout.box()
389+
row = box.row()
390+
row.label(text='Checks')
391+
for propName in self.props_validation_checks:
392+
row = box.row()
393+
row.prop(self, propName)
394+
395+
box = layout.box()
396+
row = box.row()
397+
row.label(text='Thresholds')
398+
for propName in self.props_validation_thresholds:
399+
row = box.row()
400+
row.prop(self, propName)
401+
386402
def draw_support_panel(self, layout, context):
387403
"""Draw the support panel"""
388404
box = layout.box()
@@ -483,6 +499,9 @@ def draw(self, context):
483499
elif self.prefs_tabs == 'UI':
484500
self.draw_ui_panel(layout)
485501

502+
elif self.prefs_tabs == 'VALIDATION':
503+
self.draw_validation_panel(layout)
504+
486505
elif self.prefs_tabs == 'SUPPORT':
487506
self.draw_support_panel(layout, context)
488507

preferences/prefs_properties.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class CollisionAddonPrefsProperties():
4444
('KEYMAP', "Keymap", "Change the hotkeys for tools associated with this addon."),
4545
('UI', "Ui", "Settings related to the Ui and display of the addon."),
4646
('VHACD', "Auto Convex", "Settings related to Auto Convex generation."),
47+
('VALIDATION', "Validation", "Settings for the collider validation checks."),
4748
('SUPPORT', "Support", "Get support and help with the addon and help improve it"),
4849
),
4950
default='SETTINGS',
@@ -464,6 +465,59 @@ class CollisionAddonPrefsProperties():
464465
"export pipelines that rely on the render flag",
465466
default=True)
466467

468+
###################################################################
469+
# VALIDATION
470+
471+
validate_check_missing_collider: bpy.props.BoolProperty(name="Missing Collider",
472+
description="Flag render meshes that have no collider assigned",
473+
default=True)
474+
475+
validate_check_triangle_count: bpy.props.BoolProperty(name="Triangle Count",
476+
description="Flag colliders whose triangle count exceeds the limit below",
477+
default=True)
478+
479+
validate_check_min_dimension: bpy.props.BoolProperty(name="Collider Too Small",
480+
description="Flag colliders whose bounding box is smaller than the minimum dimension below",
481+
default=True)
482+
483+
validate_check_bbox_mismatch: bpy.props.BoolProperty(name="Bounding Box Mismatch",
484+
description="Flag colliders whose bounding box differs too much from their render mesh",
485+
default=True)
486+
487+
validate_check_naming: bpy.props.BoolProperty(name="Naming Convention",
488+
description="Flag colliders whose name doesn't match the configured naming convention",
489+
default=True)
490+
491+
validate_check_non_manifold: bpy.props.BoolProperty(name="Non-Manifold Geometry",
492+
description="Flag colliders with non-manifold (not watertight) edges",
493+
default=True)
494+
495+
validate_check_physics_material: bpy.props.BoolProperty(name="Missing Physics Material",
496+
description="Flag colliders with no physics material assigned",
497+
default=True)
498+
499+
validate_check_parent_hierarchy: bpy.props.BoolProperty(name="Parent Hierarchy",
500+
description="Flag colliders that aren't parented to a render mesh",
501+
default=True)
502+
503+
validation_max_triangle_count: bpy.props.IntProperty(name="Max Triangle Count",
504+
description="Maximum number of triangles allowed on a collider before it is flagged",
505+
default=255,
506+
min=1)
507+
508+
validation_min_dimension: bpy.props.FloatProperty(name="Min Dimension",
509+
description="Minimum bounding box dimension a collider can have before it is flagged as too small",
510+
default=0.01,
511+
min=0.0,
512+
subtype='DISTANCE')
513+
514+
validation_bbox_tolerance: bpy.props.FloatProperty(name="Bounding Box Tolerance",
515+
description="Allowed relative difference between a collider's bounding box and its render mesh's bounding box, as a fraction of the render mesh's bounding box diagonal",
516+
default=0.1,
517+
min=0.0,
518+
max=1.0,
519+
subtype='FACTOR')
520+
467521
# DEBUG
468522
debug: bpy.props.BoolProperty(name="Debug Mode",
469523
description="Debug mode only used for debuging during development",
@@ -562,4 +616,21 @@ class CollisionAddonPrefsProperties():
562616
"my_hide",
563617
"wireframe_mode",
564618
"hide_render_on_creation",
619+
]
620+
621+
props_validation_checks = [
622+
"validate_check_missing_collider",
623+
"validate_check_triangle_count",
624+
"validate_check_min_dimension",
625+
"validate_check_bbox_mismatch",
626+
"validate_check_naming",
627+
"validate_check_non_manifold",
628+
"validate_check_physics_material",
629+
"validate_check_parent_hierarchy",
630+
]
631+
632+
props_validation_thresholds = [
633+
"validation_max_triangle_count",
634+
"validation_min_dimension",
635+
"validation_bbox_tolerance",
565636
]

tests/test_bounding_voxel.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,22 @@ def test_cube_cubic_axis_aligned_grid_merges_to_single_box(self):
9393
self.assertEqual(len(bm.faces), 6)
9494
self.assertEqual(_holes(bm), [])
9595

96+
def test_cube_cubic_hugs_true_bbox_when_voxel_size_divides_evenly(self):
97+
"""Regression for #577: an evenly-dividing voxel size used to always
98+
inflate the collider outward by one full voxel on every side, because
99+
the mesh's own bbox faces sit exactly on a grid line. The result
100+
should instead match the source cube's bbox exactly."""
101+
size = 2.0
102+
mesh = self._cube_mesh(size=size)
103+
bm = build_voxel_bmesh(mesh, voxel_size=0.5, diagonal_fill=False)
104+
self.addCleanup(bm.free)
105+
coords = [v.co for v in bm.verts]
106+
bbox_min = [min(c[axis] for c in coords) for axis in range(3)]
107+
bbox_max = [max(c[axis] for c in coords) for axis in range(3)]
108+
for axis in range(3):
109+
self.assertAlmostEqual(bbox_min[axis], -size / 2, places=5)
110+
self.assertAlmostEqual(bbox_max[axis], size / 2, places=5)
111+
96112
def test_sphere_cubic_is_watertight(self):
97113
mesh = self._sphere_mesh()
98114
bm = build_voxel_bmesh(mesh, voxel_size=0.15, diagonal_fill=False)

0 commit comments

Comments
 (0)