diff --git a/addons/gltf_khr_xmp_copyright/LICENSE b/addons/gltf_khr_xmp_copyright/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/addons/gltf_khr_xmp_copyright/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/addons/gltf_khr_xmp_copyright/gltf_khr_xmp_plugin.gd b/addons/gltf_khr_xmp_copyright/gltf_khr_xmp_plugin.gd new file mode 100644 index 0000000..00ac976 --- /dev/null +++ b/addons/gltf_khr_xmp_copyright/gltf_khr_xmp_plugin.gd @@ -0,0 +1,9 @@ +@tool +extends EditorPlugin + + +func _enter_tree() -> void: + # NOTE: Be sure to also instance and register these at runtime if you want + # the extensions at runtime. This editor plugin script won't run in games. + var ext = GLTFDocumentExtensionKHR_XMP.new() + GLTFDocument.register_gltf_document_extension(ext) diff --git a/addons/gltf_khr_xmp_copyright/plugin.cfg b/addons/gltf_khr_xmp_copyright/plugin.cfg new file mode 100644 index 0000000..83a9b94 --- /dev/null +++ b/addons/gltf_khr_xmp_copyright/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="glTF KHR_xmp_json_ld Copyright" +description="Godot implementation of the glTF KHR_xmp_json_ld extension for DCMI copyright metadata. Requires Godot 4.3 or newer." +author="Aaron Franke" +version="4.3" +script="gltf_khr_xmp_plugin.gd" diff --git a/addons/gltf_khr_xmp_copyright/xmp/dcmi_license_metadata.gd b/addons/gltf_khr_xmp_copyright/xmp/dcmi_license_metadata.gd new file mode 100644 index 0000000..934897a --- /dev/null +++ b/addons/gltf_khr_xmp_copyright/xmp/dcmi_license_metadata.gd @@ -0,0 +1,120 @@ +@tool +## Stores DCMI XMP license info and metadata as defined by Khronos and DCMI. +## List of elements: http://purl.org/dc/elements/1.1/ +## https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_xmp_json_ld +class_name DCMILicenseMetadata +extends XMPMetadataBase + +# TODO: On export: format + +const CONTEXT_URL: String = "http://purl.org/dc/elements/1.1/" + +@export_group("License") +## License name. Examples: "MIT License", "CC BY-SA 4.0", "All Rights Reserved" +@export_placeholder("Ex: MIT, BSD, CC0, etc") var rights: String = "" +## Primary authors. Example: John Smith +@export var creator: PackedStringArray = [] +## Secondary authors. Example: John Smith +@export var contributor: PackedStringArray = [] +## Company names. Example: Smith Co. +@export var publisher: PackedStringArray = [] + +@export_group("Content") +## Example: Rainbow Godette Model +@export_placeholder("Name of this GLTF file") var title: String = "" +## Example: Detailed Godette model with added sunshine, lollipops, and rainbows. +@export_placeholder("Description of this GLTF file") var description: String = "" +## Examples: Architecture, Automotive, Botany +@export var subject: PackedStringArray = [] +## Examples: Character, Building, Vehicle, Plant, Prop +@export var type: PackedStringArray = [] + +@export_group("Locale") +## Location. Example: Bay Area, CA, USA +@export_placeholder("Location/area/city/country") var coverage: String = "" +## Examples: 2014-02-09, 2014-02-09T22:10:30 +@export_placeholder("YYYY-MM-DD and/or hh:mm:ss") var date: String = "" +## ISO 639-2 code (ex: en), ISO 639-3 code (ex: eng), or culture ID (ex: en_US). +@export_placeholder("en, eng, en_US, etc") var language: String = "" + +@export_group("Reference") +## Example: ISBN-13:978-0802144423 +@export_placeholder("DOI, ISBN, URN, etc") var identifier: String = "" +## Example: https://your.website/file.glb +@export_placeholder("URL to this GLTF on the web") var source: String = "" +## Websites. Example: https://godotengine.org/ +@export var relation: PackedStringArray = [] + + +func to_dictionary() -> Dictionary: + var dcmi_dict: Dictionary = _dcmi_properties_to_dictionary() + if dcmi_dict.is_empty(): + # If there was no data, don't add @context, just return an empty dict. + return dcmi_dict + var ret: Dictionary = { + "@context": { + "dc": "http://purl.org/dc/elements/1.1/", + }, + "@id": "", + } + ret.merge(dcmi_dict) + return ret + + +func _dcmi_properties_to_dictionary() -> Dictionary: + var dcmi_dict: Dictionary = {} + if not contributor.is_empty(): + dcmi_dict["dc:contributor"] = { "@set": contributor } + if not coverage.is_empty(): + dcmi_dict["dc:coverage"] = coverage + if not creator.is_empty(): + dcmi_dict["dc:creator"] = { "@list": creator } + if not date.is_empty(): + dcmi_dict["dc:date"] = date + if not description.is_empty(): + dcmi_dict["dc:description"] = description + if not identifier.is_empty(): + dcmi_dict["dc:identifier"] = identifier + if not language.is_empty(): + dcmi_dict["dc:language"] = language + if not publisher.is_empty(): + dcmi_dict["dc:publisher"] = { "@set": publisher } + if not relation.is_empty(): + dcmi_dict["dc:relation"] = { "@set": relation } + if not rights.is_empty(): + dcmi_dict["dc:rights"] = rights + if not source.is_empty(): + dcmi_dict["dc:source"] = source + if not subject.is_empty(): + dcmi_dict["dc:subject"] = { "@set": subject } + if not title.is_empty(): + dcmi_dict["dc:title"] = title + if not type.is_empty(): + dcmi_dict["dc:type"] = { "@set": type } + return dcmi_dict + + +static func from_dictionary(xmp_packet: Dictionary) -> XMPMetadataBase: + var context_url: String = xmp_packet["@context"]["dc"] + if context_url != DCMILicenseMetadata.CONTEXT_URL: + push_warning("GLTF KHR XMP: DCMI metadata had a URL of '" + context_url + "' but expected '" + DCMILicenseMetadata.CONTEXT_URL + "'. Attempting to parse anyway.") + var ret := DCMILicenseMetadata.new() + ret.xmp_json_ld = xmp_packet + _dcmi_properties_from_dictionary(ret, xmp_packet) + return ret + + +const _SINGLE_VALUES: PackedStringArray = ["coverage", "date", "description", "identifier", "language", "rights", "source", "title"] +const _ARRAY_VALUES: PackedStringArray = ["contributor", "creator", "publisher", "relation", "subject", "type"] + +static func _dcmi_properties_from_dictionary(dcmi_data: DCMILicenseMetadata, xmp_packet: Dictionary) -> void: + for single_value_name in _SINGLE_VALUES: + var dcmi_prefixed: String = "dc:" + single_value_name + if xmp_packet.has(dcmi_prefixed): + var extracted = extract_single_value_from_json_ld(xmp_packet[dcmi_prefixed]) + dcmi_data.set(single_value_name, String(extracted)) + for array_value_name in _ARRAY_VALUES: + var dcmi_prefixed: String = "dc:" + array_value_name + if xmp_packet.has(dcmi_prefixed): + var extracted = extract_array_from_json_ld(xmp_packet[dcmi_prefixed]) + dcmi_data.set(array_value_name, PackedStringArray(extracted)) diff --git a/addons/gltf_khr_xmp_copyright/xmp/khr_xmp_doc_ext.gd b/addons/gltf_khr_xmp_copyright/xmp/khr_xmp_doc_ext.gd new file mode 100644 index 0000000..193479a --- /dev/null +++ b/addons/gltf_khr_xmp_copyright/xmp/khr_xmp_doc_ext.gd @@ -0,0 +1,176 @@ +@tool +class_name GLTFDocumentExtensionKHR_XMP +extends GLTFDocumentExtension + + +## Applies to the whole document when exporting. +@export var dcmi_license_metadata := DCMILicenseMetadata.new() + + +# Import process. +func _import_preflight(gltf_state: GLTFState, extensions_used: PackedStringArray) -> Error: + if not extensions_used.has("KHR_xmp_json_ld"): + return ERR_SKIP + var state_json: Dictionary = gltf_state.json + if not state_json.has("extensions"): + return ERR_INVALID_DATA + var doc_extensions: Dictionary = state_json["extensions"] + if not doc_extensions.has("KHR_xmp_json_ld"): + return ERR_INVALID_DATA + var khr_xmp_ext: Dictionary = doc_extensions["KHR_xmp_json_ld"] + if not khr_xmp_ext.has("packets"): + return ERR_INVALID_DATA + var khr_xmp_packets: Array = khr_xmp_ext["packets"] + var parsed_khr_xmp_data: Array = [] + for xmp_packet in khr_xmp_packets: + var xmp: XMPMetadataBase = _parse_xmp_packet(gltf_state, xmp_packet) + if xmp == null: + return ERR_INVALID_DATA + parsed_khr_xmp_data.append(xmp) + gltf_state.set_additional_data(&"KHR_xmp_json_ld", parsed_khr_xmp_data) + return OK + + +func _parse_xmp_packet(gltf_state: GLTFState, xmp_packet: Dictionary) -> XMPMetadataBase: + if not xmp_packet.has("@context"): + return null + var xmp_context: Dictionary = xmp_packet["@context"] + var xmp_metadata: XMPMetadataBase = null + for context_prefix in xmp_context: + if context_prefix == "dc": + xmp_metadata = DCMILicenseMetadata.from_dictionary(xmp_packet) + elif context_prefix == "rdf": + # RDF allows specifying language alternatives, it combines together + # with another context, we don't need to do anything special here. + pass + else: + # XMP is an open-ended standard, any data structure can be stored + # in it. Show a warning when we encounter something unrecognized, + # but the JSON data will still be available in an XMPMetadataBase. + push_warning("GLTF KHR XMP: Unrecognized context prefix '" + context_prefix + "'.") + if xmp_metadata == null: + # No specific class wants to handle this, so just return the base class. + xmp_metadata = XMPMetadataBase.from_dictionary(xmp_packet) + return xmp_metadata + + +func _get_supported_extensions() -> PackedStringArray: + return PackedStringArray(["KHR_xmp_json_ld"]) + + +func _import_post(gltf_state: GLTFState, root: Node) -> Error: + var asset_json: Dictionary = gltf_state.json["asset"] + if asset_json.has("extensions"): + var asset_extensions: Dictionary = asset_json["extensions"] + if asset_extensions.has("KHR_xmp_json_ld"): + var asset_xmp: Dictionary = asset_extensions["KHR_xmp_json_ld"] + if asset_xmp.has("packet"): + var packet: int = int(asset_xmp["packet"]) + var xmp_array: Array = gltf_state.get_additional_data("KHR_xmp_json_ld") + root.set_meta("KHR_xmp_json_ld", xmp_array[packet]) + return OK + + +# Export process. +func _export_preflight(gltf_state: GLTFState, root: Node) -> Error: + var dcmi_dict: Dictionary = dcmi_license_metadata.to_dictionary() + if dcmi_dict.is_empty(): + return OK + dcmi_dict["dc:format"] = "model/gltf" + # Add the DCMI XMP JSON dictionary to the document extensions. + var state_packets: Array = _get_or_create_state_packets_in_state(gltf_state) + state_packets.append(dcmi_dict) + return OK + + +func _export_preserialize(gltf_state: GLTFState) -> Error: + var state_packets = _get_state_packets_in_state_if_present(gltf_state) + if state_packets == null or state_packets.is_empty(): + return OK + var first_packet: Dictionary = state_packets[0] + if first_packet.has("dc:format"): + if gltf_state.filename.ends_with(".gltf"): + first_packet["dc:format"] = "model/gltf+json" + else: # If .glb, it's binary. If empty, it's a buffer, also binary. + first_packet["dc:format"] = "model/gltf-binary" + return OK + + +func _export_node(gltf_state: GLTFState, gltf_node: GLTFNode, node_json: Dictionary, node: Node) -> Error: + if not node.has_meta(&"dcmi_license_metadata"): + return OK + var dcmi_data: DCMILicenseMetadata = node.get_meta(&"dcmi_license_metadata") + var dcmi_dict = dcmi_data.to_dictionary() + if dcmi_dict.is_empty(): + return OK + # Insert the DCMI dictionary in the state packets and reference it on the node. + var state_packets: Array = _get_or_create_state_packets_in_state(gltf_state) + var node_extensions: Dictionary + # TODO: = node_json.get_or_set_default("extensions", {}) + if node_json.has("extensions"): + node_extensions = node_json["extensions"] + else: + node_extensions = {} + node_json["extensions"] = node_extensions + node_extensions["KHR_xmp_json_ld"] = { + "packet": state_packets.size() + } + state_packets.append(dcmi_dict) + return OK + + +func _export_post(gltf_state: GLTFState) -> Error: + var state_json: Dictionary = gltf_state.json + var state_packets = _get_state_packets_in_state_if_present(gltf_state) + if state_packets == null or state_packets.is_empty(): + return OK + if not state_packets[0].has("dc:format"): + return OK + # Reference the DCMI XMP JSON dictionary in the asset. + var asset: Dictionary = state_json["asset"] + var asset_extensions: Dictionary + # TODO: = asset.get_or_set_default("extensions", {}) + if asset.has("extensions"): + asset_extensions = asset["extensions"] + else: + asset_extensions = {} + asset["extensions"] = asset_extensions + asset_extensions["KHR_xmp_json_ld"] = { + "packet": 0 + } + return OK + + +func _get_state_packets_in_state_if_present(gltf_state: GLTFState): # -> Array? + var state_json: Dictionary = gltf_state.json + if not state_json.has("extensions"): + return null + var state_extensions: Dictionary = state_json["extensions"] + if not state_extensions.has("KHR_xmp_json_ld"): + return null + var khr_xmp_ext: Dictionary = state_extensions["KHR_xmp_json_ld"] + return khr_xmp_ext["packets"] + + +func _get_or_create_state_packets_in_state(gltf_state: GLTFState) -> Array: + var state_json = gltf_state.get_json() + var state_extensions: Dictionary + if state_json.has("extensions"): + state_extensions = state_json["extensions"] + else: + state_extensions = {} + state_json["extensions"] = state_extensions + var omi_physics_joint_doc_ext: Dictionary + if state_extensions.has("KHR_xmp_json_ld"): + omi_physics_joint_doc_ext = state_extensions["KHR_xmp_json_ld"] + else: + omi_physics_joint_doc_ext = {} + state_extensions["KHR_xmp_json_ld"] = omi_physics_joint_doc_ext + gltf_state.add_used_extension("KHR_xmp_json_ld", false) + var state_packets: Array + if omi_physics_joint_doc_ext.has("packets"): + state_packets = omi_physics_joint_doc_ext["packets"] + else: + state_packets = [] + omi_physics_joint_doc_ext["packets"] = state_packets + return state_packets diff --git a/addons/gltf_khr_xmp_copyright/xmp/xmp_metadata_base.gd b/addons/gltf_khr_xmp_copyright/xmp/xmp_metadata_base.gd new file mode 100644 index 0000000..d8981d3 --- /dev/null +++ b/addons/gltf_khr_xmp_copyright/xmp/xmp_metadata_base.gd @@ -0,0 +1,124 @@ +@tool +## Base class for XMP metadata. Has a property for the XMP JSON-LD, a method to +## construct a new instance, and helper methods for parsing JSON-LD, XMP, & RDF data. +## https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_xmp_json_ld +class_name XMPMetadataBase +extends Resource + + +# Return these as-is. JSON-LD distinguishes lists and sets, but Godot does not. +const _VALUE_KEYS: PackedStringArray = ["@json", "@list", "@set", "@value"] + +## Keeps a copy of the imported JSON-LD to allow reading or displaying it later. +var xmp_json_ld: Dictionary + + +## Constructs a new XMPMetadataBase class. With only the base class, no +## processing is performed, and the dictionary is copied to `xmp_json_ld`. +static func from_dictionary(xmp_dict: Dictionary) -> XMPMetadataBase: + var ret = XMPMetadataBase.new() + ret.xmp_json_ld = xmp_dict + return ret + + +## Extracts a single data value from a JSON-LD structure (not an array or dict). +static func extract_single_value_from_json_ld(from_data: Variant) -> Variant: + var value = extract_value_from_json_ld(from_data) + if value is Array: + return value[0] + if value is Dictionary: + return value.values()[0] + return value + + +## Extracts an array value from a JSON-LD structure (not a single value or dict). +static func extract_array_from_json_ld(from_data: Variant) -> Array: + var value = extract_value_from_json_ld(from_data) + if value is Array: + return value + if value is Dictionary: + return value.values() + return [value] + + +## Extracts a data value from a JSON-LD structure. +static func extract_value_from_json_ld(from_data: Variant) -> Variant: + if from_data is Dictionary: + if "@type" in from_data: + var value = from_data["@type"] + if value == "rdf:Alt": + return extract_value_from_rdf_xmp(from_data) + else: + push_warning("GLTF KHR XMP: Unrecognized value '" + value + "' for JSON-LD @type.") + elif "@id" in from_data and from_data.size() == 1: + return find_by_json_ld_id(from_data, from_data["@id"]) + else: + for value_key in _VALUE_KEYS: + if value_key in from_data: + return from_data[value_key] + push_warning("GLTF KHR XMP: Tried to parse JSON-LD but did not find a recognized key.") + return from_data + + +## Extracts one value with the most similar language +## from a larger RDF structure inside of an XMP structure. +static func extract_value_from_rdf_xmp(from_data: Variant, desired_lang: String = "") -> Variant: + var rdf_dict: Dictionary = extract_rdf_dict_from_rdf_xmp(from_data, desired_lang) + return rdf_dict.get("@value", null) + + +## Extracts one RDF dictionary with the most similar language +## from a larger RDF structure inside of an XMP structure. +static func extract_rdf_dict_from_rdf_xmp(from_data: Variant, desired_lang: String = "") -> Dictionary: + desired_lang = language_tag_to_culture_code(desired_lang) + # Look through the RDF languages and find the closest matching language. + # If only one language, or multiple match equally well, pick the first one. + var best_dict: Dictionary + var best_similarity: float = -1.0 + for key in from_data: + if not key.begins_with("rdf:"): + continue + var rdf_dict: Dictionary = from_data[key] + var rdf_lang: String = String(rdf_dict.get("@language", "")) + # RDF language tags are fully lowercase, but for comparing them using + # String similarity, it's helpful to have the country suffix uppercase. + rdf_lang = language_tag_to_culture_code(rdf_lang) + var similarity: float = rdf_lang.similarity(desired_lang) + if similarity > best_similarity: + best_dict = rdf_dict + best_similarity = similarity + return best_dict + + +## Recursively finds the JSON-LD element with the given ID. +static func find_by_json_ld_id(json_ld_container: Variant, json_ld_id: String): # -> Dictionary?: + if json_ld_container is Dictionary: + # If an element doesn't have "@id", it's not what we are looking for. + # If an element ONLY has "@id" it's a reference, also not what we're + # looking for. We want a matching "@id" with other elements too. + if json_ld_container.size() > 1 and \ + json_ld_container.keys()[0] == "@id" and \ + json_ld_container["@id"] == json_ld_id: + return json_ld_container + for key in json_ld_container: + var value: Variant = json_ld_container[key] + var found = find_by_json_ld_id(value, json_ld_id) + if found: + return found + elif json_ld_container is Array: + for item in json_ld_container: + var found = find_by_json_ld_id(item, json_ld_id) + if found: + return found + return null + + +## Converts a language tag (ex: RDF "en-us") to a country code (ex: "en_US"). +## This funcion helps us avoid mixing up languages and countries when using String +## similarity to compare county codes. For example, "ar" Arabic and "AR" Argentina. +static func language_tag_to_culture_code(language_tag: String) -> String: + language_tag = language_tag.replace("-", "_") + if not language_tag.contains("_"): + return language_tag.to_lower() + var split: PackedStringArray = language_tag.split("_") + return split[0].to_lower() + "_" + split[1].to_upper() diff --git a/project.godot b/project.godot index 5c474e1..33e260e 100644 --- a/project.godot +++ b/project.godot @@ -16,7 +16,7 @@ config/icon="res://icon.svg" [editor_plugins] -enabled=PackedStringArray("res://addons/omi_extensions/plugin.cfg") +enabled=PackedStringArray("res://addons/gltf_khr_xmp_copyright/plugin.cfg", "res://addons/omi_extensions/plugin.cfg") [rendering]