Running log of decisions, gotchas, and in-progress work for RoTools. Newest entries at the top. This is the project's persistent memory since there is no git history to consult.
Extends the 2026-08-26 Move-only Edit Mesh v1 scope to the other two transform tools, following the same pattern rather than inventing a new one.
tools/scale_tool.py and tools/rotate_tool.py each gained an _edit
WorkSpaceTool subclass, bl_context_mode = 'EDIT_MESH' sharing the
original's bl_idname, registered alongside the Object Mode class — exactly
ROTOOLS_WT_move_edit's shape in tools/move_tool.py, for the same reason
(bl_context_mode is a plain dict key, so one class cannot cover two modes).
Both files also deduplicated their bl_description/bl_keymap into a
module-level DESCRIPTION/KEYMAP plus a shared _draw_settings function,
since the Object and Edit Mesh classes would otherwise carry byte-identical
copies. rotools.set_swivel and rotools.duplicate stay in the Edit Mesh
keymaps even though their poll()s remain Object-Mode-only — same precedent
as ROTOOLS_WT_move_edit's duplicate binding: an inactive keymap entry until
those operators grow Edit Mesh support of their own.
gizmos/scale_gizmo.py and gizmos/rotate_gizmo.py gained the same
poll()/draw_prepare EDIT_MESH branches move_gizmo.py already had:
poll accepts EDIT_MESH when context.objects_in_mode is non-empty, and
draw_prepare sources its box (Scale) or ring radii (Rotate) from
edit_mesh_local_aabb instead of local_aabb/world_corners when in that
mode.
Rotate's ring radius formula needed a real replacement for world_corners,
not just a mode switch. The 2026-08-25 sizing fix (see that entry) measures
each ring's radius from the part's own oriented corners, specifically
because an AABB swells as it turns. But a bmesh selection has no per-object
bound_box to take those corners from — it is a loose point cloud, not a
part with a fixed shape. core/bounds.py gained
local_aabb_corners(rotation_3x3, mins, maxs), the 8 corners of the
local_aabb-frame box itself (reconstructed via the existing
point_from_local), used as the stand-in. This is a weaker invariant than
the Object Mode case — the local AABB can still swell as the selection turns,
unlike a rigid part's own corners — but it is the closest available substitute
and matches what Scale already does for its own box handles in Edit Mesh.
gizmos/move_gizmo.py also stopped recomputing a handle's matrix_basis
while gz.is_modal. The previous comment justified recomputing every
redraw, modal included, as tracking a Shift-slowed drag that the arrow
gizmo's own interactive offset doesn't know about. That still holds in Object
Mode, where the pivot only moves through the operator's own transform. In
Edit Mesh, pivot_point is re-derived from bmesh_selected_corners every
call, and transform.translate mutates those vertex positions live during
the drag — so recomputing matrix_basis from the pivot mid-drag was
computing it from a position the drag itself had already moved, fighting
Blender's own interactive placement of the handle being dragged. Skipping the
overwrite while modal leaves Blender fully in charge of a handle's transform
for the duration of its own drag, in both modes.
Not yet manually verified in the live viewport — same category as the rest of the Edit Mesh work: no way to simulate viewport mouse input through this session's tooling. Needs a human at the keyboard: dragging Scale's six handles and Rotate's three rings on a real vert/edge/face selection, and confirming Move's handles no longer jitter mid-drag in either mode.
Closes the "per-object collidable filtering" gap left open in the
2026-08-24 dragger entry below. DragScene.candidates
(core/snapping.py) now drops any object whose Collidable custom
property is set to False, gating both ray_cast (flush resting) and
nearest_features (soft snap) in one place — the two are already fed by
the same candidates list, so nothing else needed to change.
Deliberately a raw ID property (obj.get("Collidable", True)), not a
registered bpy.props.BoolProperty. The user asked for it this way:
Blender's own Custom Properties panel (Object Properties / N-panel Item
tab, bottom of the list) already supports a Boolean type with no addon
code, so this needed neither a scene_state.py-style register/unregister
pair nor any new draw_settings row — the property just doesn't exist
until a user adds one via New > Boolean, at which point obj.get(..., True) picks it up. Absent means collidable, matching Roblox's own
CanCollide default. Tradeoff: no addon-provided way to bulk-add the
property or see its state without opening Object Properties; not asked
for, so not built.
Bug report: in Edit Mesh, with a RoTools tool equipped and nothing selected, the gizmo (arrows/rings/handles) stayed visible instead of hiding — including after switching to a different tool.
Root cause, found by reading (not live-poking, per the user's
correction mid-session): the three gizmo groups' poll() classmethods gate
Edit Mesh on bool(context.objects_in_mode) — is an object being edited —
not on whether anything is actually selected within it. So deselecting all
verts/edges/faces leaves poll() returning True, and the group keeps
running. But each group's draw_prepare() only handled the "nothing
selected" case with an early return when pivot_point() (move/rotate) or
edit_mesh_local_aabb() (scale) came back None — skipping the handles'
reposition instead of hiding them, so they stayed drawn at their last
computed (now stale) transform.
Fix: gizmos/move_gizmo.py, gizmos/scale_gizmo.py,
gizmos/rotate_gizmo.py now set gz.hide = True on every owned gizmo in
that early-return branch, and gz.hide = False on the normal path.
bpy.types.Gizmo.hide confirmed as a real bool property via the bundled API
docs before use. Not yet manually verified in the live viewport (needs a
human at the keyboard, same category as the rest of the Edit Mesh work
below).
Dropped the Q/W/E/R bindings from core/keymaps.py's BINDINGS
tuple, keeping only Ctrl+1..4 (Roblox Studio's own exact shortcuts).
Q/W/E/R shadowed Blender's own wm.call_menu, tool-cycle, and
transform.rotate bindings in Object Mode - too much of a cost for a
Blender user's muscle memory versus the adjacency convenience they bought.
use_tool_shortcuts now only gates the four Ctrl+1..4 items.
Updated every place that documented the old eight-item set as current
behavior to match: core/preferences.py's property description and the
addon-preferences panel labels, core/keymaps.py's own docstrings, and
docs/01-overview.md, 02-architecture.md, 03-tools-and-keymaps.md,
07-settings-reference.md, 08-module-reference.md, 09-blender-api-notes.md,
and 11-known-gaps.md. Left the 2026-08-25 entry below describing the
original opt-out decision as-is - it's a record of what was true when it was
built, not a live reference.
Built from a user brainstorm doc, refined into a plan after three parallel codebase-exploration passes and two empirical spikes (see the plan file referenced in this session). The brainstorm's own assumptions needed two corrections before implementation:
bmesh.update_edit_mesh's destructive default is True, not False as
the brainstorm assumed - verified against the bundled Blender Python API docs.
Pass destructive=False explicitly for a pure position-only update (nothing
added/removed).
WorkSpaceTool.bl_context_mode does not accept a tuple of modes. Verified
by triggering it live: bpy.utils.register_tool uses bl_context_mode as a
plain dict key (cls._tools[context_mode]), and registering a class with
bl_context_mode = ('OBJECT', 'EDIT_MESH') raises KeyError: ('OBJECT', 'EDIT_MESH') immediately. The working pattern - also verified live - is two
separate WorkSpaceTool subclasses sharing one bl_idname, one per mode,
each registered independently; Blender resolves them as the same tool
activating in both modes. tools/move_tool.py now defines
ROTOOLS_WT_move (OBJECT) and ROTOOLS_WT_move_edit (EDIT_MESH) this way,
written as two flat classes rather than a shared base class, matching every
other tool file's existing style and keeping the documented
WorkSpaceTool.__subclasses__() stuck-registration recovery trick (see the
2026-08-24 entry) valid without modification - a shared base would put the
real tool classes one level deeper than that recovery walk checks.
Scope for v1: only the Move tool, and only its gizmo. Two calls made during planning, both worth remembering if Scale/Rotate get the same treatment later:
operators/select.py's body-click drag-vs-box-select heuristic (allow_drag) stays Object-Mode-only. This turned out to need no changes at all for Move: Move/Scale/Rotate already invokerotools.selectwithallow_drag=False(the default, never overridden in their own keymaps), so the operator's body unconditionally falls through to Blender's nativeview3d.select/view3d.select_box- both already mode-agnostic. Only the standalone Select tool setsallow_drag=True, and that tool wasn't touched.operators/drag.py(rotools.drag) needs no changes for this scope. Discovered while starting what the plan called its highest-risk step: all three gizmo groups drive Blender's nativetransform.translate/resize/rotatedirectly viatarget_set_operator, entirely independent ofrotools.drag- which is invoked only fromselect.py's deferred body-click path andduplicate.py's Ctrl+D. Nativetransform.translatealready works correctly on a bmesh selection with zero RoTools code changes. The bmesh-mutation-safety spike below remains valid groundwork for a future full-drag-in-Edit-Mesh phase, just isn't on this scope's critical path.
core/bounds.py gained local_aabb_from_points (the projection loop
extracted from the existing local_aabb, which now just calls it with
world_corners(objects)), bmesh_selected_corners (world-space coordinate
of every selected BMVert - verts cover vertex/edge/face select modes
uniformly, since selecting an edge or face selects its verts too), and
edit_mesh_local_aabb (walks context.objects_in_mode, supporting
multi-object Edit Mode from the start). Verified headlessly: a 2x2x2 cube's
+X face selection gave x:6..6, y:-1..1, z:0..2; full selection gave the
whole cube's box; empty selection gave None. Object Mode's local_aabb
confirmed unchanged after the refactor.
core/pivot.py's pivot_point gained an EDIT_MESH branch
(_edit_mesh_pivot): CENTER uses edit_mesh_local_aabb, ORIGIN falls back to
the median of context.objects_in_mode's origins (no per-vertex analog,
same treatment Object Mode gives a multi-object selection), SWIVEL is
unchanged since it's already a stored world-space point. Signature and
return type untouched, so every caller needs no changes. Verified: CENTER on
the +X face selection gave (6,0,1); ORIGIN gave the object's origin
(5,0,1); empty selection gave None.
gizmos/move_gizmo.py's poll now accepts EDIT_MESH when
context.objects_in_mode is non-empty; draw_prepare's face-handle
placement branches to edit_mesh_local_aabb in that mode. Verified two ways:
headlessly, by calling draw_prepare directly against lightweight
gizmo/operator stand-ins (bypassing the need for a live registered gizmo
instance) - center at (6,0,1), ±X handles symmetric at 5.88/6.12
(correct: a flat face selection has zero thickness along its own normal, so
both handles straddle the same plane ± the handle gap); and live, after a
full documented reload, confirming rotools.move_tool activates in both
Object and Edit Mesh and the gizmo's poll returns True in Edit Mesh with
a real selection. Screenshot verification hit the same all-black-frame
environment limitation noted in the 2026-08-25 entry (occluded/minimized
window capture) - not a code defect, and not re-litigated here.
Bmesh per-frame-mutation safety spike (throwaway, not committed):
registered a scratch operator that wrote 120 sequential BMVert.co updates
plus bmesh.update_edit_mesh(destructive=False) calls against a ~2600-vert
selection, all within one execute(). Averaged 0.19ms/frame (worst
0.52ms) - well under a 16ms frame budget - with correct final positions and
no corruption. Undo-step-count could not be verified through this session's
scripting bridge (bpy.ops.ed.undo's poll refuses to run outside a real
interactive context, unrelated to the addon) - deferred to manual
verification alongside the click-drag test below, though the documented
operator-undo contract (one step per FINISHED, regardless of how many
mutations happened during execute) already covers this and matches how
drag.py's existing Object Mode code already behaves in production.
Not yet verified - needs a human at the keyboard, same category as the 2026-08-26 duplicate/swivel entry below: dragging the Move gizmo's arrows/ center ring on a real vert/edge/face selection in the live viewport (visual confirmation the handles draw and drag correctly - the MCP tooling here has no way to simulate viewport mouse input), and a real Ctrl+Z after such a drag.
Explicitly out of scope, not forgotten: mesh-geometry snapping, Ctrl+D in
Edit Mesh, Scale/Rotate tools in Edit Mesh, rotools.set_swivel in Edit
Mesh, the N-panel, and updating docs/01-overview.md's compatibility table
(deferred until this v1 gets its manual verification pass).
Verified the uncommitted duplicate + swivel-rescope change. Working tree
had operators/duplicate.py (new, untracked) plus edits to __init__.py,
keymaps.py, scene_state.py, set_swivel.py, all three transform tools,
overlay.py, and tool_ui.py, with no PROJECT_NOTES entry. Reloaded from the
repo (disable → purge sys.modules → enable) and checked the resolved addon
keyconfig plus operator poll()/state logic directly, since the Blender MCP
tools here have no mouse/keyboard input into the 3D viewport (unlike the
browser tools) — a real click-drag Ctrl+D test still needs a human:
- Keymap:
V(rotools.set_swivel) now lives only in Rotate's tool keymap; Select/Move/Scale/Rotate all bindCtrl+D→rotools.duplicate. Matches the diff exactly, confirmed by walkingwm.keyconfigs.addon.keymaps. ROTOOLS_OT_set_swivel.pollisTrueonly with Rotate active,Falseon Move/Select (wm.tool_set_by_idneeds a realtemp_override(area=<VIEW_3D>, region=<WINDOW>)— without it the call silently no-ops with "Tool cannot be set with an empty space" and any poll check downstream is meaningless)._duplicate_objects(inoperators/duplicate.py) gives each copy its own mesh data (not shared with the original) and re-points a copied child's parent at the copied parent, not the original — mirrorsdrag.py's_stampas the docstring claims.overlay._clear_swivel_on_tool_changedoes drop a swivel set while on Rotate the moment the active tool changes:rotools_swivel_is_set→ False, kind cleared,rotools_pivot_modefalls back to CENTER. Verified by forcing the module's_last_tool_idnameto Rotate, setting a swivel by hand, switching tool, and calling the handler directly.
Not yet verified: an actual Ctrl+D press with the cursor over a surface,
confirming the copy drags/snaps/rests like a normal drag and falls back to
object.duplicate_move over empty space. Needs a human at the keyboard.
Fixed: the 15° rotate increment was never reverted on addon disable
(docs/11-known-gaps.md §11.3). _set_default_rotate_increment nudges a
scene from Blender's 5° default to Roblox's 15° but nothing undid that on
unregister(), so disabling the addon left 15° behind. Fixed by recording
which scenes were actually nudged (_nudged_scene_names, module-level) and,
on unregister(), reverting only those scenes and only if the value is still
exactly what the nudge set it to — a scene the user deliberately changed
since (verified with a manual set to 30°) is left alone. Verified end to end
in live Blender: 5°→enable→15°, disable→5°; separately, 5°→enable→15°→user
sets 30°→disable→still 30°.
Left alone (per CLAUDE.md, only fix what's asked): the rest of
docs/11-known-gaps.md §11.3–11.5, since most of it is documented tradeoffs
(swivel not tracking picked geometry, sheared-object gizmo basis) rather than
bugs, and the remainder (4.0-support claim, no test suite, overlay redraw
scope) is unconfirmed or cosmetic.
The rings were sized by the bounding sphere, and that is the wrong solid.
_radius returned one number for all three rings: the half-diagonal of the
selection's AABB, hypot(hx, hy, hz). That radius is dominated by the box's
longest axis, so a ring spanning only the two short ones came out far bigger
than the cross-section it wraps. On the 3.83x2x2 test cube the shared radius was
2.737 while the X ring (the YZ plane, a 2x2 square) only needed 1.626 — a 1.7x
overshoot, and the reason the rings ran off screen.
Replaced with a per-ring radius (_radii, keyed by axis). A ring turning about
axis a sweeps the selection around the line through the pivot along a, so
its radius is the farthest any of the part's corners sits from that line —
the perpendicular distance, which on an orthonormal frame is just the two
in-plane components:
r = max over corners of hypot((c - pivot).u, (c - pivot).v)
with u, v the ring plane's two axes (PLANE_AXES = {'X': (1, 2), 'Y': (2, 0), 'Z': (0, 1)}). Padding aside this is the tightest circle that clears the part,
and it fixes the old pivot handling for free: the old code added the full 3D
|pivot - centre| to a 3D radius, a bound rather than a distance, so a swivel
offset purely along X inflated the X ring too even though the swivel sits dead
centre in that ring's own plane.
The corners must be the part's own, never an AABB's — this is the whole point. First attempt measured the corners of the axis-aligned box fitted around the selection. An AABB swells as its contents turn, so the ring grew while you dragged it. Measured on the test slab, sweeping world Y and reading the Y ring:
rotation about Y 0deg 15 30 45 60 90
from AABB corners 2.361 2.867 3.187 3.296 3.187 2.361 <- swells 40%
from real corners 2.361 2.361 2.361 2.361 2.361 2.361 <- spread 0
Hence bounds.world_corners(objects): the oriented corners, bound_box carried
through matrix_world. local_aabb now walks the same generator.
Why this is the right invariant. A rotation about a preserves every
point's distance from a — so the ring being dragged cannot change size during
its own drag, exactly, not approximately. Rings for the other two axes do
resize as the part turns, and that is correct: the part genuinely reaches
further from those lines.
Verified. 12 combinations (WORLD/LOCAL x CENTER/ORIGIN x XYZ), each stepped
through a full 360deg turn about the ring's own axis, driven the way the gizmo
drives it (Translation(pivot) @ Rotation(t, axis) @ Translation(-pivot), i.e.
center_override + orient_axis). Worst spread over a full turn 6.4e-7,
worst disagreement with a brute-force (c - pivot).cross(axis).length walk
4.5e-7. Both are float32 noise out of obj.bound_box.
Careful when testing this: stepping one component of
rotation_euleris not a rotation about that world axis unless the other two are zero (XYZ order meansR = Rz @ Ry @ Rx). Doing it that way showed a bogus 0.08 spread on the X ring. Compose the rotation about the pivot explicitly instead.
Only half of each ring was drawn — that was draw_options = {'CLIP'}. The
dial gizmo's CLIP option cuts the circle at the view plane through its centre,
leaving just the camera-facing half. Blender's own rotate gizmo wants that;
Roblox's rings are whole circles, and the clipped half gives nothing to grab on
the far side. Now set to set() explicitly rather than left off, so nobody has
to know what the type's default is. Confirmed by screenshot after reload: three
complete circles.
Sizing and clipping are independent — worth separating if this comes up again. Big rings ran off the edges of the region; CLIP punched the gap out of the middle of each ring, well inside the viewport.
Not verified by screenshot the second time round. The invariance fix was
confirmed numerically only — get_screenshot_of_* returned an all-black frame
for the whole Blender window, which is what a minimised/occluded window grabs.
The CLIP fix above was confirmed visually, before that happened.
Still a flat world-space number: MIN_RADIUS = 0.5. It is a floor on the
world radius, so a small part viewed from far away still gets a small on-screen
ring. A screen-space floor via view_math.pixels_to_world would be the honest
fix; not done, because nobody has asked and draw_prepare would need the region
and rv3d threaded in. Flagging it here rather than fixing it silently.
A comparison pass over all four tools against Roblox Studio, plus the swivel
pivot. Everything below was verified in the live Blender 5.2.0 LTS, loading
the repo from sys.path rather than the installed copy — see the very last
section, this turned out to matter a lot.
The dragger double-transformed parented selections. Setting matrix_world
on a child and then on its parent applies the drag twice. Measured: a child 2
units from its parent, both offset by +10 on X, landed at X = 22 instead of
12 when the child was written first; parent-first gave the correct 12. And
context.selected_objects gives no ordering guarantee, so this was a coin
flip. drag.py:_drag_roots now writes only objects with no selected ancestor —
children ride along with their parents, which is also what Blender's own
transform does.
Rotate and Scale rotated/scaled around a different point than they drew.
Neither gizmo set center_override, so transform.rotate and (in CENTER mode)
transform.resize fell back to tool_settings.transform_pivot_point. That
defaults to MEDIAN_POINT, and is CURSOR for anyone who has used the pivot pie
menu — so the rings were drawn around the selection and the part span around
the 3D cursor. Both gizmos now force center_override to the same pivot they
draw at, in every mode. Verified transform.rotate does have
center_override (it is not documented alongside resize's, but it is there).
Rotate snapping never engaged, and neither did Scale's. Blender splits
transform snapping into a master use_snap plus per-mode affect flags
use_snap_translate / use_snap_rotate / use_snap_scale. Verified in 5.2:
use_snap_rotate and use_snap_scale both default to False while
use_snap_translate defaults True. The Rotate tool's settings row exposed
only use_snap_rotate, which does nothing while the master is off; the Scale
tool exposed only the master, leaving use_snap_scale off. Either way the
button lied. Fixed with three rotools_snap_* proxy properties
(core/scene_state.py) that read true only when the transform will really
snap, and on write set the mode flag plus — when enabling — the master.
Disabling deliberately does not clear the master, so turning off Move snap
does not silently kill Rotate snap.
property_unset("center_override") was guarded against nothing. Probed it
directly on OperatorProperties: assigning sets is_property_set true,
property_unset clears it and does not raise. The try/except Exception in
scale_gizmo.py is gone — and so is the call, since the anchor is now always
forced.
__package__.split(".")[0] is wrong for extensions. Three call sites used
it to find the addon preferences. As a legacy addon __package__ is
roblox_tools.core, so it worked; as an extension it is
bl_ext.<repo>.roblox_tools.core and split(".")[0] returns bl_ext, finding
nothing. Now one PACKAGE = __package__.rpartition(".")[0] in
core/preferences.py, correct for both.
An unselectable object under the press ate the click. scene.ray_cast
ignores hide_select, so pressing on one deselected everything and then handed
the dragger an object it refuses. select.py now treats an unselectable hit as
a miss, and also falls back to box-select when rotools.drag returns
CANCELLED, so a refused drag is never a dead click.
Move now draws six arrows on the bounding-box faces, not three on a central
tripod. This is the most visible difference between the two tools: Studio has
you push the face you want to move. The centre ring stays for free movement and
is where the pivot is shown. Verified in-viewport — 7 gizmos at exactly the
bbox face centres plus HANDLE_GAP, e.g. a 2 x 3.2 x 1.2 part gave handles at
±1.12 X, ±1.72 Y, ±0.72 Z.
Orientation is now scene-wide, not per-tool. Studio has one Local-space
toggle in the Model tab that every transform tool obeys. Scale and Rotate used
to hardcode orient_type='LOCAL' while only Move had a control, so the Move
tool's World setting was quietly contradicted the moment you switched tools.
rotools_move_orientation became rotools_orientation, Ctrl+L works from
every tool, and gizmo_common.orientation_frame returns the drawn frame and
the operator's orient_type together so they cannot drift apart again.
Pivot became a first-class shared setting — CENTER (bounding-box centre,
the Roblox default, since a part's CFrame is its centre), ORIGIN (median of
object origins, the old behaviour), SWIVEL. This also closes the "rotate
gizmo is centred on origins but sized from bounds" mismatch, since both now
come from the same place. Verified the modes actually differ: two cubes, a
4-unit at the origin and a 1-unit at X=10, gave origin-median 5.0 and
bbox-centre 4.25.
The 15° rotate increment is a default again, not an assertion. It used to
be written to every scene on every addon enable, so a user who set their own
increment lost it on the next Blender start. Now only scenes still sitting on
Blender's own default (0.0872665 rad = 5°) get nudged. The deferred timer is
also unregistered on unregister().
The Q/W/E/R and Ctrl+1..4 shortcuts are opt-out via a new
use_tool_shortcuts preference, because they shadow transform.rotate on R
and object.subdivision_set on Ctrl+1..4 in the same keymap.
The
keyconfigs.update()call inkeymaps.refresh()is load-bearing and non-obvious. Removing items from the addon keyconfig does not touch the resolved user keyconfig, which is a cached merge: measured 8 addon items → 0 while the user keymap still listed all 8, so R stayed shadowed. Withwm.keyconfigs.update()the user count drops to 0 immediately, and back to 8 when re-enabled.
New: rotools.set_swivel (bound to plain V in every RoTools tool's own
keymap) picks a mesh element under the cursor and every transform tool pivots
around it. rotools.clear_swivel drops it. World/Local keeps applying on top,
exactly as it does for Center and Origin — the swivel is a pivot point, not
an orientation, so no custom transform orientation is involved.
That last point was settled by probing rather than assumed: orient_type has
no CUSTOM member in 5.2 (GLOBAL/LOCAL/NORMAL/GIMBAL/VIEW/CURSOR/PARENT),
so a picked-face-normal frame would have needed the orient_matrix /
orient_matrix_type pair. Not needed — pivot alone is what "swivel from a set
vert/edge/face" asks for.
core/picking.py deliberately does not use the dragger's snapping engine.
The BVH in core/snapping.py is triangulated, so its "edges" include
triangulation diagonals that do not exist in the mesh the user is looking at;
snapping a deliberately picked edge onto an invisible diagonal is indefensible.
Instead the picker takes the face index from scene.ray_cast and reads that
polygon off the evaluated mesh, so an n-gon's four real edges are the four
candidates.
Verified:
scene.ray_castreturns the original object but alocation/normal/indexbelonging to the evaluated object's mesh. A subdivided cube raycast down Z reported index 20 of the evaluated mesh's 24 polygons, and that polygon's corners contained the hit point. So the lookup must go throughevaluated_get(depsgraph); usingobj.datawould index the wrong mesh entirely.
AUTO mode takes vertex, then edge, then face, each only within the soft-snap
pixel margin, so it degrades to the face out in the middle of one. Verified on
a default cube: cursor near a corner → VERTEX (1,1,1), near an edge midpoint
→ EDGE (0,1,1), mid-face → FACE (0,0,1), empty space → None.
Face normals are transformed by the inverse transpose, not the matrix — Roblox-style building scales axes freely and the plain matrix would skew them.
The marker and the live pick preview are drawn by ui/overlay.py, a single
POST_VIEW handler, using POLYLINE_UNIFORM_COLOR (viewportSize,
lineWidth, color) and POINT_UNIFORM_COLOR (color, size) — uniform
names taken from the 5.2 reference, not guessed. It draws with depth test off,
like the 3D cursor: a pivot you cannot see because it is inside the part is
worse than one floating over it. Cross arms are sized through
pixels_to_world, per this project's no-flat-world-space-thresholds rule.
- R spins the selection 90° about the drop surface's normal (world +Z when
free-dragging). T tips it onto the next of its six faces. Both are
modelled as "which of the object's six signed local axes is the resting one"
plus "how many quarter turns about the normal", which keeps the flush
placement exact — the depth solve already runs on the final rotation.
_signed_axessorts the six by world Z so index 0 is the axis it is already resting on, preserving the previous behaviour when neither key is pressed. - T implies alignment even when the Surface Align toggle is off. Tipping is a request to put a different face against the surface; refusing to align it would make the key do nothing.
- Ctrl+D stamps a copy and keeps dragging the original. Full copies (what
Ctrl+D means everywhere else in Blender), already coincident because
obj.copy()carries the transform, with internal parents re-pointed at the copies so the stamp is self-contained rather than hanging off parts still being dragged. Cancelling the drag removes the stamps and the meshes they orphaned. Verified end to end: 2 objects in, child copy parented to the parent copy, data actually copied, discard freed both objects and both meshes. - The header readout now carries the reference point's XYZ alongside the snap kind, plus spin/tip/stamp state.
- The Select tool's Soft Snap toggle is greyed while Grid is on, because
resolve_snaptakes the grid outright — the toggle was previously drawn as though the two were peers.
- The installed addon is not the repo. Blender loads
roblox_toolsfrom%APPDATA%/Blender Foundation/Blender/5.2/scripts/addons/roblox_tools/, which is a copy, and that copy is a pre-dragger snapshot — nodrag.py,snapping.pyorview_math.pyat all. The note in the 2026-08-24 entry saying the addon "is enabled directly from the repo path" is wrong. All verification above was done by disabling the installed copy (default_set=False), purgingsys.modulesafter disabling, and importing from the repo onsys.path. Nothing in the install was touched. roblox_tools.zipis still stale, now more so.GIZMO_GT_arrow_3din'NORMAL'draw style renders a stem, a box part-way along, and a cone at the tip — three glyphs per handle, so the six-arrow Move gizmo is busier than Studio's six clean cones. Blender's own Move tool gizmo draws box handles too, so this is stock styling rather than a regression, anddraw_style's full enum could not be read (draw_styleis a dynamic property, absent fromgz.bl_rna.properties, and a probe GizmoGroup'ssetup()never fired). Left alone rather than guessed at.
Deep read of the whole addon, written up as a 12-file docs/ folder. This file
stays the chronological log; docs/ is the structured view. See
docs/README.md for the index.
Four API questions were settled by probing the live Blender (5.2.0 LTS) rather
than assumed; all four are recorded in docs/09-blender-api-notes.md:
obj.bound_boxalready reflects modifier-evaluated bounds. A plane with a 4-unit Solidify reported Z −2…2 on the original object'sbound_box, matching bothevaluated_get(dg).bound_boxand the evaluated mesh's vertex range. This matters because the dragger's broad phase readsbound_boxwhile its narrow phase builds the BVH from the evaluated mesh — reading the code suggested they could disagree and the broad phase could reject a hittable object. They agree. Not a bug; do not "fix" it.- An EMPTY's
bound_boxis eight zero vectors in 5.2, not the-1.0the docs mention for "not available". So a selected Empty contributes exactly its origin to anylocal_aabb.drag.pyfilters Empties out;pivot.pyand the gizmos do not. Matrix.normalized()is column normalization and does not orthogonalize. A shear matrix's normalized columns still had dot 0.447. Solocal_basis_matrixgives unit-length but not necessarily perpendicular axes for a sheared object.- Keymap shadowing is real and same-keymap. Enumerating the resolved user
keyconfig shows
rotools.switch_toolordered ahead oftransform.rotate(R) andobject.subdivision_set(Ctrl+1..4) insideObject Mode. Pressing R in Object Mode no longer starts a rotate.
Gaps found and left alone (noticed, not fixed), full list in
docs/11-known-gaps.md:
roblox_tools.zipis stale — built 2026-08-23, so it is missingsnapping.py,view_math.py, anddrag.pyentirely, with 9 more files differing. Installing from it yields an addon with no dragger.CLAUDE.md's Layout section lists aui/package that does not exist in the working tree (the stale zip has an emptyui/entry, so it once did). All UI isdraw_settings; there is nobpy.types.Panelin the addon.dragger-addon-priorities.md, cited as the source for Tiers 0–6, is not in the repo.- The 15° rotate increment is re-applied to every scene on every addon enable and never restored on unregister — stronger than "set a default".
- Scale tool's
bl_descriptionstill promises "plane handles"; there are six single-axis handles and no plane handles.
Built from dragger-addon-priorities.md. Scope this pass was Tiers 0–2 only
(the raycast + BVH + reference-point engine). Tiers 3–6 — modal key layer,
blf/gpu HUD, animated tilt, per-object collidable filtering — are
deliberately not started.
The dragger is the Select tool, not a new tool. Confirmed with the user:
in Roblox Studio the Select tool doubles as the dragger, so dragging a part
moves it and dragging empty space box-selects. The priorities doc had assumed
an additive standalone tool; that assumption was wrong and the doc's Tier 0
bullet ("new modal operator") was kept, but wired into rotools.select
rather than given its own ToolDef.
rotools.selectgained anallow_dragbool property, default False. Move/Scale/Rotate bind the same operator for plain click-select, and a body drag there would fight their gizmos. Onlyselect_tool.pypasses it True, via thebl_keymapthird element:{"properties": [("allow_drag", True)]}. That format is asserted as a list of tuples in Blender's ownbl_keymap_utils/io.py:_init_properties_from_data— a dict silently fails.select.pyraycasts once on mouse-down to decide up front whether the drag threshold routes to the dragger or to box-select.drag.pyre-raycasts in its owninvoke, so it stays independently invokable. Two raycasts, but only on drag start, never per frame.
Why the per-frame drop target can't use scene.ray_cast: it has no way to
exclude objects, and the dragged geometry is exactly what's under the cursor.
Hence core/snapping.py:DragScene, which owns per-object BVHTrees. The
mouse-down grab raycast does use scene.ray_cast — nothing to exclude yet.
- BVH trees are built in world space from the evaluated mesh, lazily, and
cached for the lifetime of one drag. Valid because nothing but the dragged
objects moves during a drag. World space means the hot loop does no matrix
conversion, and a BVH face index maps straight back to that triangle's three
world corners — which is what makes the coarse→fine handoff cheap: one
find_nearestper object yields the face hit and its vertices/edge midpoints. - The synthetic ground is an analytic infinite Z-up plane, not a mesh quad. A finite baseplate stand-in would have edges you could slide off; the analytic version means the dragger always has something to land on. It has no vertices, so it participates in face/grid snapping only.
Placement formula (drag.py:_apply), the part worth not re-deriving:
with corner offsets o taken relative to the grab point and a surface normal
n, let d = min((rot @ o)·n). Then position = surface.point - n*d rests
the box flush — only the normal component moves, so the grab point stays under
the cursor. Applied as one rigid transform
Translation(position) @ rot @ Translation(-grab_point) on top of each
object's start matrix, which preserves per-object scale and relative offsets.
Verified in-Blender: grabbing a cube by its top face still rests its
bottom on the surface (the grab point must not become the resting point).
- Surface align swings the object's resting axis (the local axis nearest
world +Z at grab time,
_resting_axis) onto the new normal — not the clicked face's normal. That's what makes a part dragged off the floor onto a wall tip over and lie against it. Alt overrides, per Tier 2. - Snap priority is vertex > edge > face > grid. Soft geometry snapping is checked first and wins outright inside the margin; the grid is the fallback so open-space drags still land on round numbers. Reconciles the doc's two statements ("soft snap when hard snap is off" vs "grid fallback").
- All pixel thresholds go through
core/view_math.py:pixels_to_world. The conversion is derived fromwindow_matrix[1][1](= 1/tan(fovy/2) perspective, 2/view_height ortho), not tuned by eye — one pixel spans2*depth / (m11 * region.height)world units, depth pinned to 1 for ortho. Do not hardcode a flat world-space margin anywhere.
Hard grid snap now outranks soft snap (resolve_snap), reversing the first
pass. Reported symptom: increment set to 1, but dragged parts landing hundredths
off — a screenshot showed Location X -1.06, Y -0.03 with Soft Snap enabled.
Cause: soft snap was checked first, so any vertex/edge/face within the margin
won and dragged the result to that neighbour's arbitrary position. Reproduced
exactly with a neighbour cube parked at (1.06, 0.03, 1.0): the reference point
snapped to its vertex at (0.06, -0.97, 0) instead of the grid's (0, -1, 0).
The priorities doc contains both "soft snap ... when hard snap is off" and
"vertex > edge > face > grid fallback". The first pass reconciled these by
letting geometry always win; that was the wrong call — an increment that
silently loses to a nearby vertex is not an increment. Now: grid on → grid
only, exact. Grid off → soft pass, with vertex > edge > face preserved within
it. Z still comes from the flush-placement step, not from snapping, which is
why Z read as an exact 1 m even while X/Y were off.
Modifier keys (Tier 3's shift-hold, pulled forward because the tool felt inert without it): Shift flips snap-enabled for as long as it is held — a per-frame local flag, never written back to the scene toggles, per the doc. Alt keeps the original orientation (Tier 2). Ctrl is deliberately unbound: the doc's only Ctrl binding is Ctrl+D stamping, which is still unbuilt, and there was no verified Roblox meaning for plain Ctrl to copy.
_modifiers(event)exists becauseevent.shiftstill reads True on the LEFT_SHIFT release that turns it off. On a shift/alt event,event.valueis the authority. Do not readevent.shiftdirectly in_apply.modalre-applies on modifier press/release as well as MOUSEMOVE, so the part updates the instant Shift goes down rather than on the next nudge.
Status bar / header. workspace.status_text_set accepts a callable
(fn(self, context) with a real self.layout), which is what allows the
EVENT_/MOUSE_ icons Blender's own modal tools use — a plain string gets no
icons. Both the status text and area.header_text_set must be cleared on
FINISHED and CANCELLED or the hints stay on screen after the drag ends.
Gotcha — reloading this addon during a session. It's enabled directly from
the repo path, so edits are live, but del sys.modules["roblox_tools.*"]
before addon_disable orphans the module and unregister silently no-ops
("addon_utils.disable: roblox_tools not enabled"). The WorkSpaceTool
subclasses then survive and the next enable dies with "Tool
'rotools.select_tool' already exists!". Fix: disable first, then purge
sys.modules. To recover once stuck, unregister the strays by walking
bpy.types.WorkSpaceTool.__subclasses__() for bl_idname starting rotools..
Verified against Blender 5.2.0 LTS / Python 3.13 (the version actually running
this addon — __pycache__ tags are cpython-313).
Snap offsets are constrained to the drop surface's tangent plane — the
single least obvious line in drag.py:_apply, and load-bearing. The placement
step puts the box exactly flush; applying a raw 3D snap offset on top of that
undoes it. Measured before the constraint went in, on a 1×1×1 cube dropped on a
slab whose top face is at z=0.5:
- vertex snap toward a vertex just under the surface → box bottom driven to z=0.1, i.e. 0.4 units sunk into the slab
- grid snap → box bottom to z=0.0, because rounding pulled Z to a round number and lifted/dropped the box off its surface
Projecting the offset onto the tangent plane (offset -= normal * offset.dot(normal)) fixes both: the box still slides in X/Y toward the snap
target and the reported snap kind is unchanged, but flush contact is now an
invariant that snapping cannot break. Do not "simplify" this back to
position += snap.point - reference.
This is also why FACE is safe to keep in SNAP_PRIORITY as the doc
specifies: the offset to the nearest point on the surface being rested on is
purely along the normal, so the projection leaves exactly zero. FACE is a
principled no-op rather than the hazard it was. Free-drag (no surface hit) has
no tangent plane, so its snapping stays unconstrained 3D — correct, since there
is no flush placement to preserve.
- Established
CLAUDE.mdwith a strict no-guessing rule and scope-discipline rules for this repo. - Addon structure at time of writing:
core/,gizmos/,operators/,tools/,ui/, registered via aMODULEStuple inroblox_tools/__init__.py(register/unregister loop pattern). - No prior notes exist yet — add entries here going forward whenever a non-obvious decision is made, a bug's root cause is found, or work is left unfinished.