diff --git a/ISSUES.md b/ISSUES.md index 9383fcd..d37e58f 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -67,7 +67,12 @@ |NON_OBJECT_EXTRAS|Prefer JSON Objects for extras.|Information| |NON_RELATIVE_URI|Non-relative URI found: '`%1`'.|Warning| |NON_REQUIRED_EXTENSION|Extension '`%1`' cannot be optional.|Error| +|OMI_COLLIDER_INVALID_CAPSULE_HEIGHT|The capsule height must be at least twice the radius.|Error| +|OMI_COLLIDER_TRIMESH_TRIGGER|This collider is both a trimesh and a trigger. This is valid but not recommended since trimeshes do not have an interior volume and the trigger may not work as expected.|Information| +|OMI_PHYSICS_BODY_INVALID_INERTIA_TENSOR|This physics body has an invalid inertia tensor. The inertia tensor must be a symmetric 3x3 matrix.|Error| +|OMI_PHYSICS_BODY_MISSING_COLLIDER|This physics body does not have any colliders. This is valid but body will not collide with anything.|Information| |ROTATION_NON_UNIT|Rotation quaternion must be normalized.|Error| +|SHARES_NODE_WITH|`%1` must be on its own glTF node, it cannot be on the same glTF node as `%2`. Move the `%3` to a child node.|Error| |SKIN_NO_COMMON_ROOT|Joints do not have a common root.|Error| |SKIN_SKELETON_INVALID|Skeleton node is not a common root.|Error| |UNKNOWN_ASSET_MAJOR_VERSION|Unknown glTF major asset version: `%1`.|Error| diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 4e57567..887b26f 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -484,6 +484,13 @@ class SemanticError extends IssueType { (args) => 'Prefer JSON Objects for extras.', Severity.Information); + static final SemanticError sharesNodeWith = SemanticError._( + 'SHARES_NODE_WITH', + (args) => '${args[0]} must be on its own glTF node, ' + 'it cannot be on the same glTF node as ${args[1]}.' + '${args.length > 2 ? ' Move the ${args[2]} to a child node.' : ''}', + Severity.Error); + static final SemanticError extraProperty = SemanticError._( 'EXTRA_PROPERTY', (args) => 'This property should not be defined as it will not be used.', @@ -533,6 +540,34 @@ class SemanticError extends IssueType { 'minimum is equal to the thickness maximum.', Severity.Information); + static final SemanticError omiColliderInvalidCapsuleHeight = + SemanticError._( + 'OMI_COLLIDER_INVALID_CAPSULE_HEIGHT', + (args) => 'The capsule height must be at least twice the radius.', + Severity.Error); + + static final SemanticError omiColliderTrimeshTrigger = + SemanticError._( + 'OMI_COLLIDER_TRIMESH_TRIGGER', + (args) => 'This collider is both a trimesh and a trigger. This ' + 'is valid but not recommended since trimeshes do not have an ' + 'interior volume and the trigger may not work as expected.', + Severity.Information); + + static final SemanticError omiPhysicsBodyMissingCollider = + SemanticError._( + 'OMI_PHYSICS_BODY_MISSING_COLLIDER', + (args) => 'This physics body does not have any colliders. ' + 'This is valid but body will not collide with anything.', + Severity.Information); + + static final SemanticError omiPhysicsBodyInvalidInertiaTensor = + SemanticError._( + 'OMI_PHYSICS_BODY_INVALID_INERTIA_TENSOR', + (args) => 'This physics body has an invalid inertia tensor. ' + 'The inertia tensor must be a symmetric 3x3 matrix.', + Severity.Error); + SemanticError._(String type, ErrorFunction message, [Severity severity = Severity.Error]) : super(type, message, severity); diff --git a/lib/src/ext/OMI_collider/omi_collider.dart b/lib/src/ext/OMI_collider/omi_collider.dart new file mode 100644 index 0000000..b79347e --- /dev/null +++ b/lib/src/ext/OMI_collider/omi_collider.dart @@ -0,0 +1,255 @@ +// Copyright 2022 The Khronos Group Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +library gltf.extensions.omi_collider; + +import 'package:gltf/src/base/gltf_property.dart'; +import 'package:gltf/src/ext/extensions.dart'; + +const String OMI_COLLIDER = 'OMI_collider'; + +const String IS_TRIGGER = 'isTrigger'; +const String SIZE = 'size'; +const String RADIUS = 'radius'; +const String HEIGHT = 'height'; +const String MESH = 'mesh'; + +const String BOX = 'box'; +const String SPHERE = 'sphere'; +const String CAPSULE = 'capsule'; +const String CYLINDER = 'cylinder'; +const String HULL = 'hull'; +const String TRIMESH = 'trimesh'; + +const String COLLIDER = 'collider'; +const String COLLIDERS = 'colliders'; + +const List OMI_COLLIDER_GLTF_MEMBERS = [COLLIDERS]; +const List OMI_COLLIDER_NODE_MEMBERS = [COLLIDER]; + +const List OMI_COLLIDER_COLLIDER_MEMBERS = [ + TYPE, + IS_TRIGGER, + SIZE, + RADIUS, + HEIGHT, + MESH +]; + +const List OMI_COLLIDER_COLLIDER_TYPES = [ + BOX, + SPHERE, + CAPSULE, + CYLINDER, + HULL, + TRIMESH +]; + +const Extension omiColliderExtension = + Extension(OMI_COLLIDER, { + Gltf: ExtensionDescriptor(OmiColliderGltf.fromMap), + Node: ExtensionDescriptor(OmiColliderNode.fromMap) +}); + +// The document-level extension that holds a list of colliders. +class OmiColliderGltf extends GltfProperty { + final SafeList colliders; + + OmiColliderGltf._( + this.colliders, Map extensions, Object extras) + : super(extensions, extras); + + static OmiColliderGltf fromMap( + Map map, Context context) { + if (context.validate) { + checkMembers(map, OMI_COLLIDER_GLTF_MEMBERS, context); + } + + SafeList colliders; + final colliderMaps = getMapList(map, COLLIDERS, context); + if (colliderMaps != null) { + colliders = SafeList(colliderMaps.length, COLLIDERS); + context.path.add(COLLIDERS); + for (var i = 0; i < colliderMaps.length; i++) { + final colliderMap = colliderMaps[i]; + context.path.add(i.toString()); + colliders[i] = OmiColliderCollider.fromMap(colliderMap, context); + context.path.removeLast(); + } + context.path.removeLast(); + } else { + colliders = SafeList.empty(COLLIDERS); + } + + return OmiColliderGltf._( + colliders, + getExtensions(map, OmiColliderGltf, context), + getExtras(map, context)); + } + + @override + void link(Gltf gltf, Context context) { + if (colliders != null) { + context.path.add(COLLIDERS); + final extCollectionList = context.path.toList(growable: false); + context.extensionCollections[colliders] = extCollectionList; + colliders.forEachWithIndices((i, collider) { + context.path.add(i.toString()); + collider.link(gltf, context); + context.path.removeLast(); + }); + context.path.removeLast(); + } + } +} + +// The main data structure that stores collider data. +class OmiColliderCollider extends GltfChildOfRootProperty { + final String type; + final bool isTrigger; + final List size; + final double radius; + final double height; + final int mesh; + + OmiColliderCollider._(this.type, this.isTrigger, this.size, this.radius, + this.height, this.mesh, String name, + Map extensions, Object extras) + : super(name, extensions, extras); + + static OmiColliderCollider fromMap( + Map map, Context context) { + if (context.validate) { + checkMembers(map, OMI_COLLIDER_COLLIDER_MEMBERS, context); + } + + final type = getString(map, TYPE, context, + list: OMI_COLLIDER_COLLIDER_TYPES, req: true); + + final isTrigger = getBool(map, IS_TRIGGER, context); + + final size = getFloatList(map, SIZE, context, + lengthsList: const [3], min: 0, def: const [1.0, 1.0, 1.0]); + + final radius = getFloat(map, RADIUS, context, min: 0, def: 0.5); + final height = getFloat(map, HEIGHT, context, min: 0, def: 2); + + final mesh = getIndex(map, MESH, context, req: false); + + if (context.validate) { + if (map.containsKey(SIZE) && type != BOX) { + context.addIssue(SemanticError.extraProperty, name: SIZE); + } + if (type != CAPSULE && type != CYLINDER) { + if (map.containsKey(HEIGHT)) { + context.addIssue(SemanticError.extraProperty, name: HEIGHT); + } + if (type != SPHERE) { + if (map.containsKey(RADIUS)) { + context.addIssue(SemanticError.extraProperty, name: RADIUS); + } + } + } + if (type == CAPSULE && height < radius * 2) { + context.addIssue(SemanticError.omiColliderInvalidCapsuleHeight, + name: HEIGHT); + } + if (map.containsKey(MESH) && type != TRIMESH && type != HULL) { + context.addIssue(SemanticError.extraProperty, name: MESH); + } + if (type == TRIMESH && isTrigger) { + context.addIssue(SemanticError.omiColliderTrimeshTrigger, + name: IS_TRIGGER); + } + } + + return OmiColliderCollider._( + type, + isTrigger, + size, + radius, + height, + mesh, + getName(map, context), + getExtensions(map, OmiColliderCollider, context), + getExtras(map, context)); + } +} + +// The node-level extension that references a document-level collider by index. +class OmiColliderNode extends GltfProperty { + final int _colliderIndex; + + OmiColliderCollider _collider; + + OmiColliderNode._( + this._colliderIndex, Map extensions, Object extras) + : super(extensions, extras); + + static OmiColliderNode fromMap( + Map map, Context context) { + if (context.validate) { + checkMembers(map, OMI_COLLIDER_NODE_MEMBERS, context); + } + + return OmiColliderNode._( + getIndex(map, COLLIDER, context), + getExtensions(map, OmiColliderNode, context), + getExtras(map, context)); + } + + @override + void link(Gltf gltf, Context context) { + if (!context.validate) { + return; + } + final collidersExtension = gltf.extensions[omiColliderExtension.name]; + if (collidersExtension is OmiColliderGltf) { + // Mark the collider that this node references as used. + if (_colliderIndex != -1) { + _collider = collidersExtension.colliders[_colliderIndex]; + if (_collider == null) { + context.addIssue(LinkError.unresolvedReference, + name: COLLIDER, args: [_colliderIndex]); + } else { + _collider.markAsUsed(); + // If this is a trimesh, we also need to mark the mesh as used. + final meshIndex = _collider.mesh; + if (meshIndex != -1) { + if (meshIndex >= gltf.meshes.length) { + context.addIssue(LinkError.unresolvedReference, + name: MESH, args: [meshIndex]); + } else { + gltf.meshes[meshIndex].markAsUsed(); + } + } + } + } + // Get the glTF node that this collider is attached to. + final path = context.path; + if (path.length < 2 || path[0] != 'nodes') { + return; + } + final nodeIndex = int.tryParse(path[1]); + if (nodeIndex == null) { + return; + } + final node = gltf.nodes[nodeIndex]; + // Ensure that the collider is not on the same node as a mesh or camera. + if (node.mesh != null) { + context.addIssue(SemanticError.sharesNodeWith, + args: ['A collider', 'a mesh']); + } + if (node.camera != null) { + context.addIssue(SemanticError.sharesNodeWith, + args: ['A collider', 'a camera']); + } + } else { + context.addIssue(SchemaError.unsatisfiedDependency, + args: ['/$EXTENSIONS/${omiColliderExtension.name}']); + } + } + + OmiColliderCollider get collider => _collider; +} diff --git a/lib/src/ext/OMI_physics_body/omi_physics_body.dart b/lib/src/ext/OMI_physics_body/omi_physics_body.dart new file mode 100644 index 0000000..6444f33 --- /dev/null +++ b/lib/src/ext/OMI_physics_body/omi_physics_body.dart @@ -0,0 +1,139 @@ +// Copyright 2022 The Khronos Group Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +library gltf.extensions.omi_physics_body; + +import 'package:gltf/src/base/gltf_property.dart'; +import 'package:gltf/src/ext/extensions.dart'; + +const String OMI_PHYSICS_BODY = 'OMI_physics_body'; + +const String MASS = 'mass'; +const String LINEAR_VELOCITY = 'linearVelocity'; +const String ANGULAR_VELOCITY = 'angularVelocity'; +const String INERTIA_TENSOR = 'inertiaTensor'; + +const String STATIC = 'static'; +const String KINEMATIC = 'kinematic'; +const String CHARACTER = 'character'; +const String RIGID = 'rigid'; +const String VEHICLE = 'vehicle'; +const String TRIGGER = 'trigger'; + +const List OMI_PHYSICS_BODY_MEMBERS = [ + TYPE, + MASS, + LINEAR_VELOCITY, + ANGULAR_VELOCITY, + INERTIA_TENSOR +]; + +const List OMI_PHYSICS_BODY_TYPES = [ + STATIC, + KINEMATIC, + CHARACTER, + RIGID, + VEHICLE, + TRIGGER +]; + +class OmiPhysicsBody extends GltfProperty { + final String type; + final double mass; + final List linearVelocity; + final List angularVelocity; + final List inertiaTensor; + + OmiPhysicsBody._(this.type, this.mass, this.linearVelocity, + this.angularVelocity, this.inertiaTensor, + Map extensions, Object extras) + : super(extensions, extras); + + static OmiPhysicsBody fromMap(Map map, Context context) { + if (context.validate) { + checkMembers(map, OMI_PHYSICS_BODY_MEMBERS, context); + } + + final type = getString(map, TYPE, context, + list: OMI_PHYSICS_BODY_TYPES, req: true); + + final mass = getFloat(map, MASS, context, def: 1); + + final linearVelocity = getFloatList(map, LINEAR_VELOCITY, context, + lengthsList: const [3], def: const [0.0, 0.0, 0.0]); + + final angularVelocity = getFloatList(map, ANGULAR_VELOCITY, context, + lengthsList: const [3], def: const [0.0, 0.0, 0.0]); + + final inertiaTensor = getFloatList(map, INERTIA_TENSOR, context, + lengthsList: const [9], def: const + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + + return OmiPhysicsBody._( + type, + mass, + linearVelocity, + angularVelocity, + inertiaTensor, + getExtensions(map, OmiPhysicsBody, context), + getExtras(map, context)); + } + + @override + void link(Gltf gltf, Context context) { + if (!context.validate) { + return; + } + // Get the glTF node that this physics body is attached to. + final path = context.path; + if (path.length < 2 || path[0] != 'nodes') { + return; + } + final nodeIndex = int.tryParse(path[1]); + if (nodeIndex == null) { + return; + } + final node = gltf.nodes[nodeIndex]; + // Ensure that the physics body is not on the same node as a mesh or camera. + if (node.mesh != null) { + context.addIssue(SemanticError.sharesNodeWith, + args: ['A physics body', 'a mesh']); + } + if (node.camera != null) { + context.addIssue(SemanticError.sharesNodeWith, + args: ['A physics body', 'a camera']); + } + if (node.extensions.containsKey('OMI_collider')) { + context.addIssue(SemanticError.sharesNodeWith, + args: ['A physics body', 'a collider', 'collider']); + } + // Check that the physics body has at least one collider. + var hasCollider = false; + if (node.children != null) { + for (final child in node.children) { + if (child.extensions != null && + child.extensions.containsKey(OMI_COLLIDER)) { + hasCollider = true; + break; + } + } + } + if (!hasCollider) { + context.addIssue(SemanticError.omiPhysicsBodyMissingCollider, + name: OMI_PHYSICS_BODY); + } + // Ensure that the inertia tensor is a symmetric matrix. + if (inertiaTensor[1] != inertiaTensor[3] || + inertiaTensor[2] != inertiaTensor[6] || + inertiaTensor[5] != inertiaTensor[7]) { + context.addIssue(SemanticError.omiPhysicsBodyInvalidInertiaTensor, + name: INERTIA_TENSOR); + } + } +} + +const Extension omiPhysicsBodyExtension = Extension( + OMI_PHYSICS_BODY, { + Node: ExtensionDescriptor(OmiPhysicsBody.fromMap) +}); diff --git a/lib/src/ext/extensions.dart b/lib/src/ext/extensions.dart index cdf598e..abd35e6 100644 --- a/lib/src/ext/extensions.dart +++ b/lib/src/ext/extensions.dart @@ -32,6 +32,8 @@ import 'package:gltf/src/ext/KHR_materials_variants/KHR_materials_variants.dart' import 'package:gltf/src/ext/KHR_materials_volume/khr_materials_volume.dart'; import 'package:gltf/src/ext/KHR_mesh_quantization/khr_mesh_quantization.dart'; import 'package:gltf/src/ext/KHR_texture_transform/khr_texture_transform.dart'; +import 'package:gltf/src/ext/OMI_collider/omi_collider.dart'; +import 'package:gltf/src/ext/OMI_physics_body/omi_physics_body.dart'; import 'package:gltf/src/hash.dart'; import 'package:meta/meta.dart'; @@ -51,6 +53,8 @@ export 'package:gltf/src/ext/KHR_materials_variants/KHR_materials_variants.dart' export 'package:gltf/src/ext/KHR_materials_volume/khr_materials_volume.dart'; export 'package:gltf/src/ext/KHR_mesh_quantization/khr_mesh_quantization.dart'; export 'package:gltf/src/ext/KHR_texture_transform/khr_texture_transform.dart'; +export 'package:gltf/src/ext/OMI_collider/omi_collider.dart'; +export 'package:gltf/src/ext/OMI_physics_body/omi_physics_body.dart'; class Extension { const Extension(this.name, this.functions, @@ -111,7 +115,9 @@ const List kDefaultExtensions = [ khrMaterialsVariantsExtension, khrMaterialsVolumeExtension, khrMeshQuantizationExtension, - khrTextureTransformExtension + khrTextureTransformExtension, + omiColliderExtension, + omiPhysicsBodyExtension ]; // https://github.com/KhronosGroup/glTF/blob/main/extensions/Prefixes.md