From ca6748abc102974b20bb601e21e3e0d740baa477 Mon Sep 17 00:00:00 2001 From: racerx2 Date: Sat, 1 Aug 2026 14:26:58 -0500 Subject: [PATCH 1/3] Add support for Blender 5.x Blender 5.0 rejects the manifest at install time because the extension validator requires the tagline to end with an alphanumeric character: key "tagline" invalid: alphanumeric suffix expected Also addresses two smaller issues surfaced while testing on 5.x: - Material.use_nodes is deprecated in 5.0 (slated for removal in 6.0), and new materials already carry a node tree, so only set it when the node tree is genuinely absent. Keeps 4.5 working unchanged. - The exporter derived its log path from os.path.dirname(bpy.data.filepath), which is empty for an unsaved .blend, so a verbose export wrote the log to Blender's working directory or silently failed to open it. Use get_log_folder(), matching what the importer already did. Verified against Blender 4.5 LTS, 5.0.1 and 5.2.0 LTS. --- __init__.py | 8 +++++++- blender_manifest.toml | 4 ++-- import_tools.py | 6 +++++- orbiter_tools.py | 7 +++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/__init__.py b/__init__.py index e193fca..c6964bb 100644 --- a/__init__.py +++ b/__init__.py @@ -46,9 +46,15 @@ # 2.1.8 - Fix material parsing bug. # 2.2.0 - Blender 4.2 LTS support. # 2.3.0 - Blender 4.5 LTS support. +# 2.3.1 - Blender 5.x support. +# - Fix manifest tagline rejected by the 5.0 extension validator. +# - Export: write the verbose log to the .blend folder (or temp +# folder when unsaved) instead of Blender's working directory. +# - Import: avoid the deprecated Material.use_nodes when the new +# material already has a node tree. # Update version in blender_manifest.toml -__version__ = "2.3.0" +__version__ = "2.3.1" import bpy import os diff --git a/blender_manifest.toml b/blender_manifest.toml index 1243a87..4132fa1 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,9 +1,9 @@ schema_version = "1.0.0" id = "orbiter_mesh_tools" -version = "2.3.0" +version = "2.3.1" name = "Orbiter Mesh Tools" -tagline = "Tools for building Orbiter mesh files." +tagline = "Tools for building Orbiter mesh files" maintainer = "Blake Christensen " type = "add-on" diff --git a/import_tools.py b/import_tools.py index b24dac2..6f71dd8 100644 --- a/import_tools.py +++ b/import_tools.py @@ -389,7 +389,11 @@ def build_mat_textures( *new_mat.orbiter_emit_color)) if src_tex: config.log_line(" texture node image: {}".format(src_tex_file)) - new_mat.use_nodes = True + # Blender 5.0 gives new materials a node tree already, and + # 'use_nodes' is deprecated (slated for removal in 6.0). + # Only touch it when the node tree is actually missing (4.5). + if not new_mat.node_tree: + new_mat.use_nodes = True bsdf = new_mat.node_tree.nodes["Principled BSDF"] texImage = new_mat.node_tree.nodes.new('ShaderNodeTexImage') texImage.image = bpy.data.images.load(src_tex_file) diff --git a/orbiter_tools.py b/orbiter_tools.py index 9cb8969..6a3d993 100644 --- a/orbiter_tools.py +++ b/orbiter_tools.py @@ -53,9 +53,12 @@ def __init__(self, self.name_pattern_location = name_pattern_location self.name_pattern_verts = name_pattern_verts self.name_pattern_id = name_pattern_id + # Use the same resolution as the importer: the .blend folder when + # the file is saved, otherwise the temp folder. Previously an + # unsaved .blend produced an empty path and the log landed in + # Blender's working directory (or failed to open). self.log_file_path = build_file_path( - os.path.dirname(bpy.data.filepath), - "BlenderTools", ".log") + get_log_folder(), "BlenderTools", ".log") if self.verbose: try: self.log_file = open(self.log_file_path, 'w') From c1b9070ed642367102ea2efbdbb8ce52d92f101c Mon Sep 17 00:00:00 2001 From: racerx2 Date: Sat, 1 Aug 2026 14:27:32 -0500 Subject: [PATCH 2/3] Fix texture path resolution on Linux and macOS Importing a mesh on Linux failed in two ways, both because Orbiter add-ons are authored on Windows. 1. Meshes not under a folder literally named 'Meshes' raised: msh_index = up.index('meshes') ValueError: 'meshes' is not in list which aborted the import entirely. Root detection now uses the last 'Meshes' component in the path, falls back to walking up for a folder containing Textures/Textures2, and as a last resort imports the geometry with a warning rather than raising. Resolving the path first also fixes a latent TypeError on relative paths, where the old code would call os.path.join() with no arguments. 2. Mesh files reference textures with Windows separators, for example 'MyVessel\hull.dds'. A backslash is not a separator on Linux/macOS, so os.path.join() produced a path that could never exist and every texture in such a mesh silently came up missing. References are now normalized before being joined, and each path component is matched case-insensitively, since Windows-authored add-ons are inconsistent about case and it is not significant there. Material names built from a texture reference now use only the file stem, so a texture sub-folder no longer ends up inside the name. No behaviour change on Windows: os.path.exists() matches on the first attempt, so the case-insensitive scan never runs, and joining the split components reproduces the original path exactly. --- __init__.py | 6 ++- blender_manifest.toml | 2 +- import_tools.py | 108 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/__init__.py b/__init__.py index c6964bb..9dfaa06 100644 --- a/__init__.py +++ b/__init__.py @@ -52,9 +52,13 @@ # folder when unsaved) instead of Blender's working directory. # - Import: avoid the deprecated Material.use_nodes when the new # material already has a node tree. +# 2.3.2 - Import: fix crash on meshes not under a 'Meshes' folder. +# - Import: resolve texture references that use Windows '\' +# separators, and match folder/file case on Linux and macOS. +# - Import: keep texture sub-folders out of material names. # Update version in blender_manifest.toml -__version__ = "2.3.1" +__version__ = "2.3.2" import bpy import os diff --git a/blender_manifest.toml b/blender_manifest.toml index 4132fa1..3491286 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "orbiter_mesh_tools" -version = "2.3.1" +version = "2.3.2" name = "Orbiter Mesh Tools" tagline = "Tools for building Orbiter mesh files" maintainer = "Blake Christensen " diff --git a/import_tools.py b/import_tools.py index 6f71dd8..5f332f2 100644 --- a/import_tools.py +++ b/import_tools.py @@ -282,27 +282,104 @@ def get_tris(group, swap_yz = True): return tris +def resolve_case_insensitive(base, rel_parts): + """ + Resolve 'rel_parts' under 'base', matching each path component + case-insensitively. Orbiter add-ons are authored on Windows, where + case does not matter, so 'Textures/MyVessel/Hull.dds' may really + be 'textures/myvessel/hull.dds' on a case-sensitive filesystem. + Returns the resolved path, or None if any component is missing. + """ + current = base + for part in rel_parts: + if not part or part == '.': + continue + + direct = os.path.join(current, part) + if os.path.exists(direct): + current = direct + continue + + try: + entries = os.listdir(current) + except OSError: + return None + + lowered = part.lower() + match = next((e for e in entries if e.lower() == lowered), None) + if match is None: + return None + current = os.path.join(current, match) + + return current + + +def split_orbiter_path(tex_name): + """ + Split an Orbiter texture reference into path components. Mesh files are + written on Windows and use '\\' as the separator, which is not a separator + on Linux/macOS, so normalize it before splitting. + """ + return [p for p in tex_name.replace('\\', '/').split('/') if p] + + def resolve_texture_path(config, orbiter_path, tex_name): """ Resolve the texture file path. Textures can be in Orbiter\\Textures, or sometimes in Orbiter\\Textures2. Textures2 is searched first, if not found then Textures will be searched. """ - tex2_file = os.path.join(orbiter_path, "Textures2", tex_name) - tex_file = os.path.join(orbiter_path, "Textures", tex_name) - if os.path.exists(tex2_file): - config.log_line("Texture: {}".format(tex2_file)) - return tex2_file + rel_parts = split_orbiter_path(tex_name) + attempted = [] - if os.path.exists(tex_file): - config.log_line("Texture: {}".format(tex_file)) - return tex_file + for tex_dir in ("Textures2", "Textures"): + attempted.append(os.path.join(orbiter_path, tex_dir, *rel_parts)) + found = resolve_case_insensitive(orbiter_path, [tex_dir] + rel_parts) + if found and os.path.isfile(found): + config.log_line("Texture: {}".format(found)) + return found print("WARN: Texture file not found: {}".format(tex_name)) - config.log_line("WARN: Missing texture:[{}], [{}]".format(tex2_file, tex_file)) + config.log_line("WARN: Missing texture:[{}], [{}]".format(*attempted)) return "" +def find_orbiter_root(config, file_path): + """ + Determine the Orbiter root folder for a mesh file, which is the folder + holding the 'Meshes' and 'Textures' directories. Normally the mesh sits + in /Meshes/..., but importing a loose .msh from somewhere else + should degrade gracefully rather than raise. + """ + p = Path(file_path).resolve() + parts = list(p.parts) + lowered = [pp.lower() for pp in parts] + + # Use the last 'Meshes' in the path, so a folder that happens to be + # called 'meshes' higher up does not win over the real one. + if 'meshes' in lowered: + idx = len(lowered) - 1 - lowered[::-1].index('meshes') + return os.path.join(*parts[0:idx]) + + # No 'Meshes' folder: walk up looking for something that has a + # Textures/Textures2 folder next to it. + for parent in p.parents: + try: + entries = {e.lower() for e in os.listdir(parent)} + except OSError: + continue + if 'textures' in entries or 'textures2' in entries: + config.log_line("No 'Meshes' folder; using texture root: {}".format(parent)) + return str(parent) + + # Last resort: import the geometry and let the textures come up missing. + warn = ("WARN: Mesh is not inside an Orbiter folder structure, textures " + "will likely not resolve: {}".format(p.parent)) + print(warn) + config.log_line(warn) + return str(p.parent) + + def build_mat_textures( config, orbiter_path, @@ -350,8 +427,13 @@ def build_mat_textures( src_tex_file = "" if src_tex: # Material has a texture if config.concat_mat: + # Use just the file stem: Orbiter texture references often + # include a sub-folder ('MyVessel\\hull.dds') which would + # otherwise end up inside the Blender material name. + tex_stem = os.path.splitext( + os.path.basename(src_tex.replace('\\', '/')))[0] mat_name = "{}_{}_{}".format( - src_mat.name, src_tex.split(".")[0], scene_name) + src_mat.name, tex_stem, scene_name) else: mat_name = src_mat.name @@ -439,11 +521,9 @@ def import_mesh(config, file_path): config.log_line("Target scene: {}".format(scene_name)) # find the Orbiter path - p = Path(file_path) - up = [pp.lower() for pp in p.parts] - msh_index = up.index('meshes') - orbiter_path = os.path.join(*p.parts[0:msh_index]) + orbiter_path = find_orbiter_root(config, file_path) print("Orbiter path: {}".format(orbiter_path)) + config.log_line("Orbiter path: {}".format(orbiter_path)) groups, materials, textures = read_mesh_file(config, file_path) mat_dict = build_mat_textures( From b04e776e5214e7e9a9cdc9a371bec526d075e7ea Mon Sep 17 00:00:00 2001 From: racerx2 Date: Sat, 1 Aug 2026 14:28:26 -0500 Subject: [PATCH 3/3] Do not abort the import when a texture file is missing A texture named in the mesh file but absent from disk left src_tex_file empty, and the material branch went on to call bpy.data.images.load("") regardless. That raised and aborted the whole import, so one missing or unreadable texture cost the entire mesh, geometry included. Materials whose texture cannot be found are now created untextured and the mesh still imports, with the missing reference reported. Loading is also wrapped so an unreadable or unsupported image file is reported and skipped rather than propagating. --- __init__.py | 4 +++- blender_manifest.toml | 2 +- import_tools.py | 31 +++++++++++++++++++++++++------ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/__init__.py b/__init__.py index 9dfaa06..2e6a3d6 100644 --- a/__init__.py +++ b/__init__.py @@ -56,9 +56,11 @@ # - Import: resolve texture references that use Windows '\' # separators, and match folder/file case on Linux and macOS. # - Import: keep texture sub-folders out of material names. +# 2.3.3 - Import: a missing texture file no longer aborts the entire +# import; the material is created untextured instead. # Update version in blender_manifest.toml -__version__ = "2.3.2" +__version__ = "2.3.3" import bpy import os diff --git a/blender_manifest.toml b/blender_manifest.toml index 3491286..fbb7897 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "orbiter_mesh_tools" -version = "2.3.2" +version = "2.3.3" name = "Orbiter Mesh Tools" tagline = "Tools for building Orbiter mesh files" maintainer = "Blake Christensen " diff --git a/import_tools.py b/import_tools.py index 5f332f2..d5b8975 100644 --- a/import_tools.py +++ b/import_tools.py @@ -469,18 +469,37 @@ def build_mat_textures( new_mat.orbiter_specular_power)) config.log_line(" emissive: {0:.4}, {0:.4}, {0:.4}, {0:.4}".format( *new_mat.orbiter_emit_color)) - if src_tex: + if src_tex and src_tex_file: config.log_line(" texture node image: {}".format(src_tex_file)) # Blender 5.0 gives new materials a node tree already, and # 'use_nodes' is deprecated (slated for removal in 6.0). # Only touch it when the node tree is actually missing (4.5). if not new_mat.node_tree: new_mat.use_nodes = True - bsdf = new_mat.node_tree.nodes["Principled BSDF"] - texImage = new_mat.node_tree.nodes.new('ShaderNodeTexImage') - texImage.image = bpy.data.images.load(src_tex_file) - new_mat.node_tree.links.new( - bsdf.inputs['Base Color'], texImage.outputs['Color']) + + try: + image = bpy.data.images.load(src_tex_file) + except RuntimeError as error: + image = None + warn = "WARN: Could not load texture [{}]: {}".format( + src_tex_file, error) + print(warn) + config.log_line(warn) + + if image is not None: + bsdf = new_mat.node_tree.nodes["Principled BSDF"] + texImage = new_mat.node_tree.nodes.new('ShaderNodeTexImage') + texImage.image = image + new_mat.node_tree.links.new( + bsdf.inputs['Base Color'], texImage.outputs['Color']) + elif src_tex: + # Texture named in the mesh but not found on disk. Import the + # geometry with an untextured material rather than failing the + # whole mesh. + warn = "WARN: Material '{}' left untextured, missing: {}".format( + new_mat.name, src_tex) + print(warn) + config.log_line(warn) dict_mat[mt] = new_mat.name # dict: (tuple) -> mat name. config.log_line("Finished building {} materials.".format(len(dict_mat)))