Fix #149678: Add USD import/export of UsdUIAccessibilityAPI data

This change adds support for importing and exporting accessibility
metadata to USD.

Details:

USD supports authoring accessibility metadata via the UsdUIAccessibilityAPI
schema. Two methods for authoring accessibility metadata are supported:
1) An accessibility label and description can be specified directly in
the USD export options via two new string export fields This data will
be set on the default prim of the exported stage and will have a
"standard" priority.

2) Accessibility data can be authored in the Blender object's Custom
Properties which will be written to the corresponding USD prim during
the export. If creating custom properties, the property name must be
defined in the following format:
- accessibility:\<namespace\>:label
- accessibility:\<namespace\>:description
- accessibility:\<namespace\>:priority

Since the AccessibilityAPI is a MultipleApply schema, the namespace
can specify an intended purpose for the accessibility metadata.

Note: Although the AccessibilityAPI schema supports time-samples,
Blender STRING properties are not keyframe-able, so this change only
writes default attribute values without any time-samples.

On import, the accessibility properties are populated back into the
object's Custom Properties.

Authored by Apple: Dan Knowlton

Pull Request: https://projects.blender.org/blender/blender/pulls/149682
This commit is contained in:
Michael B Johnson 2025-12-09 22:48:13 +01:00 • committed by Jesse Yurkovich
parent 17945ce420
commit 54ce53ac4e
7 changed files with 321 additions and 2 deletions

View file

@ -364,6 +364,9 @@ static wmOperatorStatus wm_usd_export_exec(bContext *C, wmOperator *op)
RNA_string_get(op->ptr, "custom_properties_namespace", params.custom_properties_namespace);
RNA_string_get(op->ptr, "collection", params.collection);
params.accessibility_label = RNA_string_get(op->ptr, "accessibility_label");
params.accessibility_description = RNA_string_get(op->ptr, "accessibility_description");
bool ok = USD_export(C, filepath, &params, as_background_job, op->reports);
return as_background_job || ok ? OPERATOR_FINISHED : OPERATOR_CANCELLED;
@ -497,6 +500,14 @@ static void wm_usd_export_draw(bContext *C, wmOperator *op)
}
}
if (blender::ui::Layout *panel = layout.panel(
C, "USD_export_accessibility", true, IFACE_("Accessibility")))
{
blender::ui::Layout &col = panel->column(false);
col.prop(ptr, "accessibility_label", UI_ITEM_NONE, std::nullopt, ICON_NONE);
col.prop(ptr, "accessibility_description", UI_ITEM_NONE, std::nullopt, ICON_NONE);
}
if (blender::ui::Layout *panel = layout.panel(
C, "USD_export_experimental", true, IFACE_("Experimental")))
{
@ -732,6 +743,22 @@ void WM_OT_usd_export(wmOperatorType *ot)
"to blender object and data names which are always exported in the "
"'userProperties:blender' namespace");
prop = RNA_def_string(ot->srna,
"accessibility_label",
nullptr,
0,
"Label",
"Set the accessibility label for the exported stage's default prim");
RNA_def_property_flag(prop, PROP_SKIP_SAVE);
prop = RNA_def_string(ot->srna,
"accessibility_description",
nullptr,
0,
"Description",
"Set the accessibility description for the exported stage's default prim");
RNA_def_property_flag(prop, PROP_SKIP_SAVE);
RNA_def_boolean(ot->srna,
"author_blender_name",
true,

View file

@ -23,6 +23,7 @@
#include <pxr/usd/usdGeom/tokens.h>
#include <pxr/usd/usdGeom/xform.h>
#include <pxr/usd/usdGeom/xformCommonAPI.h>
#include <pxr/usd/usdUI/accessibilityAPI.h>
#include <pxr/usd/usdUtils/usdzPackage.h>
#include "MEM_guardedalloc.h"
@ -195,6 +196,35 @@ static void ensure_root_prim(pxr::UsdStageRefPtr stage, const USDExportParams &p
}
}
/**
* If the user has provided an accessibility label and description for the export,
* write that information to the exported stage's default prim. This information
* will be written with the `UsdUIAccessibilityAPI` under the `default`
* namespace. Note: The information will only be added if the label is non-empty.
*/
static void write_root_accessibility_information(pxr::UsdStageRefPtr stage,
const USDExportParams &params)
{
/* Don't apply the API if both the label and description are empty. */
if (params.accessibility_label.empty() && params.accessibility_description.empty()) {
return;
}
pxr::UsdUIAccessibilityAPI accessibility_api = pxr::UsdUIAccessibilityAPI::ApplyDefaultAPI(
stage->GetDefaultPrim());
if (!accessibility_api) {
return;
}
if (!params.accessibility_label.empty()) {
accessibility_api.CreateLabelAttr().Set(params.accessibility_label);
}
if (!params.accessibility_description.empty()) {
accessibility_api.CreateDescriptionAttr().Set(params.accessibility_description);
}
}
static void report_job_duration(const ExportJobData *data)
{
timeit::Nanoseconds duration = timeit::Clock::now() - data->start_time;
@ -587,6 +617,9 @@ pxr::UsdStageRefPtr export_to_stage(const USDExportParams &params,
}
}
/* Write accessibility information to the default prim. */
write_root_accessibility_information(usd_stage, params);
if (params.use_instancing) {
process_scene_graph_instances(params, usd_stage);
}

View file

@ -9,6 +9,7 @@
#include "BKE_idprop.hh"
#include <pxr/usd/usd/attribute.h>
#include <pxr/usd/usdUI/accessibilityAPI.h>
#include "CLG_log.h"
static CLG_LogRef LOG = {"io.usd"};
@ -155,11 +156,49 @@ static void set_double_prop(IDProperty *idgroup, const StringRefNull prop_name,
IDP_AddToGroup(idgroup, prop);
}
static void set_accessibility_property(const pxr::UsdAttribute &attr,
IDProperty *idgroup,
const pxr::UsdTimeCode time_code)
{
/* Only set the property if the attribute has an authored value. */
if (!attr.IsAuthored()) {
return;
}
/* Since STRING properties do not support keyframes, if there is already a value written
* for this property, don't try to write it again. */
std::string property_name = attr.GetName().GetString();
if (IDP_GetPropertyFromGroup(idgroup, property_name)) {
return;
}
pxr::SdfValueTypeName type_name = attr.GetTypeName();
if (type_name == pxr::SdfValueTypeNames->String) {
std::string value;
if (attr.Get<std::string>(&value, time_code)) {
set_string_prop(idgroup, property_name, value);
}
}
else if (type_name == pxr::SdfValueTypeNames->Token) {
pxr::TfToken value;
if (attr.Get<pxr::TfToken>(&value, time_code)) {
set_string_prop(idgroup, property_name, value.GetString());
}
}
}
void set_id_props_from_prim(ID *id,
const pxr::UsdPrim &prim,
const eUSDPropertyImportMode property_import_mode,
const pxr::UsdTimeCode time_code)
{
for (const auto &api : pxr::UsdUIAccessibilityAPI::GetAll(prim)) {
IDProperty *idgroup = IDP_EnsureProperties(id);
set_accessibility_property(api.GetLabelAttr(), idgroup, time_code);
set_accessibility_property(api.GetDescriptionAttr(), idgroup, time_code);
set_accessibility_property(api.GetPriorityAttr(), idgroup, time_code);
}
pxr::UsdAttributeVector attribs = prim.GetAuthoredAttributes();
if (attribs.empty()) {
return;

View file

@ -10,6 +10,7 @@
#include <pxr/base/tf/stringUtils.h>
#include <pxr/usd/usdGeom/bboxCache.h>
#include <pxr/usd/usdGeom/scope.h>
#include <pxr/usd/usdUI/accessibilityAPI.h>
#include "BKE_customdata.hh"
@ -27,6 +28,76 @@ namespace usdtokens {
static const pxr::TfToken blender_ns("userProperties:blender", pxr::TfToken::Immortal);
} // namespace usdtokens
namespace {
struct AccessibilityPropertyName {
pxr::TfToken property_namespace;
pxr::TfToken property_base_name;
};
} // anonymous namespace
static std::optional<AccessibilityPropertyName> parse_accessibility_property_name(
IDProperty *prop, bool allow_unicode)
{
std::vector<std::string> property_tokens = pxr::TfStringTokenize(prop->name, ":");
/* First check if the property name matches the UsdUIAccessibility format exactly. */
if (property_tokens.size() == 3) {
pxr::TfToken accessibility_token(property_tokens[0]);
pxr::TfToken basename(property_tokens[2]);
if (accessibility_token == pxr::UsdUITokens->accessibility &&
pxr::UsdUIAccessibilityAPI::IsSchemaPropertyBaseName(basename))
{
AccessibilityPropertyName property_name;
/* Sanitize the namespace since this is user-generated and might need to be conformed
* to the `allow_unicode` export setting. */
property_name.property_namespace = pxr::TfToken(
blender::io::usd::make_safe_name(property_tokens[1], allow_unicode));
property_name.property_base_name = basename;
return property_name;
}
}
return std::nullopt;
}
static bool is_valid_accessibility_priority(const pxr::TfToken &token)
{
return token == pxr::UsdUITokens->low || token == pxr::UsdUITokens->standard ||
token == pxr::UsdUITokens->high;
}
/**
* Write the accessibility property on the given prim. Note: although the
* UsdUIAccessibilityAPI DOES allow time-sampled data for the `label`
* and `description` properties, Blender does not currently support
* keyframes on string custom properties so time-sample authoring will
* not be done here.
*/
static void write_accessibility_property(const pxr::UsdPrim &prim,
const AccessibilityPropertyName &property_name,
const std::string &value)
{
pxr::UsdUIAccessibilityAPI accessibility_api = pxr::UsdUIAccessibilityAPI::Apply(
prim, property_name.property_namespace);
if (!accessibility_api) {
return;
}
if (property_name.property_base_name == pxr::UsdUITokens->label) {
accessibility_api.CreateLabelAttr().Set(value);
}
else if (property_name.property_base_name == pxr::UsdUITokens->description) {
accessibility_api.CreateDescriptionAttr().Set(value);
}
else if (property_name.property_base_name == pxr::UsdUITokens->priority) {
pxr::TfToken priority(value);
if (is_valid_accessibility_priority(priority)) {
accessibility_api.CreatePriorityAttr().Set(priority);
}
}
}
static std::string get_mesh_active_uvlayer_name(const Object *ob)
{
if (!ob || ob->type != OB_MESH || !ob->data) {
@ -382,6 +453,16 @@ void USDAbstractWriter::write_user_properties(const pxr::UsdPrim &prim,
continue;
}
if (auto accessibility_property_name = parse_accessibility_property_name(
prop, usd_export_context_.export_params.allow_unicode))
{
if (prop->type == IDP_STRING && prop->data.pointer) {
write_accessibility_property(
prim, *accessibility_property_name, static_cast<char *>(prop->data.pointer));
}
continue;
}
std::vector<std::string> path_names = pxr::TfStringTokenize(prop->name, ":");
/* If the path does not already have a namespace prefix, prepend the default namespace

View file

@ -179,6 +179,9 @@ struct USDExportParams {
char collection[MAX_ID_NAME - 2] = "";
char custom_properties_namespace[MAX_IDPROP_NAME] = "";
std::string accessibility_label = "";
std::string accessibility_description = "";
eUSDSceneUnits convert_scene_units = eUSDSceneUnits::USD_SCENE_UNITS_METERS;
float custom_meters_per_unit = 1.0f;

View file

@ -9,7 +9,7 @@ import pprint
import sys
import tempfile
import unittest
from pxr import Gf, Sdf, Usd, UsdGeom, UsdMtlx, UsdShade, UsdSkel, UsdUtils, UsdVol
from pxr import Gf, Sdf, Usd, UsdGeom, UsdMtlx, UsdShade, UsdSkel, UsdUI, UsdUtils, UsdVol
import bpy
@ -1980,6 +1980,86 @@ class USDExportTest(AbstractUSDTest):
self.assertLess(len(uv_values), 24,
f"Unique UV count ({len(uv_values)}) should be less than face vertex count (24)")
def test_export_accessibility(self):
"""Validate that writing UsdUIAccessibilityAPI metadata exports correctly."""
def verify_accessibility_api(prim, namespace, label, description=None, priority=None):
self.assertTrue(prim.HasAPI(UsdUI.AccessibilityAPI))
accessibility_api = UsdUI.AccessibilityAPI(prim, namespace)
label_attr = accessibility_api.GetLabelAttr()
self.assertTrue(label_attr.HasAuthoredValue())
self.assertEqual(label_attr.Get(), label)
if description is not None:
description_attr = accessibility_api.GetDescriptionAttr()
self.assertTrue(description_attr.HasAuthoredValue())
self.assertEqual(description_attr.Get(), description)
if priority is not None:
priority_attr = accessibility_api.GetPriorityAttr()
self.assertTrue(priority_attr.HasAuthoredValue())
self.assertEqual(priority_attr.Get(), priority)
# Create a few objects to export.
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend"))
bpy.ops.mesh.primitive_uv_sphere_add()
sphere = bpy.context.active_object
sphere.name = "Sphere"
sphere_label = "a sphere"
sphere_description = "a primitive uv sphere"
sphere["accessibility:default:label"] = sphere_label
sphere["accessibility:default:description"] = sphere_description
sphere["accessibility:default:priority"] = UsdUI.Tokens.high
# Apply a second set of accessibility data.
sphere_color_label = "blue"
sphere_color_description = "a cool slightly greenish blue"
sphere["accessibility:color:label"] = sphere_color_label
sphere["accessibility:color:description"] = sphere_color_description
export_path = self.tempdir / "accessibility_basic.usda"
root_label = "Accessibility Test"
root_description = "This is an accessibility test from Python!"
self.export_and_validate(
filepath=str(export_path),
export_custom_properties=True,
accessibility_label=root_label,
accessibility_description=root_description,
selected_objects_only=True,
)
stage = Usd.Stage.Open(str(export_path))
root_prim = stage.GetPrimAtPath("/root")
self.assertTrue(root_prim.IsValid())
sphere_prim = stage.GetPrimAtPath(f"/root/{sphere.name}")
self.assertTrue(sphere_prim.IsValid())
# Check the accessibility metadata on the root prim (set via the export args).
verify_accessibility_api(root_prim, UsdUI.Tokens.default_, root_label, root_description)
# Check the accessibility metadata exported from custom properties.
verify_accessibility_api(
sphere_prim, UsdUI.Tokens.default_, sphere_label, sphere_description, UsdUI.Tokens.high)
verify_accessibility_api(
sphere_prim, "color", sphere_color_label, sphere_color_description)
# Test another export, but this time do not have the root prim. Verify
# that the export settings take precedence over custom properties.
export_path = self.tempdir / "accessibility_basic_no_root.usda"
self.export_and_validate(
filepath=str(export_path),
export_custom_properties=True,
accessibility_label=root_label,
accessibility_description=root_description,
root_prim_path="",
selected_objects_only=True,
)
stage = Usd.Stage.Open(str(export_path))
root_prim = stage.GetPrimAtPath(f"/{sphere.name}")
self.assertTrue(root_prim.IsValid())
# Check that the accessibility information is pulled from the export args.
verify_accessibility_api(root_prim, UsdUI.Tokens.default_, root_label, root_description)
class USDHookBase:
instructions = {}

View file

@ -8,7 +8,7 @@ import pathlib
import sys
import tempfile
import unittest
from pxr import Ar, Gf, Sdf, Usd, UsdGeom, UsdShade
from pxr import Ar, Gf, Sdf, Usd, UsdGeom, UsdShade, UsdUI
import bpy
@ -2116,6 +2116,62 @@ class USDImportTest(AbstractUSDTest):
bpy.utils.unregister_class(ImportMtlxTextureUSDHook)
def test_import_accessibility(self):
"""Test importing accessibility metadata as custom properties."""
# Create a simple USD file with accessibility metadata
usd_path = self.tempdir / "accessibility_test.usda"
stage = Usd.Stage.CreateNew(str(usd_path))
# Create a cube with accessibility metadata
prim = stage.DefinePrim("/root", "Xform")
stage.SetDefaultPrim(prim)
# Add default namespace
default_api = UsdUI.AccessibilityAPI.Apply(prim, "default")
label_attr = default_api.CreateLabelAttr()
label_attr.Set("Test Prim")
description_attr = default_api.CreateDescriptionAttr()
description_attr.Set("A test prim for import")
priority_attr = default_api.CreatePriorityAttr()
priority_attr.Set(UsdUI.Tokens.high)
# Add custom namespace
custom_api = UsdUI.AccessibilityAPI.Apply(prim, "alternate")
alt_label_attr = custom_api.CreateLabelAttr()
alt_label_attr.Set("Alternate Label")
alt_description_attr = custom_api.CreateDescriptionAttr()
alt_description_attr.Set("Alternate description")
stage.Save()
# Import the USD file
res = bpy.ops.wm.usd_import(filepath=str(usd_path))
self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {usd_path}")
# Verify the imported object has the accessibility custom properties
xform = bpy.data.objects.get("root")
self.assertIsNotNone(xform, "root prim object should be imported")
label_key = label_attr.GetName()
description_key = description_attr.GetName()
priority_key = priority_attr.GetName()
self.assertIn(label_key, xform, "Default label should be imported")
self.assertEqual(xform[label_key], label_attr.Get())
self.assertIn(description_key, xform, "Default description should be imported")
self.assertEqual(xform[description_key], description_attr.Get())
self.assertIn(priority_key, xform, "Default priority should be imported")
self.assertEqual(xform[priority_key], priority_attr.Get())
alt_label_key = alt_label_attr.GetName()
alt_description_key = alt_description_attr.GetName()
self.assertIn(alt_label_key, xform, "Alternate label should be imported")
self.assertEqual(xform[alt_label_key], alt_label_attr.Get())
self.assertIn(alt_description_key, xform, "Alternate description should be imported")
self.assertEqual(xform[alt_description_key], alt_description_attr.Get())
class USDImportComparisonTest(unittest.TestCase):
@classmethod