CLAUDE.md forbids guessing about Blender API behaviour. This file records the
API facts RoTools depends on, each with the source that established it, so a
future change can check an assumption instead of re-deriving it.
Environment for every runtime probe below: Blender 5.2.0 LTS, Python 3.13, RoTools enabled from the repository path.
Legend:
- 📗 Docs — quoted from Blender's bundled Python API reference.
- 🔬 Probe — established by running code in a live Blender session.
- 📝 Source — established by reading Blender's own Python modules.
"BVH tree constructed from geometry passed in as arguments. […]
all_triangles: Use when all polygons are triangles for more efficient conversion."
Used by _build_tree, which calls mesh.calc_loop_triangles() first so the
precondition holds.
"Returns a tuple: (position, normal, index, distance). Values will all be
Noneif no hit is found."
index is the polygon index, which indexes the same tris list the tree was
built from — this is what makes the coarse → fine handoff in
nearest_features work without a second query.
ray_cast's direction is documented as "normalized internally", but
DragScene.ray_cast still requires a normalized direction from the caller so
that the BVH's reported distance is comparable to the analytic ground
plane's. mouse_ray guarantees it.
"Cast a ray onto evaluated geometry in world-space" Returns
(result, location, normal, index, object, matrix).
Used for the two grab-time raycasts. It has no way to exclude objects,
which is the entire reason DragScene exists — see
05-snapping-engine.md.
"Returns a quaternion representing the rotational difference between this vector and another." 2D vectors raise
AttributeError.
This is the surface-align rotation: rest_axes[tip_index].rotation_difference(up).
📗 The docstring reads: "Return a column normalized matrix (3x3 and 4x4 only). … for 4x4 matrices, the 4th column (translation) is left untouched."
🔬 Confirmed at runtime, and confirmed that it does not orthogonalize:
| Input | Column lengths before | Column lengths after |
|---|---|---|
Rotation(0.7,'Z') @ Diagonal(2,3,4) |
2, 3, 4 |
1, 1, 1 |
For a shear matrix [[1, 0.5, 0], [0, 1, 0], [0, 0, 1]], after
.normalized() the dot product of columns 0 and 1 was 0.447, not 0.
Consequence for RoTools: local_basis_matrix produces unit-length axes but
not necessarily perpendicular ones for a sheared object. Not handled — see
11-known-gaps.md.
📗 The docs say only: "Object's bounding box in object-space coordinates, all values are -1.0 when not available."
That leaves open whether it tracks modifier output, which matters because the
dragger's broad phase reads obj.bound_box while its narrow phase
builds a BVH from the evaluated mesh. If they disagreed, the broad phase
could reject an object the BVH would have hit.
🔬 Probed with a 1×1 plane carrying a 4-unit Solidify modifier, linked into the active scene so the real depsgraph evaluated it:
| Measurement | Z range |
|---|---|
obj.bound_box (original object) |
−2 … 2 |
obj.evaluated_get(depsgraph).bound_box |
−2 … 2 |
| Evaluated mesh vertex Z range | −2 … 2 |
They agree. The broad phase and narrow phase are consistent; this is not a source of missed collisions. (An earlier reading of the code suggested it might be — the probe disproved it.)
🔬 In Blender 5.2.0 LTS, a newly created bpy.data.objects.new(name, None)
reported bound_box as eight (0, 0, 0) triples — not the -1.0 the docs
mention for the "not available" case.
Consequence: all eight transformed corners collapse onto the Empty's
origin, so a selected Empty contributes exactly its origin to any local_aabb.
operators/drag.py filters type != 'EMPTY' out of the dragged set; the
gizmos do not filter. See 06-gizmos.md.
From operator_type_flag_items:
| Flag | Documented meaning |
|---|---|
REGISTER |
"Display in the info window and support the redo toolbar panel." |
UNDO |
"Push an undo event when the operator returns FINISHED (needed for operator redo, mandatory if the operator modifies Blender data)." |
GRAB_CURSOR |
"Use so the operator grabs the mouse focus, enables wrapping when continuous grab is enabled." |
BLOCKING |
"Block anything else from using the cursor." |
rotools.drag uses all four; rotools.select, rotools.switch_tool, and
rotools.toggle_orientation use {'UNDO'} only.
"Center Override, Force using this center value (when set)"
The "when set" is the operative phrase: it forces the pivot while set, and
reverts to the normal transform pivot once unset. That is why the scale
gizmo's CENTER mode calls property_unset("center_override") rather than
assigning a neutral value.
Recorded in PROJECT_NOTES.md from in-session testing: on the LEFT_SHIFT
event whose value is 'RELEASE', event.shift still reads True.
Consequence: during a modifier key's own event, event.value is the
authority. drag.py:_modifiers exists solely for this, and _apply must never
read event.shift directly.
after: "Optional identifiers this tool will be added after."separator: "When true, add a separator before this tool."group: "When true, add a new nested group of tools."
The four tools chain via after={previous_idname}, which is why their
registration order in MODULES is load-bearing.
3D— "Use in 3D viewport."SCALE— "Scale to respect zoom (otherwise zoom independent display size)."
SCALE is required for all three RoTools gizmos because they size themselves
from real selection bounds.
"Set the status text or None to clear. When text is a function, this will be called with the (header, context) arguments."
Passing a callable is what makes the EVENT_* / MOUSE_* icons possible;
a plain string gets no icons. The callable receives a real self.layout.
Both status_text_set(None) and area.header_text_set(None) must be called on
FINISHED and CANCELLED, or the hints persist after the drag ends.
Blender's own bl_keymap_utils/io.py:_init_properties_from_data asserts
that the "properties" value is a list of tuples. A dict fails silently —
allow_drag stays at its default and the dragger is quietly disabled.
{"properties": [("allow_drag", True)]} # correct
{"properties": {"allow_drag": True}} # silently does nothing"Angle used for rotation increments in 3D editors (in [0, 3.14159], default 0.0872665)"
Blender's default is 5°; RoTools overrides it to 15° (Roblox Studio's default) at registration.
Blender's restricted context during addon registration means
scene_state.register() cannot iterate bpy.data.scenes inline. It defers via
bpy.app.timers.register(_set_default_rotate_increment, first_interval=0) —
one tick later the context is unrestricted.
The callback returns None, so it is a one-shot and never reschedules.
🔬 Enumerated from the live wm.keyconfigs in a session with the addon
enabled. In the resolved user keyconfig, RoTools' Object Mode items are
ordered ahead of Blender's defaults in the same keymap:
| Key | RoTools item (keymap) | Blender default (keymap) |
|---|---|---|
Ctrl+1..4 |
rotools.switch_tool (Object Mode) |
object.subdivision_set (Object Mode) |
This is a same-keymap shadow, so that default is genuinely unreachable while the addon is enabled. Full discussion: 03-tools-and-keymaps.md.
Kept here so nobody mistakes an unknown for a verified fact:
- Blender 4.0 compatibility.
bl_infodeclares(4, 0, 0)as the minimum, but every check in this document was made on 5.2.0 LTS. The 4.0 claim is untested. - Whether
property_unsetcan raise in the scale gizmo'sCENTERbranch. Thetry/except Exceptionthere guards a case that has not been shown to occur. - Handler-ordering rules between different keymaps (
Object Modevs3D ViewvsWindow) for theQandWbindings. The keymaps each binding lives in were enumerated; the resolution rule between different keymaps was not independently confirmed. obj.nameuniqueness across linked libraries.DragScenekeys its caches byobj.name. Whether two library-linked objects can present the samenamethroughcontext.view_layer.objectswas not tested.