From 943044e6963318bc7926ba2447bc8c805884ba2d Mon Sep 17 00:00:00 2001 From: utagestudio Date: Sun, 25 Oct 2026 18:28:33 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=82=80?= =?UTF-8?q?=E5=8D=98=E4=BD=8D=20Prefabs=20=E3=81=A7=E3=80=81prefab=20?= =?UTF-8?q?=E3=81=8C=E4=BD=BF=E3=81=A3=E3=81=A6=E3=81=84=E3=81=AA=E3=81=84?= =?UTF-8?q?=20FBX=20=E3=81=AE=E9=83=A8=E5=93=81=E3=82=92=E9=9A=A0=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Japanese Train Station PBR Pack では駅の部品 54 個が 1 つの FBX にまとまっていて、 prefab は MeshFilter でそのうち 1〜3 個だけを指している。Scenes 単位には表に無いメッシュを 隠す処理(#58)があるが、Prefabs 単位には無く、どの prefab にも 54 個すべてが表示されていた。 Prefabs 単位でも、prefab がモデルを丸ごと置いていない(モデルの PrefabInstance の配置が無い) 場合は、表に無いメッシュを隠し、件数を Scenes 単位と同じ文言で警告に出す。 - core/hierarchy.py: Renderer から作った配置かの判定を built_from_renderers にまとめ、 parts_to_hide と共用する - core/prefab.py: models_placed_whole と unused_parts を追加。tables_from_hierarchy は 求めた配置を受け取れるようにし、prepare_package で配置を 1 回だけ求める - core/scene_import.py: 文言を unused_warning に切り出し、prefab 用の prefab_warnings を追加 - 合成パッケージの Partial.prefab(Spike だけを使う)を Prefabs 単位の期待値に加えた バージョンは 1.12.0 の開発を始めるので 1.12.0-dev.1 にする(#152 の機能追加と一緒に出す)。 Closes #151 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 2 +- README.md | 3 +- README_ja.md | 2 +- .../expectations_synthetic_scene_prefabs.json | 33 +++++++++++++- tests/test_prefab.py | 33 +++++++++++++- tests/test_scene_import.py | 11 ++++- unitypackage_loader/blender/importer.py | 44 ++++++++++++++++--- unitypackage_loader/blender_manifest.toml | 2 +- unitypackage_loader/core/hierarchy.py | 8 ++++ unitypackage_loader/core/prefab.py | 24 +++++++++- unitypackage_loader/core/scene_import.py | 29 +++++++++--- 11 files changed, 167 insertions(+), 24 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e71bdcf..3bcbe0c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -57,7 +57,7 @@ Blender から `.unitypackage` を直接読み込み、メッシュ(アーマ - 座標変換(`core/transform.py`): Unity のモデル空間 (x, y, z) は Blender では (-x, -z, y)。Unity でモデルのルートに掛かる行列 M は、Blender では C·M·C⁻¹ を原点に読み込んだオブジェクトの行列に左から掛ける。Unity 6 で合成 FBX を 3 通り(FBX を中に置いた prefab、空の親の下への直置き、非一様スケールで置いて中の子を上書きした展開 prefab)に置いたシーンを作り、Renderer ごとの頂点のワールド座標(スキンしたメッシュはボーン行列 × bindpose で計算)と、Blender の FBX インポーターで読んだ同じ FBX から予測した座標が、9 か所すべてで一致することを確かめた。Blender 由来の FBX では、Unity のルート直下のノードは X -90 度・スケール 100 を持つが、Blender のオブジェクトはその値を持たない。そのためノードの値を直接オブジェクトに当てず、行列で計算してから差分として当てる。 - 読み込み: 配置のルートと、その祖先の GameObject を Empty にし(行列は Unity のローカル行列を C·M·C⁻¹ で変換)、モデルの最上位のオブジェクトをルートの Empty の子にする。.meta の `globalScale` は親の逆行列として掛ける。同じモデル・同じマテリアルの割り当ての配置は、2 つ目からメッシュ・アーマチュアのデータを共有した複製(`Object.copy()`)にし、アーマチュアモディファイアとコンストレイントの参照を複製側に付け替える。割り当てが違えば読み直す(組み立て済みの .mat は、下記「マテリアルの共有」のとおり共有)。展開 prefab の中のノードが上書きで動いていれば、そのノードから逆算したルートの行列でオブジェクトを置き直す(親から順に。ワールド行列は評価を待たずに親をたどって計算する。アーマチュアで変形するものは動かさず件数を警告に出す)。非アクティブな GameObject と無効な Renderer は `hide_render` をその場で立て、`hide_set` は読み込みがすべて終わってからまとめて当てる(下記の理由で、読み込み中はビューレイヤーに無いため)。 - 読み込み中のビューレイヤー: インポーターのオペレーターは呼ぶたびにビューレイヤーの中身を評価し直すので、読み込み済みのオブジェクトが増えるほど 1 回が重くなる(3000 オブジェクトで 1 回 180 ms。Japanese Street の Day_Showcase の読み込み 16 秒の大半)。パッケージのコレクションは読み込みが終わるまで `exclude` し、モデルは空の作業用コレクション「<パッケージ名> (importing)」に読み込んでから配置先へ移す。終わったら(失敗しても)除外を戻して作業用コレクションを消す。これで Day_Showcase は 1.7 秒になった(Issue #75)。 -- 使われていない部品(Issue #58): 展開した prefab・シーンの Renderer から作った配置では、Unity にあるのは表の Renderer だけなので、表に無いメッシュのオブジェクト(prefab が使っていない LOD、FBX にだけある別のノードなど)を非表示にする(削除はしない。件数を警告に出す)。FBX を直接置いた配置は対象にしない。Renderer の名前は GameObject 名ではなくメッシュ参照(MeshFilter / SkinnedMeshRenderer の `m_Mesh` の fileID)から決める。展開後に GameObject の名前を変えていると、名前の照合では使っている部品まで隠してしまうため。モデルの .meta の表(`fileIDToRecycleName`、古い番号を引き継いだ `internalIDToNameTable`)で名前を引き、表の無い新しい形式では fileID = xxHash64("Type:Mesh-><名前>0")(`core/unity_ids.py`。Unity 6 の prefab と Japanese Street の 100 か所で一致)を Blender のオブジェクト名で計算して照合する。 +- 使われていない部品(Issue #58): 展開した prefab・シーンの Renderer から作った配置では、Unity にあるのは表の Renderer だけなので、表に無いメッシュのオブジェクト(prefab が使っていない LOD、FBX にだけある別のノードなど)を非表示にする(削除はしない。件数を警告に出す)。FBX を直接置いた配置は対象にしない。読み込む単位 Prefabs でも、prefab がモデルを丸ごと置いていなければ(モデルの PrefabInstance の配置が無ければ。`prefab.models_placed_whole`)、表に無いメッシュを同じように隠す(Issue #151。以前は Prefabs 単位にこの処理が無く、部品をまとめた FBX のメッシュを 1 つだけ指す prefab で、FBX の全部品が表示されていた)。Renderer の名前は GameObject 名ではなくメッシュ参照(MeshFilter / SkinnedMeshRenderer の `m_Mesh` の fileID)から決める。展開後に GameObject の名前を変えていると、名前の照合では使っている部品まで隠してしまうため。モデルの .meta の表(`fileIDToRecycleName`、古い番号を引き継いだ `internalIDToNameTable`)で名前を引き、表の無い新しい形式では fileID = xxHash64("Type:Mesh-><名前>0")(`core/unity_ids.py`。Unity 6 の prefab と Japanese Street の 100 か所で一致)を Blender のオブジェクト名で計算して照合する。 - LOD(Issue #104): Unity の LOD は prefab・シーンの `LODGroup`(クラス ID 205)が持ち、`m_LODs` の段ごとに Renderer の fileID が並ぶ。段ごとの GameObject はどれも Renderer を持つので、そのままでは全段が重なって表示される。`parse_asset` で段を読み、`RendererInfo.lod_level`(0 = もっとも細かい LOD0)に付けて、読み込み側が LOD1 以降を非表示にする(削除はしない。段は `unity_lod` カスタムプロパティに残し、件数を警告に出す。ダイアログの「LODs」トグルで切れる)。LODGroup はシーンから prefab インスタンスの中の Renderer を指すことがあるので、段の解決は PrefabInstance の差し込みが終わった後に、stripped ドキュメントの別名を通して行う。1 つの LODGroup の中で同じ Renderer が複数の段にあればもっとも細かい段を採り、内側の prefab で付いた段は外側で上書きしない(内側で遠景用だったものは隠れたままにする)。`m_Enabled: 0` の LODGroup は Unity でも全段が描かれるので段を付けない。適用するのは Scenes と Prefabs 単位(Models 単位の表は prefab をまたいで統合したものなので対象にしない)。外側から LODGroup 自体を無効化・削除する上書きは反映しない。`_LOD1` のような命名規則からの推測はしない(LODGroup の無いモデルは今までどおり全部表示する)。 - Renderer をまとめるモデルのルート(Issue #60): 以前は Transform を直接持つアセットでの最上位の祖先に固定していたため、シーンの共通の親(部屋など)の下に同じ小物を複製して並べると、名前が同じ Renderer が 1 つだけ残り、しかも親の原点に置かれていた。モデルの .meta の表(`fileIDToRecycleName` / `internalIDToNameTable`)にある、ルート以外の GameObject 名を FBX のノード名として、次のように決める(`core/hierarchy.py` の `placements`)。 - 1 メッシュの FBX(表の GameObject はルートだけで、メッシュも 1 つ): Renderer の GameObject 自身。この形の FBX では、Unity はノードを `//RootNode` に畳んでその変換をモデルのルートの Transform に載せ、メッシュの頂点はノード空間のままにするので、メッシュを直接指す Renderer にはノードの変換が掛からない。そこで、下のノード自身をルートにした配置と同じく `node_transforms`(名前は表のメッシュ名)でそのノードを単位行列にする。これをしないと、ノードが原点から離れた FBX(街並みアセットの歩道など)がノードの移動ぶんずれる(Issue #98。Unity 6000.6.0f1 で確認)。 diff --git a/README.md b/README.md index 4452e1c..1f76325 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ ticked): - **Prefabs**: imports each prefab with its own material assignments, in its own Collection inside the package Collection. Color variants that share a model can be imported together. When two or more prefabs are selected, **Arrange** places them `Side by Side` (next to each other without overlapping, wrapping into a grid when there are many) or - `Stack at Origin`. Prefabs with an LODGroup hide their distant LOD levels, as scenes do. + `Stack at Origin`. Prefabs with an LODGroup hide their distant LOD levels, and parts of a model file that the prefab + does not use are hidden, as scenes do. - **Models**: imports the model files (FBX etc.) as they are. Materials are resolved as described in [Matching meshes to materials](#matching-meshes-to-materials). diff --git a/README_ja.md b/README_ja.md index 58fc381..fd4a200 100644 --- a/README_ja.md +++ b/README_ja.md @@ -112,7 +112,7 @@ Scenes は、シーンをまとめて読み込むと建物やライトが重な 中のオブジェクトへの変更(マテリアルや、開けたドアなど部品の位置)は、モデルの `.meta` が Unity 2018.2 以前の形式(名前の表がある)なら当て、 そうでなければ件数を警告に出します。 - **Prefabs**: prefab ごとに、その prefab のマテリアルの割り当てのまま読み込みます。パッケージの Collection の中に prefab ごとの Collection ができます。 - LODGroup のある prefab は、Scenes と同じく遠景用の LOD を非表示にします。 + LODGroup のある prefab は、Scenes と同じく遠景用の LOD を非表示にします。prefab が使っていないモデルファイルの部品も、Scenes と同じく非表示にします。 同じモデルを使う色違いも一緒に読み込めます。2 つ以上選んだときは **Arrange** で、`Side by Side`(重ならないように並べる。数が多ければ格子状に折り返す)と `Stack at Origin`(原点に重ねる)を選べます。 - **Models**: モデルファイル(FBX など)をそのまま読み込みます。マテリアルは [メッシュとマテリアルの対応付け](#メッシュとマテリアルの対応付け) のとおりに決まります。 diff --git a/tests/expectations_synthetic_scene_prefabs.json b/tests/expectations_synthetic_scene_prefabs.json index 83c14a6..107b629 100644 --- a/tests/expectations_synthetic_scene_prefabs.json +++ b/tests/expectations_synthetic_scene_prefabs.json @@ -33,11 +33,42 @@ "mat_files": [ "" ] + }, + "Partial": { + "prefab": "Assets/Synthetic/Prefabs/Partial.prefab", + "objects": 5, + "parts": { + "Spike.001": { + "hidden": false + }, + "Child.001": { + "hidden": true + }, + "Skinned.001": { + "hidden": true + } + } + }, + "Unpacked": { + "prefab": "Assets/Synthetic/Prefabs/Unpacked.prefab", + "objects": 5, + "parts": { + "Spike.002": { + "hidden": false + }, + "Child.002": { + "hidden": false + }, + "Skinned.002": { + "hidden": false + } + } } }, "warnings_max": 4, "warnings_contain": [ - "Booth.fbx: 1 material override(s) on an object inside the model are not read yet" + "Booth.fbx: 1 material override(s) on an object inside the model are not read yet", + "prefab Assets/Synthetic/Prefabs/Partial.prefab: 2 object(s) from model files are not used" ], "warnings_exclude": [ "Kiosk.fbx: " diff --git a/tests/test_prefab.py b/tests/test_prefab.py index 4e5d586..da95a52 100644 --- a/tests/test_prefab.py +++ b/tests/test_prefab.py @@ -5,8 +5,15 @@ from unitypackage_loader.core.mapping import resolve_materials from unitypackage_loader.core.material import parse_material from unitypackage_loader.core.meta import ModelImporterInfo -from unitypackage_loader.core.hierarchy import SOLE_RENDERER, Expander, parse_asset -from unitypackage_loader.core.prefab import RendererMaterials, merge_prefab_tables, resolve_sole_renderer, tables_from_hierarchy +from unitypackage_loader.core.hierarchy import SOLE_RENDERER, Expander, parse_asset, placements +from unitypackage_loader.core.prefab import ( + RendererMaterials, + merge_prefab_tables, + models_placed_whole, + resolve_sole_renderer, + tables_from_hierarchy, + unused_parts, +) MAT_A = "1" * 32 MAT_B = "2" * 32 @@ -404,6 +411,28 @@ def test_lookup_order(self): self.assertIsNone(find_renderer(table, "Window")) +class UnusedPartsTests(unittest.TestCase): + """読み込む単位 Prefabs で、prefab が使っていない FBX の部品を見分ける(#151)。""" + + def test_meshes_missing_from_the_table(self): + from unitypackage_loader.core.unity_ids import mesh_file_id + + table = { + "Body": RendererMaterials("Body", [MAT_A]), + "Renamed": RendererMaterials("Renamed", [MAT_B], mesh_file_id=mesh_file_id("Door")), + } + objects = [("Body", True), ("Body.002", True), ("Door.001", True), ("Window", True), ("Armature", False)] + self.assertEqual(unused_parts(table, objects), {"Window"}) # メッシュでないものは対象にしない + + def test_models_placed_whole(self): + # Renderer からだけ使う prefab では、FBX の部品は表の分しか Unity に無い + h = expand(BASE, {BASE: PREFAB}) + self.assertEqual(models_placed_whole(h, placements(h, MODELS)), set()) + # モデルの PrefabInstance で FBX を丸ごと置いた prefab では、すべての部品がある + on_model = expand(VARIANT, {VARIANT: variant_yaml(5000, MODEL_A, [(123456789, "m_LocalPosition.x", "1", None)])}) + self.assertEqual(models_placed_whole(on_model, placements(on_model, MODELS)), {MODEL_A}) + + class RenamedGameObjectMappingTests(unittest.TestCase): def test_slot_assignments_fall_back_to_mesh_file_id(self): from unitypackage_loader.core.mapping import slot_assignments diff --git a/tests/test_scene_import.py b/tests/test_scene_import.py index c0137e8..3037341 100644 --- a/tests/test_scene_import.py +++ b/tests/test_scene_import.py @@ -2,7 +2,7 @@ from tests import _paths # noqa: F401 from unitypackage_loader.core.hierarchy import CLASS_MESH_RENDERER, Hierarchy, ModelPlacement, Node, PlacedRenderer, SceneContents -from unitypackage_loader.core.scene_import import parts_to_hide, scene_warnings +from unitypackage_loader.core.scene_import import parts_to_hide, prefab_warnings, scene_warnings, unused_warning from unitypackage_loader.core.transform import IDENTITY from unitypackage_loader.core.unity_ids import mesh_file_id @@ -106,5 +106,14 @@ def test_unreadable_scene_reports_only_lights(self): self.assertEqual(scene_warnings("S", SceneContents([])), []) +class PrefabWarningsTests(unittest.TestCase): + def test_same_wording_as_scenes(self): + self.assertEqual(prefab_warnings(), []) + messages = prefab_warnings(hidden_unused=53, hidden_lods=2) + self.assertEqual(messages[0], unused_warning(53)) + self.assertTrue(messages[0].startswith("53 object(s) from model files are not used")) + self.assertTrue(messages[1].startswith("2 object(s) are lower LOD levels")) + + if __name__ == "__main__": unittest.main() diff --git a/unitypackage_loader/blender/importer.py b/unitypackage_loader/blender/importer.py index f0f9205..3430c0a 100644 --- a/unitypackage_loader/blender/importer.py +++ b/unitypackage_loader/blender/importer.py @@ -23,6 +23,7 @@ RawAsset, effective_active, parse_asset, + placements, summarize, ) from ..core.hierarchy import components as scene_components @@ -35,11 +36,19 @@ from ..core.material import NormalizedMaterial, UnityMaterial, parse_material from ..core.meta import ModelImporterInfo, TextureImporterInfo, strip_numeric_suffix from ..core.package import AssetEntry, PackageError, UnityPackage -from ..core.prefab import RendererMaterials, find_renderer, merge_prefab_tables, resolve_sole_renderer, tables_from_hierarchy +from ..core.prefab import ( + RendererMaterials, + find_renderer, + merge_prefab_tables, + models_placed_whole, + resolve_sole_renderer, + tables_from_hierarchy, + unused_parts, +) from ..core.profiles import ShaderTable, normalize_material from ..core.profiles.base import default_table from ..core.report import ImportReport, MaterialReport -from ..core.scene_import import lod_warning, parts_to_hide, scene_warnings +from ..core.scene_import import parts_to_hide, prefab_warnings, scene_warnings from ..core.shader_source import SHADER_SOURCE_EXTS, declared_properties from ..core.units import ( UNIT_PREFABS, @@ -159,6 +168,8 @@ class PreparedPackage: prefab_tables: dict[str, dict[str, dict[str, RendererMaterials]]] = field(default_factory=dict) warnings: list[str] = field(default_factory=list) prefabs: list[PrefabSummary] = field(default_factory=list) # 読み込む単位 Prefabs の候補(pathname 順) + # prefab の pathname → FBX を丸ごと置いているモデルの GUID(表に無い部品も隠さない。#151) + prefab_whole_models: dict[str, set[str]] = field(default_factory=dict) scene_entries: list[AssetEntry] = field(default_factory=list) # パッケージ内のシーン(pathname 順。展開は後回し) scene_hierarchies: dict[str, Hierarchy] = field(default_factory=dict) # シーンの GUID → 展開した階層(ensure_scenes で作る) pipeline: str = "BUILTIN" # レンダーパイプライン(ライトの強さの換算に使う。決め方は core/lights.decide_pipeline) @@ -350,12 +361,15 @@ def prepare_package( model_names, recycle = _model_tables(models) expander = Expander(_prefab_reader(pkg, warnings.append), model_names, recycle) prefab_tables: dict[str, dict[str, dict[str, RendererMaterials]]] = {} + prefab_whole_models: dict[str, set[str]] = {} for entry in pkg.prefabs(): try: hierarchy = expander.expand_asset(entry.guid) if hierarchy is None: # 読めなかった(警告は読むときに出している) continue - tables = tables_from_hierarchy(hierarchy, model_names, recycle) + placed = placements(hierarchy, model_names, recycle) + tables = tables_from_hierarchy(hierarchy, model_names, recycle, placed) + whole = models_placed_whole(hierarchy, placed) except Exception as exc: # noqa: BLE001 - その prefab の割り当てだけを外して続ける(#69) warnings.append(f"could not resolve prefab {entry.pathname}: {exc}") _log_exception(f"could not resolve prefab {entry.pathname}") @@ -367,6 +381,7 @@ def prepare_package( ) if tables: prefab_tables[entry.pathname] = tables + prefab_whole_models[entry.pathname] = whole unsupported = {m.guid: m.skip_reason for m in models if not m.supported} # シェーダー表で分かったマテリアルだけを手掛かりにする。指紋で URP / HDRP と読んだ表に無いシェーダーは使わない(#116)。 # シーン・prefab のライト・カメラに URP / HDRP の追加データがあれば、そちらを優先する(#120) @@ -379,7 +394,7 @@ def prepare_package( prefabs = summarize_prefabs(((e.guid, e.pathname) for e in pkg.prefabs()), prefab_tables, model_guids, unsupported) return PreparedPackage( path, pkg, unity_mats, normalized, models, referenced, missing, prefab_tables, warnings, - prefabs=prefabs, scene_entries=pkg.scenes(), _expander=expander, + prefabs=prefabs, prefab_whole_models=prefab_whole_models, scene_entries=pkg.scenes(), _expander=expander, pipeline=pipeline.pipeline, pipeline_source=pipeline.source, ) @@ -865,7 +880,8 @@ def run(self) -> None: skipped = self.model_by_guid.get(guid) if skipped is not None and not skipped.supported: self.report.warn(f"prefab {prefab.pathname}: skipped {skipped.entry.pathname}: {skipped.skip_reason}") - hidden_lods = 0 + hidden_lods = hidden_unused = 0 + whole = self.prepared.prefab_whole_models.get(group.prefab.pathname, set()) if group.prefab else set() for summary, prefab_table in group.models: self.step(0.55 + 0.4 * done / max(total, 1), f"Importing {summary.entry.name}") done += 1 @@ -879,8 +895,11 @@ def run(self) -> None: # Models 単位の表は prefab をまたいで統合したものなので、LOD を隠すのは prefab 単位のときだけ if group.prefab is not None: hidden_lods += self.hide_prefab_lods(objects, prefab_table) - if hidden_lods and group.prefab is not None: - self.report.warn(f"prefab {group.prefab.pathname}: {lod_warning(hidden_lods)}") + if summary.guid not in whole: + hidden_unused += self.hide_unused_parts(objects, prefab_table) + if group.prefab is not None: + for message in prefab_warnings(hidden_unused=hidden_unused, hidden_lods=hidden_lods): + self.report.warn(f"prefab {group.prefab.pathname}: {message}") for scene_summary in scenes: try: self.import_scene(scene_summary, 0.55 + 0.4 * done / max(total, 1), 0.4 / max(total, 1)) @@ -1103,6 +1122,17 @@ def hide_prefab_lods(self, objects: list[bpy.types.Object], prefab_table: dict[s hidden += 1 return hidden + def hide_unused_parts(self, objects: list[bpy.types.Object], prefab_table: dict[str, RendererMaterials]) -> int: + """prefab が使っていない FBX の部品を隠し、隠した数を返す(Scenes 単位の #58 と同じ扱い。#151)。 + + LOD として隠したものは数えない(表にある Renderer なので、ここでは対象にならない)。 + """ + unused = unused_parts(prefab_table, [(o.name, o.type == "MESH") for o in objects]) + for obj in objects: + if obj.name in unused: + defer_hide(obj, self.hidden_objects) + return len(unused) + def light_scale(self, scene_summary: SceneSummary, target: bpy.types.Collection) -> float: """HDRP のシーンのグローバルな Volume の露出から、そのシーンの HDRP のライトに掛ける倍率を決める(#130)。 diff --git a/unitypackage_loader/blender_manifest.toml b/unitypackage_loader/blender_manifest.toml index 92d32ae..45c689a 100644 --- a/unitypackage_loader/blender_manifest.toml +++ b/unitypackage_loader/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "unitypackage_loader" -version = "1.11.5" +version = "1.12.0-dev.1" name = "Unitypackage Importer" tagline = "Import meshes, materials and textures from .unitypackage files" maintainer = "utagestudio" diff --git a/unitypackage_loader/core/hierarchy.py b/unitypackage_loader/core/hierarchy.py index aea1a10..0342690 100644 --- a/unitypackage_loader/core/hierarchy.py +++ b/unitypackage_loader/core/hierarchy.py @@ -931,6 +931,14 @@ def signature(self) -> tuple: return (self.model_guid, tuple(sorted((name, tuple(r.materials)) for name, r in self.renderers.items()))) +def built_from_renderers(h: Hierarchy, placement: ModelPlacement) -> bool: + """展開した prefab・シーンの Renderer から作った配置か(モデルの PrefabInstance で FBX を丸ごと置いた配置ではないか)。 + + このとき Unity にあるのは ``renderers`` の Renderer だけで、FBX のほかの部品は使われていない(#58)。 + """ + return placement.root in h.nodes and h.nodes[placement.root].model_guid is None + + def _close(a: Mat4, b: Mat4) -> bool: return all(abs(x - y) <= _CLOSE * max(1.0, abs(x), abs(y)) for ra, rb in zip(a, b) for x, y in zip(ra, rb)) diff --git a/unitypackage_loader/core/prefab.py b/unitypackage_loader/core/prefab.py index 5f6d1c3..9d9a975 100644 --- a/unitypackage_loader/core/prefab.py +++ b/unitypackage_loader/core/prefab.py @@ -17,9 +17,10 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field -from .hierarchy import CLASS_MESH_RENDERER, SOLE_RENDERER, Hierarchy, ModelNames, placements +from .hierarchy import CLASS_MESH_RENDERER, SOLE_RENDERER, Hierarchy, ModelNames, ModelPlacement, built_from_renderers, placements from .meta import strip_numeric_suffix from .unity_ids import mesh_file_id @@ -51,6 +52,7 @@ def tables_from_hierarchy( h: Hierarchy, model_names: ModelNames, name_tables: dict[str, dict[int, str]] | None = None, + placed: list[ModelPlacement] | None = None, ) -> dict[str, dict[str, RendererMaterials]]: """展開した prefab の階層から、モデルの GUID → GameObject 名 → Renderer の表を作る。 @@ -58,9 +60,10 @@ def tables_from_hierarchy( 同じモデルに同名の GameObject があれば先のもの(配置の順)を採用する。モデルをそのまま置いた PrefabInstance は、 中への上書きを名前で引けたものだけが表に入る。1 つも引けなければ表に入れない(FBX を置いただけの prefab を、 モデルを使う prefab として数えない。読み込む単位 Prefabs の候補は以前と同じ)。 + ``placed`` に ``placements`` の結果を渡すと、配置を求め直さない。 """ result: dict[str, dict[str, RendererMaterials]] = {} - for placement in placements(h, model_names, name_tables): + for placement in placed if placed is not None else placements(h, model_names, name_tables): if not placement.renderers: continue table = result.setdefault(placement.model_guid, {}) @@ -75,6 +78,23 @@ def tables_from_hierarchy( return result +def models_placed_whole(h: Hierarchy, placed: list[ModelPlacement]) -> set[str]: + """モデルの PrefabInstance で FBX を丸ごと置いている配置のある、モデルの GUID。 + + そのモデルは FBX のすべての部品が Unity にあるので、表に無い部品も隠さない(#151)。 + """ + return {p.model_guid for p in placed if not built_from_renderers(h, p)} + + +def unused_parts(table: dict[str, RendererMaterials], objects: Iterable[tuple[str, bool]]) -> set[str]: + """表の Renderer が使っていないメッシュのオブジェクト名(読み込む単位 Prefabs で隠すもの。#151)。 + + ``objects`` は (Blender のオブジェクト名, メッシュか)。照合は ``find_renderer`` と同じ(名前 → 連番を外した形 → + メッシュ参照の fileID)。FBX を丸ごと置いたモデル(``models_placed_whole``)には使わない。 + """ + return {name for name, is_mesh in objects if is_mesh and find_renderer(table, name) is None} + + def merge_prefab_tables(tables: list[dict[str, RendererMaterials]]) -> dict[str, RendererMaterials]: """表を先勝ちで統合する。同じ名前に加えて、先の表が割り当て済みのメッシュ(メッシュ参照の fileID)の行も採らない。 diff --git a/unitypackage_loader/core/scene_import.py b/unitypackage_loader/core/scene_import.py index c4aee1c..cc3d48e 100644 --- a/unitypackage_loader/core/scene_import.py +++ b/unitypackage_loader/core/scene_import.py @@ -5,11 +5,11 @@ from collections.abc import Iterable from typing import NamedTuple -from .hierarchy import Hierarchy, ModelPlacement, SceneContents +from .hierarchy import Hierarchy, ModelPlacement, SceneContents, built_from_renderers from .meta import strip_numeric_suffix from .unity_ids import mesh_file_id -__all__ = ["HiddenParts", "lod_warning", "parts_to_hide", "scene_warnings"] +__all__ = ["HiddenParts", "lod_warning", "parts_to_hide", "prefab_warnings", "scene_warnings", "unused_warning"] class HiddenParts(NamedTuple): @@ -35,7 +35,7 @@ def parts_to_hide( (表で名前を引けない新しい形式のモデル) - ``hide_lods`` なら、LODGroup の遠景用の段(LOD1 以降)の Renderer を隠す(#104) """ - from_renderers = placement.root in h.nodes and h.nodes[placement.root].model_guid is None + from_renderers = built_from_renderers(h, placement) disabled = {name for name, r in placement.renderers.items() if not r.visible} used = set(placement.renderers) | {strip_numeric_suffix(n) for n in placement.renderers} mesh_ids = {r.mesh_file_id for r in placement.renderers.values() if r.mesh_file_id} @@ -99,10 +99,7 @@ def scene_warnings( "(such as Unity's built-in primitives) and were skipped" ) if hidden_unused: - messages.append( - f"{hidden_unused} object(s) from model files are not used by the Unity prefabs or scene " - "and were hidden (they stay in the file; unhide them if a renamed part was hidden by mistake)" - ) + messages.append(unused_warning(hidden_unused)) if hidden_lods: messages.append(lod_warning(hidden_lods)) if skipped_nodes: @@ -117,6 +114,24 @@ def scene_warnings( return [prefix + m for m in messages] +def prefab_warnings(*, hidden_unused: int = 0, hidden_lods: int = 0) -> list[str]: + """prefab 1 つを読み込んだ後にレポートへ出す警告(呼ぶ側が「prefab : 」を付ける)。""" + messages: list[str] = [] + if hidden_unused: + messages.append(unused_warning(hidden_unused)) + if hidden_lods: + messages.append(lod_warning(hidden_lods)) + return messages + + +def unused_warning(hidden_unused: int) -> str: + """使われていない FBX の部品を隠したときの文言(Scenes / Prefabs のどちらからも使う。#58 / #151)。""" + return ( + f"{hidden_unused} object(s) from model files are not used by the Unity prefabs or scene " + "and were hidden (they stay in the file; unhide them if a renamed part was hidden by mistake)" + ) + + def lod_warning(hidden_lods: int) -> str: """遠景用 LOD を隠したときの文言(Scenes / Prefabs のどちらからも使う)。""" return ( From c52031806a1fca932a9b4d5e17bbef17442c18bc Mon Sep 17 00:00:00 2001 From: utagestudio Date: Sun, 25 Oct 2026 18:37:40 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E3=82=B7=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E3=83=BBprefab=20=E3=81=AE=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=A7=E3=80=81=E4=BD=BF=E3=82=8F=E3=82=8C=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=81=AA=E3=81=84=20FBX=20=E3=81=AE=E9=83=A8=E5=93=81=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BE=E3=81=AA=E3=81=84=E3=82=AA?= =?UTF-8?q?=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Japanese Train Station PBR Pack の DemoScene では、駅の部品 54 個をまとめた FBX から prefab が 1〜3 個だけを使うため、配置ごとに FBX を丸ごと複製して残りを隠しており、 非表示のメッシュが 28,015 個できていた。オブジェクトが多く、読み込み・保存・操作が重い。 読み込む単位 Scenes / Prefabs に「Skip Unused Parts」(既定 ON)を追加し、ON なら prefab・シーンが使っていない部品(#58 / #151 で隠していたもの)を読み込まない。 OFF なら今までどおり読み込んで隠す(照合を誤ったときの逃げ道)。 - Scenes 単位: 最初に読み込んだモデルから使わない部品を消してからテンプレートを作るので、 以後の配置では必要な分だけを複製する。使い回しのキーに、Renderer から作った配置かと メッシュ参照の fileID を加えた - 消すものは core/scene_import.parts_to_remove が決める。使う部品を子孫に持つものは 親子関係と位置を保つため残して隠し、子がすべて消えた Empty・アーマチュアは消す。 使われていないかの判定は unused_in_placement に切り出し、parts_to_hide と共用する - 消したオブジェクトだけが使っていたメッシュ・アーマチュアのデータも消す。マテリアルは 同じ回の別のモデルと共有するので消さない - 警告は「were not imported」の文言で件数を出す - 選択ダイアログに「Unused Parts」のトグル、オペレーターに skip_unused_parts を追加 - 統合テストに absent(オブジェクトが無いこと)を追加し、OFF を確かめる期待値を Scenes / Prefabs に 1 つずつ足した DemoScene: 読み込み 34.2 秒 → 3.4 秒、オブジェクト 31,290 → 1,167。表示されるメッシュ 555 個は 名前と位置が OFF と一致した(Japanese Street の 2 シーン、Japanese Apartment でも一致)。 Closes #152 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 2 + CLAUDE.md | 2 + DESIGN.md | 3 +- README.md | 11 ++- README_ja.md | 5 +- tests/expectations.schema.md | 4 +- tests/expectations_synthetic_scene.json | 4 +- ...ectations_synthetic_scene_keep_unused.json | 32 +++++++ .../expectations_synthetic_scene_prefabs.json | 23 +---- ...s_synthetic_scene_prefabs_keep_unused.json | 35 +++++++ tests/integration_import.py | 6 ++ tests/test_scene_import.py | 64 ++++++++++++- unitypackage_loader/blender/importer.py | 45 ++++++++- unitypackage_loader/blender/scene_objects.py | 20 ++++ unitypackage_loader/blender_manifest.toml | 2 +- unitypackage_loader/core/scene_import.py | 96 ++++++++++++++++--- .../operators/import_package.py | 7 ++ .../operators/select_models.py | 17 +++- 18 files changed, 322 insertions(+), 56 deletions(-) create mode 100644 tests/expectations_synthetic_scene_keep_unused.json create mode 100644 tests/expectations_synthetic_scene_prefabs_keep_unused.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3bf5651..1b70ec6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -130,6 +130,8 @@ jobs: run synthetic_multi.unitypackage expectations_synthetic_prefabs_stack.json run synthetic_scene.unitypackage expectations_synthetic_scene.json run synthetic_scene.unitypackage expectations_synthetic_scene_prefabs.json + run synthetic_scene.unitypackage expectations_synthetic_scene_keep_unused.json + run synthetic_scene.unitypackage expectations_synthetic_scene_prefabs_keep_unused.json run synthetic_urp.unitypackage expectations_synthetic_urp.json run synthetic_urp_standard.unitypackage expectations_synthetic_urp_standard.json run synthetic_hdrp.unitypackage expectations_synthetic_hdrp.json diff --git a/CLAUDE.md b/CLAUDE.md index 8ff3386..b566907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,8 @@ blender -b --factory-startup --python tests/integration_import.py -- _local/unit blender -b --factory-startup --python tests/make_synthetic_scene_package.py # シーン用の合成パッケージ(_local/unitypackages/ に出る) blender -b --factory-startup --python tests/integration_import.py -- _local/unitypackages/synthetic_scene.unitypackage tests/expectations_synthetic_scene.json # 読み込む単位 Scenes blender -b --factory-startup --python tests/integration_import.py -- _local/unitypackages/synthetic_scene.unitypackage tests/expectations_synthetic_scene_prefabs.json # 読み込む単位 Prefabs(LODGroup の非表示) +blender -b --factory-startup --python tests/integration_import.py -- _local/unitypackages/synthetic_scene.unitypackage tests/expectations_synthetic_scene_keep_unused.json # Scenes で使われていない部品を読み込んで隠す(Skip Unused Parts を OFF) +blender -b --factory-startup --python tests/integration_import.py -- _local/unitypackages/synthetic_scene.unitypackage tests/expectations_synthetic_scene_prefabs_keep_unused.json # Prefabs で同じく OFF blender -b --factory-startup --python tests/make_synthetic_urp_package.py # URP 用の合成パッケージ(ライトが URP の単位になるので別にする。マテリアルを Standard にした _standard も出る) blender -b --factory-startup --python tests/integration_import.py -- _local/unitypackages/synthetic_urp.unitypackage tests/expectations_synthetic_urp.json # URP Lit の読み方と URP のライト blender -b --factory-startup --python tests/integration_import.py -- _local/unitypackages/synthetic_urp_standard.unitypackage tests/expectations_synthetic_urp_standard.json # ライトの追加データからの URP の判定 diff --git a/DESIGN.md b/DESIGN.md index 3bcbe0c..026dbfa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -57,7 +57,8 @@ Blender から `.unitypackage` を直接読み込み、メッシュ(アーマ - 座標変換(`core/transform.py`): Unity のモデル空間 (x, y, z) は Blender では (-x, -z, y)。Unity でモデルのルートに掛かる行列 M は、Blender では C·M·C⁻¹ を原点に読み込んだオブジェクトの行列に左から掛ける。Unity 6 で合成 FBX を 3 通り(FBX を中に置いた prefab、空の親の下への直置き、非一様スケールで置いて中の子を上書きした展開 prefab)に置いたシーンを作り、Renderer ごとの頂点のワールド座標(スキンしたメッシュはボーン行列 × bindpose で計算)と、Blender の FBX インポーターで読んだ同じ FBX から予測した座標が、9 か所すべてで一致することを確かめた。Blender 由来の FBX では、Unity のルート直下のノードは X -90 度・スケール 100 を持つが、Blender のオブジェクトはその値を持たない。そのためノードの値を直接オブジェクトに当てず、行列で計算してから差分として当てる。 - 読み込み: 配置のルートと、その祖先の GameObject を Empty にし(行列は Unity のローカル行列を C·M·C⁻¹ で変換)、モデルの最上位のオブジェクトをルートの Empty の子にする。.meta の `globalScale` は親の逆行列として掛ける。同じモデル・同じマテリアルの割り当ての配置は、2 つ目からメッシュ・アーマチュアのデータを共有した複製(`Object.copy()`)にし、アーマチュアモディファイアとコンストレイントの参照を複製側に付け替える。割り当てが違えば読み直す(組み立て済みの .mat は、下記「マテリアルの共有」のとおり共有)。展開 prefab の中のノードが上書きで動いていれば、そのノードから逆算したルートの行列でオブジェクトを置き直す(親から順に。ワールド行列は評価を待たずに親をたどって計算する。アーマチュアで変形するものは動かさず件数を警告に出す)。非アクティブな GameObject と無効な Renderer は `hide_render` をその場で立て、`hide_set` は読み込みがすべて終わってからまとめて当てる(下記の理由で、読み込み中はビューレイヤーに無いため)。 - 読み込み中のビューレイヤー: インポーターのオペレーターは呼ぶたびにビューレイヤーの中身を評価し直すので、読み込み済みのオブジェクトが増えるほど 1 回が重くなる(3000 オブジェクトで 1 回 180 ms。Japanese Street の Day_Showcase の読み込み 16 秒の大半)。パッケージのコレクションは読み込みが終わるまで `exclude` し、モデルは空の作業用コレクション「<パッケージ名> (importing)」に読み込んでから配置先へ移す。終わったら(失敗しても)除外を戻して作業用コレクションを消す。これで Day_Showcase は 1.7 秒になった(Issue #75)。 -- 使われていない部品(Issue #58): 展開した prefab・シーンの Renderer から作った配置では、Unity にあるのは表の Renderer だけなので、表に無いメッシュのオブジェクト(prefab が使っていない LOD、FBX にだけある別のノードなど)を非表示にする(削除はしない。件数を警告に出す)。FBX を直接置いた配置は対象にしない。読み込む単位 Prefabs でも、prefab がモデルを丸ごと置いていなければ(モデルの PrefabInstance の配置が無ければ。`prefab.models_placed_whole`)、表に無いメッシュを同じように隠す(Issue #151。以前は Prefabs 単位にこの処理が無く、部品をまとめた FBX のメッシュを 1 つだけ指す prefab で、FBX の全部品が表示されていた)。Renderer の名前は GameObject 名ではなくメッシュ参照(MeshFilter / SkinnedMeshRenderer の `m_Mesh` の fileID)から決める。展開後に GameObject の名前を変えていると、名前の照合では使っている部品まで隠してしまうため。モデルの .meta の表(`fileIDToRecycleName`、古い番号を引き継いだ `internalIDToNameTable`)で名前を引き、表の無い新しい形式では fileID = xxHash64("Type:Mesh-><名前>0")(`core/unity_ids.py`。Unity 6 の prefab と Japanese Street の 100 か所で一致)を Blender のオブジェクト名で計算して照合する。 +- 使われていない部品(Issue #58): 展開した prefab・シーンの Renderer から作った配置では、Unity にあるのは表の Renderer だけなので、表に無いメッシュのオブジェクト(prefab が使っていない LOD、FBX にだけある別のノードなど)は Unity に無い。既定ではこれを読み込まず(ダイアログの「Unused Parts」、`ImportOptions.skip_unused_parts`。Issue #152)、オプションを切ると読み込んだうえで非表示にする(件数はどちらも警告に出す)。FBX を直接置いた配置は対象にしない。読み込む単位 Prefabs でも、prefab がモデルを丸ごと置いていなければ(モデルの PrefabInstance の配置が無ければ。`prefab.models_placed_whole`)、表に無いメッシュを同じように隠す(Issue #151。以前は Prefabs 単位にこの処理が無く、部品をまとめた FBX のメッシュを 1 つだけ指す prefab で、FBX の全部品が表示されていた)。Renderer の名前は GameObject 名ではなくメッシュ参照(MeshFilter / SkinnedMeshRenderer の `m_Mesh` の fileID)から決める。展開後に GameObject の名前を変えていると、名前の照合では使っている部品まで隠してしまうため。モデルの .meta の表(`fileIDToRecycleName`、古い番号を引き継いだ `internalIDToNameTable`)で名前を引き、表の無い新しい形式では fileID = xxHash64("Type:Mesh-><名前>0")(`core/unity_ids.py`。Unity 6 の prefab と Japanese Street の 100 か所で一致)を Blender のオブジェクト名で計算して照合する。 + - 読み込まないときの消し方(Issue #152): シーンでは配置ごとに FBX のテンプレートを複製するので、最初に読み込んだモデルから使われていない部品を消し、残りからテンプレートを作る。以後の配置では必要な分だけを複製する(部品を 1 つの FBX にまとめ、prefab がそのうち 1〜3 個だけを指す Japanese Train Station PBR Pack の DemoScene では、オブジェクトが 31,290 から 1,167 に、読み込みが 34 秒から 3.4 秒になった)。消す部品はテンプレートを使い回す条件で決まるよう、使い回しのキーに Renderer から作った配置かとメッシュ参照の fileID を加える。消すものは `scene_import.parts_to_remove` が決める。使われていないメッシュでも、子孫に残すもの(使う部品)があれば親子関係と位置を保つため残して隠し、子がすべて消えた Empty・アーマチュアは消す。消したオブジェクトだけが使っていたメッシュ・アーマチュアのデータも消す。マテリアルは同じ回の別のモデルと共有するので(#77)消さない。Prefabs 単位では prefab ごとに読み込んだモデルから同じように消す。 - LOD(Issue #104): Unity の LOD は prefab・シーンの `LODGroup`(クラス ID 205)が持ち、`m_LODs` の段ごとに Renderer の fileID が並ぶ。段ごとの GameObject はどれも Renderer を持つので、そのままでは全段が重なって表示される。`parse_asset` で段を読み、`RendererInfo.lod_level`(0 = もっとも細かい LOD0)に付けて、読み込み側が LOD1 以降を非表示にする(削除はしない。段は `unity_lod` カスタムプロパティに残し、件数を警告に出す。ダイアログの「LODs」トグルで切れる)。LODGroup はシーンから prefab インスタンスの中の Renderer を指すことがあるので、段の解決は PrefabInstance の差し込みが終わった後に、stripped ドキュメントの別名を通して行う。1 つの LODGroup の中で同じ Renderer が複数の段にあればもっとも細かい段を採り、内側の prefab で付いた段は外側で上書きしない(内側で遠景用だったものは隠れたままにする)。`m_Enabled: 0` の LODGroup は Unity でも全段が描かれるので段を付けない。適用するのは Scenes と Prefabs 単位(Models 単位の表は prefab をまたいで統合したものなので対象にしない)。外側から LODGroup 自体を無効化・削除する上書きは反映しない。`_LOD1` のような命名規則からの推測はしない(LODGroup の無いモデルは今までどおり全部表示する)。 - Renderer をまとめるモデルのルート(Issue #60): 以前は Transform を直接持つアセットでの最上位の祖先に固定していたため、シーンの共通の親(部屋など)の下に同じ小物を複製して並べると、名前が同じ Renderer が 1 つだけ残り、しかも親の原点に置かれていた。モデルの .meta の表(`fileIDToRecycleName` / `internalIDToNameTable`)にある、ルート以外の GameObject 名を FBX のノード名として、次のように決める(`core/hierarchy.py` の `placements`)。 - 1 メッシュの FBX(表の GameObject はルートだけで、メッシュも 1 つ): Renderer の GameObject 自身。この形の FBX では、Unity はノードを `//RootNode` に畳んでその変換をモデルのルートの Transform に載せ、メッシュの頂点はノード空間のままにするので、メッシュを直接指す Renderer にはノードの変換が掛からない。そこで、下のノード自身をルートにした配置と同じく `node_transforms`(名前は表のメッシュ名)でそのノードを単位行列にする。これをしないと、ノードが原点から離れた FBX(街並みアセットの歩道など)がノードの移動ぶんずれる(Issue #98。Unity 6000.6.0f1 で確認)。 diff --git a/README.md b/README.md index 1f76325..e29b258 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,11 @@ ticked): gets its own Collection; the GameObjects above the models are recreated as Empties, and inactive objects are hidden. Only the scene's contents are imported. Copies of the same model with the same materials share their mesh data. Small props copied side by side under a common parent in Unity (for example several cups in a room) are placed one by one. - Parts of a model file that a prefab or the scene does not use (for example unused LODs or extra nodes) are hidden, - matched by the mesh each Unity renderer references. Where Unity has an LODGroup, only the most detailed level (LOD0) - stays visible: the distant levels are imported and hidden (toggle this with **LODs** in the dialog; the hidden - objects stay in the file and keep their level in the `unity_lod` custom property). + Parts of a model file that a prefab or the scene does not use (for example unused LODs, extra nodes, or the other + pieces of a model file that bundles many props) are not imported, matched by the mesh each Unity renderer references + (turn off **Unused Parts** in the dialog to import them hidden instead). Where Unity has an LODGroup, only the most + detailed level (LOD0) stays visible: the distant levels are imported and hidden (toggle this with **LODs** in the + dialog; the hidden objects stay in the file and keep their level in the `unity_lod` custom property). Lights and cameras are imported too (toggle them with "Also Import"): light intensity is converted from values measured in Unity (Built-in and URP) and Blender's EEVEE, and the original values are kept as custom properties. The render pipeline is detected from the URP / HDRP additional data on lights and cameras, and from the materials' @@ -121,7 +122,7 @@ ticked): Color variants that share a model can be imported together. When two or more prefabs are selected, **Arrange** places them `Side by Side` (next to each other without overlapping, wrapping into a grid when there are many) or `Stack at Origin`. Prefabs with an LODGroup hide their distant LOD levels, and parts of a model file that the prefab - does not use are hidden, as scenes do. + does not use are not imported, as in scenes. - **Models**: imports the model files (FBX etc.) as they are. Materials are resolved as described in [Matching meshes to materials](#matching-meshes-to-materials). diff --git a/README_ja.md b/README_ja.md index fd4a200..5e39991 100644 --- a/README_ja.md +++ b/README_ja.md @@ -100,7 +100,8 @@ Scenes は、シーンをまとめて読み込むと建物やライトが重な モデルより上の GameObject は Empty で再現し、非アクティブなものは非表示にします。読み込むのはシーンの内容だけです。 同じモデル・同じマテリアルの配置は、メッシュのデータを共有した複製になります。 Unity で共通の親の下に同じ小物を複製して並べたもの(部屋に置いた複数のカップなど)も、1 つずつ置きます。 - prefab やシーンが使っていないモデルファイルの部品(使われていない LOD や余分なノードなど)は、Unity の Renderer が指すメッシュで照合して非表示にします。 + prefab やシーンが使っていないモデルファイルの部品(使われていない LOD や余分なノード、部品をまとめたモデルファイルのほかの部品など)は、 + Unity の Renderer が指すメッシュで照合して読み込みません(ダイアログの **Unused Parts** を切ると、読み込んだうえで非表示にします)。 Unity の LODGroup がある場合は、もっとも細かい LOD0 だけを表示し、遠景用の段は読み込んだうえで非表示にします(ダイアログの **LODs** で切り替え。 隠したオブジェクトはファイルに残り、段が `unity_lod` カスタムプロパティに入ります)。ライトとカメラも読み込みます(「Also Import」で切り替え)。 ライトの強さは Unity(Built-in / URP)と Blender の EEVEE で測った値をもとに換算し、元の値はカスタムプロパティに残します。 @@ -112,7 +113,7 @@ Scenes は、シーンをまとめて読み込むと建物やライトが重な 中のオブジェクトへの変更(マテリアルや、開けたドアなど部品の位置)は、モデルの `.meta` が Unity 2018.2 以前の形式(名前の表がある)なら当て、 そうでなければ件数を警告に出します。 - **Prefabs**: prefab ごとに、その prefab のマテリアルの割り当てのまま読み込みます。パッケージの Collection の中に prefab ごとの Collection ができます。 - LODGroup のある prefab は、Scenes と同じく遠景用の LOD を非表示にします。prefab が使っていないモデルファイルの部品も、Scenes と同じく非表示にします。 + LODGroup のある prefab は、Scenes と同じく遠景用の LOD を非表示にします。prefab が使っていないモデルファイルの部品も、Scenes と同じく読み込みません。 同じモデルを使う色違いも一緒に読み込めます。2 つ以上選んだときは **Arrange** で、`Side by Side`(重ならないように並べる。数が多ければ格子状に折り返す)と `Stack at Origin`(原点に重ねる)を選べます。 - **Models**: モデルファイル(FBX など)をそのまま読み込みます。マテリアルは [メッシュとマテリアルの対応付け](#メッシュとマテリアルの対応付け) のとおりに決まります。 diff --git a/tests/expectations.schema.md b/tests/expectations.schema.md index 01dcd58..2cb5a04 100644 --- a/tests/expectations.schema.md +++ b/tests/expectations.schema.md @@ -76,9 +76,9 @@ | `split_slots` | prefab の割り当てでマテリアルを差し替えたスロット数 | | `submesh_materials` | オブジェクトのポリゴンで最初に使われた順(Unity のサブメッシュ順)に並べたマテリアル名。スロットの並びに依らず、ポリゴン群とマテリアルの対応を確かめる | | `prefabs.count` | 読み込む単位 Prefabs で読み込んだ prefab の数(`options` に `"unit": "PREFABS"` を指定する) | -| `collections` | prefab ごとのコレクションの中身。`objects` はオブジェクト数(子コレクション込み)、`prefab` はコレクションの `unity_prefab`、`mat_files` はメッシュに付いたマテリアルの元の .mat のファイル名の集合(同じモデルを読み直すとマテリアル名に連番が付くため、名前ではなく元のファイルで比べる)、`parts` はオブジェクト名ごとの `hidden`(`hide_get()`)と `lod`(`unity_lod` カスタムプロパティ) | +| `collections` | prefab ごとのコレクションの中身。`objects` はオブジェクト数(子コレクション込み)、`prefab` はコレクションの `unity_prefab`、`mat_files` はメッシュに付いたマテリアルの元の .mat のファイル名の集合(同じモデルを読み直すとマテリアル名に連番が付くため、名前ではなく元のファイルで比べる)、`parts` はオブジェクト名ごとの `hidden`(`hide_get()`)と `lod`(`unity_lod` カスタムプロパティ)。`absent: true` ならそのオブジェクトが無いこと | | `scenes.count` | 読み込む単位 Scenes で読み込んだシーンの数(`options` に `"unit": "SCENES"` を指定する) | -| `placed_objects` | シーンで配置したオブジェクト。最上位の Empty(Unity の最上位の GameObject)の名前とオブジェクト名(`.001` などの連番を除く)で 1 つに絞る。`tip` は評価後のメッシュで重心から最も遠い頂点のワールド座標(誤差 0.001 まで)、`hidden` は `hide_get()`、`lod` は `unity_lod` カスタムプロパティ、`mat_files` はスロットのマテリアルの元の .mat のファイル名の集合 | +| `placed_objects` | シーンで配置したオブジェクト。最上位の Empty(Unity の最上位の GameObject)の名前とオブジェクト名(`.001` などの連番を除く)で 1 つに絞る。`tip` は評価後のメッシュで重心から最も遠い頂点のワールド座標(誤差 0.001 まで)、`hidden` は `hide_get()`、`lod` は `unity_lod` カスタムプロパティ、`mat_files` はスロットのマテリアルの元の .mat のファイル名の集合。`absent: true` なら該当するオブジェクトが無いこと(読み込まなかった部品) | | `lights` / `cameras` | シーンから作ったライト・カメラ(オブジェクト名で探す)。`type`、`energy`、`spot_size`、`spot_blend`、`size`、`size_y`、`shape`、`use_shadow`、`use_custom_distance`、`cutoff_distance`、`use_temperature`、`temperature`、`lens`、`sensor_fit`、`ortho_scale`、`clip_end` はライト・カメラのデータの値(数値は 0.1% まで)。`direction` はオブジェクトの -Z のワールドでの向き、`location` はワールド座標、`hidden` は `hide_get()` | | `scene_camera` | シーンのカメラになったオブジェクトの名前 | | `light_count` / `camera_count` | レポートのライト・カメラの数 | diff --git a/tests/expectations_synthetic_scene.json b/tests/expectations_synthetic_scene.json index 8927235..d329935 100644 --- a/tests/expectations_synthetic_scene.json +++ b/tests/expectations_synthetic_scene.json @@ -249,12 +249,12 @@ { "top": "Partial", "object": "Child", - "hidden": true + "absent": true }, { "top": "Partial", "object": "Skinned", - "hidden": true + "absent": true }, { "top": "KioskRoot", diff --git a/tests/expectations_synthetic_scene_keep_unused.json b/tests/expectations_synthetic_scene_keep_unused.json new file mode 100644 index 0000000..b30b7a4 --- /dev/null +++ b/tests/expectations_synthetic_scene_keep_unused.json @@ -0,0 +1,32 @@ +{ + "options": { + "material_mode": "AUTO", + "models": "ALL", + "unit": "SCENES", + "skip_unused_parts": false + }, + "placed_objects": [ + { + "top": "Partial", + "object": "Spike", + "hidden": false + }, + { + "top": "Partial", + "object": "Child", + "hidden": true + }, + { + "top": "Partial", + "object": "Skinned", + "hidden": true + } + ], + "warnings_contain": [ + "scene Assets/Synthetic/Scenes/Probe.unity: 2 object(s) from model files are not used by the Unity prefabs or scene and were hidden", + "scene Assets/Synthetic/Scenes/Legacy.unity: 4 object(s) from model files are not used by the Unity prefabs or scene and were hidden" + ], + "warnings_exclude": [ + "were not imported" + ] +} diff --git a/tests/expectations_synthetic_scene_prefabs.json b/tests/expectations_synthetic_scene_prefabs.json index 107b629..370da6d 100644 --- a/tests/expectations_synthetic_scene_prefabs.json +++ b/tests/expectations_synthetic_scene_prefabs.json @@ -36,39 +36,22 @@ }, "Partial": { "prefab": "Assets/Synthetic/Prefabs/Partial.prefab", - "objects": 5, + "objects": 1, "parts": { "Spike.001": { "hidden": false - }, - "Child.001": { - "hidden": true - }, - "Skinned.001": { - "hidden": true } } }, "Unpacked": { "prefab": "Assets/Synthetic/Prefabs/Unpacked.prefab", - "objects": 5, - "parts": { - "Spike.002": { - "hidden": false - }, - "Child.002": { - "hidden": false - }, - "Skinned.002": { - "hidden": false - } - } + "objects": 5 } }, "warnings_max": 4, "warnings_contain": [ "Booth.fbx: 1 material override(s) on an object inside the model are not read yet", - "prefab Assets/Synthetic/Prefabs/Partial.prefab: 2 object(s) from model files are not used" + "prefab Assets/Synthetic/Prefabs/Partial.prefab: 2 object(s) from model files are not used by the Unity prefabs or scene and were not imported" ], "warnings_exclude": [ "Kiosk.fbx: " diff --git a/tests/expectations_synthetic_scene_prefabs_keep_unused.json b/tests/expectations_synthetic_scene_prefabs_keep_unused.json new file mode 100644 index 0000000..3ab2e8e --- /dev/null +++ b/tests/expectations_synthetic_scene_prefabs_keep_unused.json @@ -0,0 +1,35 @@ +{ + "options": { + "material_mode": "AUTO", + "models": "ALL", + "unit": "PREFABS", + "skip_unused_parts": false + }, + "collections": { + "Partial": { + "prefab": "Assets/Synthetic/Prefabs/Partial.prefab", + "objects": 5, + "parts": { + "Spike.001": { + "hidden": false + }, + "Child.001": { + "hidden": true + }, + "Skinned.001": { + "hidden": true + } + } + }, + "Unpacked": { + "prefab": "Assets/Synthetic/Prefabs/Unpacked.prefab", + "objects": 5 + } + }, + "warnings_contain": [ + "prefab Assets/Synthetic/Prefabs/Partial.prefab: 2 object(s) from model files are not used by the Unity prefabs or scene and were hidden" + ], + "warnings_exclude": [ + "were not imported" + ] +} diff --git a/tests/integration_import.py b/tests/integration_import.py index 4276d72..4f00bcb 100644 --- a/tests/integration_import.py +++ b/tests/integration_import.py @@ -324,6 +324,9 @@ def main() -> int: # コレクションの中の個々のオブジェクト(非表示か、LODGroup の段) for obj_name, part in spec.get("parts", {}).items(): obj = next((o for o in objects if o.name == obj_name), None) + if part.get("absent"): # 読み込まないもの(使われていない部品。#152) + c.true(f"{coll_name}/{obj_name} absent", obj is None) + continue c.true(f"{coll_name}/{obj_name} exists", obj is not None) if obj is None: continue @@ -365,6 +368,9 @@ def main() -> int: for spec in exp.get("placed_objects", []): label = "/".join(filter(None, (spec["top"], spec.get("parent"), spec["object"]))) found = _find_placed(spec["top"], spec["object"], spec.get("parent")) + if spec.get("absent"): # 読み込まないもの(使われていない部品。#152) + c.eq(f"{label} found", len(found), 0) + continue c.eq(f"{label} found", len(found), 1) if len(found) != 1: continue diff --git a/tests/test_scene_import.py b/tests/test_scene_import.py index 3037341..7c07543 100644 --- a/tests/test_scene_import.py +++ b/tests/test_scene_import.py @@ -2,7 +2,15 @@ from tests import _paths # noqa: F401 from unitypackage_loader.core.hierarchy import CLASS_MESH_RENDERER, Hierarchy, ModelPlacement, Node, PlacedRenderer, SceneContents -from unitypackage_loader.core.scene_import import parts_to_hide, prefab_warnings, scene_warnings, unused_warning +from unitypackage_loader.core.scene_import import ( + parts_to_hide, + parts_to_remove, + prefab_warnings, + removed_warning, + scene_warnings, + unused_in_placement, + unused_warning, +) from unitypackage_loader.core.transform import IDENTITY from unitypackage_loader.core.unity_ids import mesh_file_id @@ -17,11 +25,15 @@ def renderer(name: str, visible: bool = True, mesh_id: int = 0, lod: int = 0) -> return PlacedRenderer(name, [], CLASS_MESH_RENDERER, visible, mesh_id, lod) +def placed(*renderers: PlacedRenderer, active: bool = True, model_root: bool = False): + h = Hierarchy({1: node(1, MODEL if model_root else None)}) + placement = ModelPlacement(MODEL, 1, IDENTITY, active, {r.name: r for r in renderers}) + return h, placement + + class PartsToHideTests(unittest.TestCase): def placement(self, *renderers: PlacedRenderer, active: bool = True, model_root: bool = False): - h = Hierarchy({1: node(1, MODEL if model_root else None)}) - placement = ModelPlacement(MODEL, 1, IDENTITY, active, {r.name: r for r in renderers}) - return h, placement + return placed(*renderers, active=active, model_root=model_root) def hidden(self, *args, **kwargs): result = parts_to_hide(*args, **kwargs) @@ -87,6 +99,44 @@ def test_inactive_placement_is_not_counted_as_lod(self): self.assertEqual(result.lods, {"Bike_LOD0": 0, "Bike_LOD1": 1}) +class UnusedInPlacementTests(unittest.TestCase): + def test_same_parts_as_hidden(self): + h, placement = placed(renderer("Body"), renderer("RenamedDoor", mesh_id=mesh_file_id("Door"))) + objects = [("Body.001", True), ("Door.002", True), ("Window", True), ("Armature", False)] + self.assertEqual(unused_in_placement(h, placement, objects), {"Window"}) + h, whole = placed(renderer("Body"), model_root=True) + self.assertEqual(unused_in_placement(h, whole, objects), set()) + + +class PartsToRemoveTests(unittest.TestCase): + """使われていない部品を読み込まないときに消すもの(#152)。""" + + def test_leaf_parts_and_emptied_containers(self): + objects = [ + ("Spike", None, "MESH"), # 使う + ("Group", None, "EMPTY"), # 使われていない Child だけを持つ入れ物 + ("Child", "Group", "MESH"), + ("Armature", None, "ARMATURE"), # 使われていない Skinned だけを持つ + ("Skinned", "Armature", "MESH"), + ("Marker", None, "EMPTY"), # もともと子の無い Empty は残す + ] + self.assertEqual(parts_to_remove(objects, {"Child", "Skinned"}), {"Group", "Child", "Armature", "Skinned"}) + + def test_unused_parent_of_a_used_part_is_kept(self): + objects = [ + ("Frame", None, "MESH"), # 使われていないが、使う Door の親 + ("Door", "Frame", "MESH"), + ("Glass", "Frame", "MESH"), + ("Rig", None, "ARMATURE"), # 使う Body が残るので残す + ("Body", "Rig", "MESH"), + ("Extra", "Rig", "MESH"), + ] + self.assertEqual(parts_to_remove(objects, {"Frame", "Glass", "Extra"}), {"Glass", "Extra"}) + + def test_nothing_unused(self): + self.assertEqual(parts_to_remove([("Body", None, "MESH"), ("Group", None, "EMPTY")], set()), set()) + + class SceneWarningsTests(unittest.TestCase): def test_messages_in_order(self): contents = SceneContents([], unresolved_overrides=2, missing_sources=1, other_renderers=3) @@ -114,6 +164,12 @@ def test_same_wording_as_scenes(self): self.assertTrue(messages[0].startswith("53 object(s) from model files are not used")) self.assertTrue(messages[1].startswith("2 object(s) are lower LOD levels")) + def test_removed_parts(self): + messages = prefab_warnings(removed_unused=53, hidden_unused=1) + self.assertEqual(messages, [removed_warning(53), unused_warning(1)]) + self.assertIn("were not imported", messages[0]) + self.assertEqual(scene_warnings("S", SceneContents([]), removed_unused=4), ["scene S: " + removed_warning(4)]) + if __name__ == "__main__": unittest.main() diff --git a/unitypackage_loader/blender/importer.py b/unitypackage_loader/blender/importer.py index 3430c0a..d474080 100644 --- a/unitypackage_loader/blender/importer.py +++ b/unitypackage_loader/blender/importer.py @@ -21,6 +21,7 @@ Expander, Hierarchy, RawAsset, + built_from_renderers, effective_active, parse_asset, placements, @@ -48,7 +49,7 @@ from ..core.profiles import ShaderTable, normalize_material from ..core.profiles.base import default_table from ..core.report import ImportReport, MaterialReport -from ..core.scene_import import parts_to_hide, prefab_warnings, scene_warnings +from ..core.scene_import import parts_to_hide, parts_to_remove, prefab_warnings, scene_warnings, unused_in_placement from ..core.shader_source import SHADER_SOURCE_EXTS, declared_properties from ..core.units import ( UNIT_PREFABS, @@ -76,6 +77,7 @@ make_camera, make_light, move_collection_contents, + remove_parts, scene_empty, ) from .textures import load_image @@ -101,6 +103,7 @@ class ImportOptions: scene_cameras: bool = True # シーンのカメラを読み込む scene_exposure: bool = True # HDRP のシーンの Volume の露出を、そのシーンの HDRP のライトの強さに反映する(#130) hide_lods: bool = True # LODGroup の遠景用の段(LOD1 以降)を非表示にする(Scenes / Prefabs 単位) + skip_unused_parts: bool = True # prefab・シーンが使っていない FBX の部品を読み込まない(Scenes / Prefabs 単位。#152) arrange: str = "SIDE_BY_SIDE" # prefab を複数読み込むときの並べ方: SIDE_BY_SIDE / STACK material_mode: str = mat_builder.MODE_AUTO force_opaque: bool = False @@ -880,7 +883,7 @@ def run(self) -> None: skipped = self.model_by_guid.get(guid) if skipped is not None and not skipped.supported: self.report.warn(f"prefab {prefab.pathname}: skipped {skipped.entry.pathname}: {skipped.skip_reason}") - hidden_lods = hidden_unused = 0 + hidden_lods = hidden_unused = removed_unused = 0 whole = self.prepared.prefab_whole_models.get(group.prefab.pathname, set()) if group.prefab else set() for summary, prefab_table in group.models: self.step(0.55 + 0.4 * done / max(total, 1), f"Importing {summary.entry.name}") @@ -894,11 +897,18 @@ def run(self) -> None: continue # Models 単位の表は prefab をまたいで統合したものなので、LOD を隠すのは prefab 単位のときだけ if group.prefab is not None: - hidden_lods += self.hide_prefab_lods(objects, prefab_table) if summary.guid not in whole: + if self.opts.skip_unused_parts: + objects, removed = self.remove_unused( + objects, unused_parts(prefab_table, [(o.name, o.type == "MESH") for o in objects]) + ) + removed_unused += removed hidden_unused += self.hide_unused_parts(objects, prefab_table) + hidden_lods += self.hide_prefab_lods(objects, prefab_table) if group.prefab is not None: - for message in prefab_warnings(hidden_unused=hidden_unused, hidden_lods=hidden_lods): + for message in prefab_warnings( + hidden_unused=hidden_unused, removed_unused=removed_unused, hidden_lods=hidden_lods + ): self.report.warn(f"prefab {group.prefab.pathname}: {message}") for scene_summary in scenes: try: @@ -1122,6 +1132,19 @@ def hide_prefab_lods(self, objects: list[bpy.types.Object], prefab_table: dict[s hidden += 1 return hidden + def remove_unused(self, objects: list[bpy.types.Object], unused: set[str]) -> tuple[list[bpy.types.Object], int]: + """使われていない部品を消し、(残ったオブジェクト, 消した使われていないメッシュの数) を返す(#152)。 + + 子孫に残すものがある部品は消さずに残す(隠すのは呼ぶ側)。中身が無くなった Empty・アーマチュアも消す。 + """ + if not unused: + return objects, 0 + removed = parts_to_remove([(o.name, o.parent.name if o.parent else None, o.type) for o in objects], unused) + if not removed: + return objects, 0 + self.report.objects[:] = [name for name in self.report.objects if name not in removed] + return remove_parts(objects, removed), len(removed & unused) + def hide_unused_parts(self, objects: list[bpy.types.Object], prefab_table: dict[str, RendererMaterials]) -> int: """prefab が使っていない FBX の部品を隠し、隠した数を返す(Scenes 単位の #58 と同じ扱い。#151)。 @@ -1189,6 +1212,8 @@ def import_scene(self, scene_summary: SceneSummary, progress_start: float, progr unit_scales: dict[str, float | None] = {} # モデルの GUID → FBX の UnitScaleFactor skipped_nodes = 0 # 名前を引けたが当てられなかった、モデルの中のノードへの位置の上書き hidden_unused = 0 # Unity の prefab・シーンが使っていないので隠した FBX の部品 + removed_unused = 0 # 同じく、読み込まなかった FBX の部品(#152) + removed_by_key: dict[tuple, int] = {} # テンプレートを作るときに消した部品の数(以後の配置でも同じ数を数える) hidden_lods = 0 # LODGroup の遠景用の段として隠したオブジェクト count = len(scene_summary.placements) for index, placement in enumerate(scene_summary.placements): @@ -1208,6 +1233,10 @@ def import_scene(self, scene_summary: SceneSummary, progress_start: float, progr for name, r in placement.renderers.items() } key = placement.signature() + if self.opts.skip_unused_parts: + # 使われていない部品はテンプレートから消すので、それを決める値もテンプレートを使い回す条件に入れる + mesh_ids = tuple(sorted(r.mesh_file_id for r in placement.renderers.values())) + key = (key, built_from_renderers(hierarchy, placement), mesh_ids) template = templates.get(key) if template is None: try: @@ -1215,6 +1244,10 @@ def import_scene(self, scene_summary: SceneSummary, progress_start: float, progr except Exception as exc: # noqa: BLE001 - そのモデルの配置だけを外して続ける(#70) _record_failure(self.report, f"scene {pathname}: could not import {summary.entry.pathname}: {exc}") continue + if self.opts.skip_unused_parts: + # 最初に読み込んだものから消しておけば、以後の配置では必要な分だけを複製する(#152) + unused = unused_in_placement(hierarchy, placement, [(o.name, o.type == "MESH") for o in objects]) + objects, removed_by_key[key] = self.remove_unused(objects, unused) template = templates[key] = SceneTemplate.capture(objects) else: objects = duplicate_objects(template, target) @@ -1252,6 +1285,8 @@ def import_scene(self, scene_summary: SceneSummary, progress_start: float, progr if obj.name in hidden.names: defer_hide(obj, self.hidden_objects) hidden_unused += hidden.unused + if placement.active: # 非アクティブな配置は、隠したときと同じく数えない + removed_unused += removed_by_key.get(key, 0) hidden_lods += hidden.hidden_lods # --- ライト・カメラ --- @@ -1293,7 +1328,7 @@ def import_scene(self, scene_summary: SceneSummary, progress_start: float, progr defer_hide(obj, self.hidden_objects) for message in scene_warnings( pathname, scene_summary.contents, baked_lights=baked_lights, light_notes=light_notes, - hidden_unused=hidden_unused, hidden_lods=hidden_lods, + hidden_unused=hidden_unused, removed_unused=removed_unused, hidden_lods=hidden_lods, skipped_nodes=skipped_nodes, skipped_offsets=skipped_offsets, ): self.report.warn(message) diff --git a/unitypackage_loader/blender/scene_objects.py b/unitypackage_loader/blender/scene_objects.py index a5f7402..d7b986a 100644 --- a/unitypackage_loader/blender/scene_objects.py +++ b/unitypackage_loader/blender/scene_objects.py @@ -271,6 +271,26 @@ def apply_collapsed_root(template: SceneTemplate, objects, root, name: str, spec return False +def remove_parts(objects: list[bpy.types.Object], names: set[str]) -> list[bpy.types.Object]: + """``names`` のオブジェクトを消し、残ったオブジェクトを返す(使われていない部品を読み込まないとき。#152)。 + + 消したオブジェクトだけが使っていたメッシュ・アーマチュアのデータも消す(読み込んだばかりで、ほかに使う人はいない)。 + """ + kept: list[bpy.types.Object] = [] + for obj in objects: + if obj.name not in names: + kept.append(obj) + continue + data = obj.data + bpy.data.objects.remove(obj) + if data is not None and data.users == 0: + if isinstance(data, bpy.types.Mesh): + bpy.data.meshes.remove(data) + elif isinstance(data, bpy.types.Armature): + bpy.data.armatures.remove(data) + return kept + + def defer_hide(obj: bpy.types.Object, hidden: list) -> None: """レンダリングからはすぐ外し、ビューポートの非表示は ``hidden`` に積んで読み込みの後で当てる。 diff --git a/unitypackage_loader/blender_manifest.toml b/unitypackage_loader/blender_manifest.toml index 45c689a..ad43a6d 100644 --- a/unitypackage_loader/blender_manifest.toml +++ b/unitypackage_loader/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "unitypackage_loader" -version = "1.12.0-dev.1" +version = "1.12.0-dev.2" name = "Unitypackage Importer" tagline = "Import meshes, materials and textures from .unitypackage files" maintainer = "utagestudio" diff --git a/unitypackage_loader/core/scene_import.py b/unitypackage_loader/core/scene_import.py index cc3d48e..f025692 100644 --- a/unitypackage_loader/core/scene_import.py +++ b/unitypackage_loader/core/scene_import.py @@ -9,7 +9,17 @@ from .meta import strip_numeric_suffix from .unity_ids import mesh_file_id -__all__ = ["HiddenParts", "lod_warning", "parts_to_hide", "prefab_warnings", "scene_warnings", "unused_warning"] +__all__ = [ + "HiddenParts", + "lod_warning", + "parts_to_hide", + "parts_to_remove", + "prefab_warnings", + "removed_warning", + "scene_warnings", + "unused_in_placement", + "unused_warning", +] class HiddenParts(NamedTuple): @@ -21,6 +31,60 @@ class HiddenParts(NamedTuple): hidden_lods: int = 0 # そのうち遠景用 LOD として隠した数 +def unused_in_placement(h: Hierarchy, placement: ModelPlacement, objects: Iterable[tuple[str, bool]]) -> set[str]: + """配置で読み込んだメッシュのうち、Unity で使われていないもの(#58)。``objects`` は (Blender のオブジェクト名, メッシュか)。 + + 展開した prefab・シーンの Renderer から作った配置では、Unity にあるのは表の Renderer だけなので、FBX にしかない + メッシュ(prefab が使っていない LOD や別のノード)は使われていない。FBX 由来の名前に「.002」が付いていることもあるので、 + 表の名前は連番を外した形でも照合し、名前で引けなければ Renderer のメッシュの fileID をオブジェクト名のハッシュと照合する + (表で名前を引けない新しい形式のモデル)。FBX を丸ごと置いた配置では空。 + """ + if not built_from_renderers(h, placement): + return set() + used = set(placement.renderers) | {strip_numeric_suffix(n) for n in placement.renderers} + mesh_ids = {r.mesh_file_id for r in placement.renderers.values() if r.mesh_file_id} + result: set[str] = set() + for obj_name, is_mesh in objects: + name = strip_numeric_suffix(obj_name) + if ( + is_mesh and obj_name not in used and name not in used + and mesh_file_id(obj_name) not in mesh_ids and mesh_file_id(name) not in mesh_ids + ): + result.add(obj_name) + return result + + +def parts_to_remove(objects: Iterable[tuple[str, str | None, str]], unused: set[str]) -> set[str]: + """使われていない部品を読み込まないとき(#152)に、消すオブジェクトの名前。 + + ``objects`` は (Blender のオブジェクト名, 親の名前, オブジェクトの種類)。``unused`` は使われていないメッシュ。 + + - 使われていないメッシュは、子孫に残すものが無ければ消す。残すものがあれば、親子関係と位置を保つため残す + (隠すのは今までどおり ``parts_to_hide`` が行う) + - Empty とアーマチュアは、子がすべて消えたら消す(使われていない部品の入れ物だったもの)。もともと子の無いものは残す + """ + objects = list(objects) + kinds = {name: kind for name, _, kind in objects} + children: dict[str, list[str]] = {} + for name, parent, _ in objects: + if parent in kinds: + children.setdefault(parent, []).append(name) + removed: set[str] = set() + + def visit(name: str) -> bool: + kids = children.get(name, []) + all_gone = all([visit(kid) for kid in kids]) # 子はすべてたどる(短絡させない) + gone = all_gone and (name in unused or (bool(kids) and kinds[name] in ("EMPTY", "ARMATURE"))) + if gone: + removed.add(name) + return gone + + for name, parent, _ in objects: + if parent not in kinds: + visit(name) + return removed + + def parts_to_hide( h: Hierarchy, placement: ModelPlacement, objects: Iterable[tuple[str, bool]], hide_lods: bool = True ) -> HiddenParts: @@ -29,16 +93,12 @@ def parts_to_hide( ``objects`` は (Blender のオブジェクト名, メッシュか)。 - 配置が非アクティブならすべて、Renderer が無効ならそのオブジェクトを隠す - - 展開した prefab・シーンの Renderer から作った配置では、Unity にあるのは表の Renderer だけなので、FBX にしかない - メッシュ(prefab が使っていない LOD や別のノード)も隠す(#58)。FBX 由来の名前に「.002」が付いていることもあるので、 - 表の名前は連番を外した形でも照合し、名前で引けなければ Renderer のメッシュの fileID をオブジェクト名のハッシュと照合する - (表で名前を引けない新しい形式のモデル) + - Unity で使われていないメッシュ(``unused_in_placement``)も隠す(#58) - ``hide_lods`` なら、LODGroup の遠景用の段(LOD1 以降)の Renderer を隠す(#104) """ - from_renderers = built_from_renderers(h, placement) + objects = list(objects) + unused_names = unused_in_placement(h, placement, objects) disabled = {name for name, r in placement.renderers.items() if not r.visible} - used = set(placement.renderers) | {strip_numeric_suffix(n) for n in placement.renderers} - mesh_ids = {r.mesh_file_id for r in placement.renderers.values() if r.mesh_file_id} levels = {name: r.lod_level for name, r in placement.renderers.items()} by_mesh_id = {r.mesh_file_id: r.lod_level for r in placement.renderers.values() if r.mesh_file_id} hide: set[str] = set() @@ -46,10 +106,7 @@ def parts_to_hide( unused_count = lod_count = 0 for obj_name, is_mesh in objects: name = strip_numeric_suffix(obj_name) - unused = ( - from_renderers and is_mesh and obj_name not in used and name not in used - and mesh_file_id(obj_name) not in mesh_ids and mesh_file_id(name) not in mesh_ids - ) + unused = obj_name in unused_names level = levels.get(obj_name, levels.get(name)) if level is None and is_mesh: level = by_mesh_id.get(mesh_file_id(obj_name), by_mesh_id.get(mesh_file_id(name))) @@ -72,6 +129,7 @@ def scene_warnings( baked_lights: int = 0, light_notes: Iterable[str] = (), hidden_unused: int = 0, + removed_unused: int = 0, hidden_lods: int = 0, skipped_nodes: int = 0, skipped_offsets: int = 0, @@ -98,6 +156,8 @@ def scene_warnings( f"{contents.other_renderers} renderer(s) use meshes outside the package " "(such as Unity's built-in primitives) and were skipped" ) + if removed_unused: + messages.append(removed_warning(removed_unused)) if hidden_unused: messages.append(unused_warning(hidden_unused)) if hidden_lods: @@ -114,9 +174,11 @@ def scene_warnings( return [prefix + m for m in messages] -def prefab_warnings(*, hidden_unused: int = 0, hidden_lods: int = 0) -> list[str]: +def prefab_warnings(*, hidden_unused: int = 0, removed_unused: int = 0, hidden_lods: int = 0) -> list[str]: """prefab 1 つを読み込んだ後にレポートへ出す警告(呼ぶ側が「prefab : 」を付ける)。""" messages: list[str] = [] + if removed_unused: + messages.append(removed_warning(removed_unused)) if hidden_unused: messages.append(unused_warning(hidden_unused)) if hidden_lods: @@ -132,6 +194,14 @@ def unused_warning(hidden_unused: int) -> str: ) +def removed_warning(removed_unused: int) -> str: + """使われていない FBX の部品を読み込まなかったときの文言(#152)。""" + return ( + f"{removed_unused} object(s) from model files are not used by the Unity prefabs or scene " + "and were not imported (turn off 'Skip Unused Parts' to import them hidden)" + ) + + def lod_warning(hidden_lods: int) -> str: """遠景用 LOD を隠したときの文言(Scenes / Prefabs のどちらからも使う)。""" return ( diff --git a/unitypackage_loader/operators/import_package.py b/unitypackage_loader/operators/import_package.py index 8367d8a..15d5402 100644 --- a/unitypackage_loader/operators/import_package.py +++ b/unitypackage_loader/operators/import_package.py @@ -41,6 +41,12 @@ class IMPORT_SCENE_OT_unitypackage(bpy.types.Operator, ImportHelper): description="Hide the distant LOD levels of Unity LODGroups (scenes and prefabs)", options={"HIDDEN", "SKIP_SAVE"}, ) + skip_unused_parts: BoolProperty( + name="Skip Unused Parts", + default=True, + description="Do not import the parts of a model file that the Unity prefabs or scene do not use (scenes and prefabs)", + options={"HIDDEN", "SKIP_SAVE"}, + ) fbx_importer: EnumProperty( name="FBX Importer", items=( @@ -216,6 +222,7 @@ def make_opts() -> ImportOptions: scene_cameras=self.scene_cameras, scene_exposure=self.scene_exposure, hide_lods=self.hide_lods, + skip_unused_parts=self.skip_unused_parts, material_mode=self.material_mode, force_opaque=self.force_opaque, backface_culling=self.backface_culling, diff --git a/unitypackage_loader/operators/select_models.py b/unitypackage_loader/operators/select_models.py index 4405aa6..5a71138 100644 --- a/unitypackage_loader/operators/select_models.py +++ b/unitypackage_loader/operators/select_models.py @@ -184,6 +184,7 @@ def filter_items(self, context, data, propname): _CAMERAS_PROP = "unitypkg_select_cameras" _EXPOSURE_PROP = "unitypkg_select_exposure" _LODS_PROP = "unitypkg_select_hide_lods" +_UNUSED_PROP = "unitypkg_select_skip_unused" def _items(context): @@ -290,6 +291,10 @@ def draw(self, context): split = layout.split(factor=0.2) split.label(text="LODs") split.prop(wm, _LODS_PROP, toggle=True, icon="MOD_DECIM") + # prefab・シーンが FBX の一部のメッシュだけを使うときに機能する(#152) + split = layout.split(factor=0.2) + split.label(text="Unused Parts") + split.prop(wm, _UNUSED_PROP, toggle=True, icon="TRASH") col = layout.column(align=True) col.label(text=self.summary_materials, icon="MATERIAL") @@ -334,6 +339,7 @@ def _execute(self, context): opts.scene_cameras = getattr(wm, _CAMERAS_PROP) opts.scene_exposure = getattr(wm, _EXPOSURE_PROP) opts.hide_lods = getattr(wm, _LODS_PROP) + opts.skip_unused_parts = getattr(wm, _UNUSED_PROP) opts.scene_paths = opts.prefab_paths = opts.model_guids = None if unit == UNIT_SCENES: opts.scene_paths = [s.pathname for s in prepared.scenes if s.guid in chosen] @@ -431,10 +437,19 @@ def register() -> None: "The hidden objects stay in the file and keep their level in the 'unity_lod' custom property" ), )) + setattr(wm, _UNUSED_PROP, BoolProperty( + name="Skip Unused Parts", + default=True, + description=( + "Do not import the parts of a model file that the Unity prefabs or scene do not use, " + "such as the other pieces of a model file that bundles many props. " + "Turn off to import them hidden, as before" + ), + )) def unregister() -> None: - for prop in (_LODS_PROP, _EXPOSURE_PROP, _CAMERAS_PROP, _LIGHTS_PROP, _ARRANGE_PROP, _UNIT_PROP, _INDEX_PROP, _ITEMS_PROP): + for prop in (_UNUSED_PROP, _LODS_PROP, _EXPOSURE_PROP, _CAMERAS_PROP, _LIGHTS_PROP, _ARRANGE_PROP, _UNIT_PROP, _INDEX_PROP, _ITEMS_PROP): if hasattr(bpy.types.WindowManager, prop): delattr(bpy.types.WindowManager, prop) for cls in reversed(_classes): From 52ea666d255280e3e92ef34cd622a8324ff4da74 Mon Sep 17 00:00:00 2001 From: utagestudio Date: Sun, 25 Oct 2026 19:14:01 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E4=BD=BF=E3=82=8F=E3=82=8C=E3=81=A6?= =?UTF-8?q?=E3=81=84=E3=81=AA=E3=81=84=E9=83=A8=E5=93=81=E3=82=92=E6=B6=88?= =?UTF-8?q?=E3=81=99=E3=81=A8=E3=81=8D=E3=80=81=E5=85=B1=E6=9C=89=E3=83=A1?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E3=81=AE=E5=88=A9=E7=94=A8=E6=95=B0?= =?UTF-8?q?=E3=82=92=E5=BD=93=E3=81=A6=E3=81=AB=E3=81=97=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gothic Lights を読み込む単位 Prefabs で読むと、Skip Unused Parts が ON のとき 「StructRNA of type Object has been removed」で読み込みが止まった。 FBX の読み込み直後は、複数のオブジェクトが共有するメッシュでも users が 1 のことがある。 消したオブジェクトのメッシュを users == 0 で消していたため、共有していたほかのオブジェクトまで Blender がデータと一緒に消し、その参照が無効になっていた。 オブジェクトを先にすべて消し、残したオブジェクトが使っていないデータだけを消すようにした。 Co-Authored-By: Claude Opus 5.5 --- unitypackage_loader/blender/scene_objects.py | 24 +++++++++++--------- unitypackage_loader/blender_manifest.toml | 2 +- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/unitypackage_loader/blender/scene_objects.py b/unitypackage_loader/blender/scene_objects.py index d7b986a..8a00eae 100644 --- a/unitypackage_loader/blender/scene_objects.py +++ b/unitypackage_loader/blender/scene_objects.py @@ -275,19 +275,21 @@ def remove_parts(objects: list[bpy.types.Object], names: set[str]) -> list[bpy.t """``names`` のオブジェクトを消し、残ったオブジェクトを返す(使われていない部品を読み込まないとき。#152)。 消したオブジェクトだけが使っていたメッシュ・アーマチュアのデータも消す(読み込んだばかりで、ほかに使う人はいない)。 + データの ``users`` は当てにしない。FBX の読み込み直後は、複数のオブジェクトが共有するメッシュでも 1 のことがあり、 + それを見て消すと、データと一緒に共有していたほかのオブジェクトまで消える。オブジェクトを先にすべて消し、 + 残したオブジェクトが使っていないデータだけを消す。 """ - kept: list[bpy.types.Object] = [] - for obj in objects: - if obj.name not in names: - kept.append(obj) - continue - data = obj.data + kept = [obj for obj in objects if obj.name not in names] + gone = [obj for obj in objects if obj.name in names] + used = {obj.data for obj in kept if obj.data is not None} + orphans = {obj.data for obj in gone if obj.data is not None} - used + for obj in gone: bpy.data.objects.remove(obj) - if data is not None and data.users == 0: - if isinstance(data, bpy.types.Mesh): - bpy.data.meshes.remove(data) - elif isinstance(data, bpy.types.Armature): - bpy.data.armatures.remove(data) + for data in orphans: + if isinstance(data, bpy.types.Mesh): + bpy.data.meshes.remove(data) + elif isinstance(data, bpy.types.Armature): + bpy.data.armatures.remove(data) return kept diff --git a/unitypackage_loader/blender_manifest.toml b/unitypackage_loader/blender_manifest.toml index ad43a6d..b8de869 100644 --- a/unitypackage_loader/blender_manifest.toml +++ b/unitypackage_loader/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "unitypackage_loader" -version = "1.12.0-dev.2" +version = "1.12.0-dev.3" name = "Unitypackage Importer" tagline = "Import meshes, materials and textures from .unitypackage files" maintainer = "utagestudio" From 3e16af4afc4b62e801f2b262d595698636922ddd Mon Sep 17 00:00:00 2001 From: utagestudio Date: Sun, 25 Oct 2026 19:14:01 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20Prefabs=20=E5=8D=98=E4=BD=8D?= =?UTF-8?q?=E3=81=AE=E4=BD=BF=E3=82=8F=E3=82=8C=E3=81=A6=E3=81=84=E3=81=AA?= =?UTF-8?q?=E3=81=84=E9=83=A8=E5=93=81=E3=81=AE=E5=88=A4=E5=AE=9A=E3=81=A7?= =?UTF-8?q?=E3=80=81=E8=A1=A8=E3=81=AE=E5=90=8D=E5=89=8D=E3=81=AE=E9=80=A3?= =?UTF-8?q?=E7=95=AA=E3=82=82=E5=A4=96=E3=81=97=E3=81=A6=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FBX のノード名がもともと「Base-Ring.001」のように連番付きだと、同じ FBX を 2 回目以降に 読み込んだとき Blender が「Base-Ring.002」に付け替える。#151 の unused_parts はオブジェクト名の 連番だけを外していたので、使っている部品まで使われていないと判定し、OFF では隠し、ON では 消していた(Gothic Lights の Ceiling-Light-001 で燭台とリングの 6 個など)。 Scenes 単位の unused_in_placement と同じく、表の名前の連番も外して比べる。外した名前が同じ別の ノードがあっても、使っている側に倒す(隠したり消したりはしない)。 Gothic Lights の Prefabs で表示されるメッシュが 249 → 278 になり、ON と OFF で一致した。 Co-Authored-By: Claude Opus 5.5 --- tests/test_prefab.py | 6 ++++++ unitypackage_loader/blender_manifest.toml | 2 +- unitypackage_loader/core/prefab.py | 13 ++++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/test_prefab.py b/tests/test_prefab.py index da95a52..a46674b 100644 --- a/tests/test_prefab.py +++ b/tests/test_prefab.py @@ -424,6 +424,12 @@ def test_meshes_missing_from_the_table(self): objects = [("Body", True), ("Body.002", True), ("Door.001", True), ("Window", True), ("Armature", False)] self.assertEqual(unused_parts(table, objects), {"Window"}) # メッシュでないものは対象にしない + def test_numbered_node_names_renamed_by_blender(self): + # FBX のノード名が「Base-Ring.001」で、2 回目の読み込みで Blender が「Base-Ring.002」に付け替えた + table = {"Base-Ring.001": RendererMaterials("Base-Ring.001", [MAT_A])} + objects = [("Base-Ring.002", True), ("Chain-Link.002", True)] + self.assertEqual(unused_parts(table, objects), {"Chain-Link.002"}) + def test_models_placed_whole(self): # Renderer からだけ使う prefab では、FBX の部品は表の分しか Unity に無い h = expand(BASE, {BASE: PREFAB}) diff --git a/unitypackage_loader/blender_manifest.toml b/unitypackage_loader/blender_manifest.toml index b8de869..df444a0 100644 --- a/unitypackage_loader/blender_manifest.toml +++ b/unitypackage_loader/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "unitypackage_loader" -version = "1.12.0-dev.3" +version = "1.12.0-dev.4" name = "Unitypackage Importer" tagline = "Import meshes, materials and textures from .unitypackage files" maintainer = "utagestudio" diff --git a/unitypackage_loader/core/prefab.py b/unitypackage_loader/core/prefab.py index 9d9a975..94bffd7 100644 --- a/unitypackage_loader/core/prefab.py +++ b/unitypackage_loader/core/prefab.py @@ -89,10 +89,17 @@ def models_placed_whole(h: Hierarchy, placed: list[ModelPlacement]) -> set[str]: def unused_parts(table: dict[str, RendererMaterials], objects: Iterable[tuple[str, bool]]) -> set[str]: """表の Renderer が使っていないメッシュのオブジェクト名(読み込む単位 Prefabs で隠すもの。#151)。 - ``objects`` は (Blender のオブジェクト名, メッシュか)。照合は ``find_renderer`` と同じ(名前 → 連番を外した形 → - メッシュ参照の fileID)。FBX を丸ごと置いたモデル(``models_placed_whole``)には使わない。 + ``objects`` は (Blender のオブジェクト名, メッシュか)。照合は ``find_renderer``(名前 → 連番を外した形 → メッシュ参照の + fileID)に加えて、表の名前の連番も外して比べる(Scenes 単位の ``scene_import.unused_in_placement`` と同じ)。 + FBX のノード名がもともと「Base-Ring.001」のように連番付きだと、同じ FBX を 2 回目に読み込んだとき Blender が + 「Base-Ring.002」に付け替えるので、片側だけ外したのでは使っている部品まで隠して(消して)しまう。 + FBX を丸ごと置いたモデル(``models_placed_whole``)には使わない。 """ - return {name for name, is_mesh in objects if is_mesh and find_renderer(table, name) is None} + stripped = {strip_numeric_suffix(n) for n in table} + return { + name for name, is_mesh in objects + if is_mesh and strip_numeric_suffix(name) not in stripped and find_renderer(table, name) is None + } def merge_prefab_tables(tables: list[dict[str, RendererMaterials]]) -> dict[str, RendererMaterials]: From d99ab35b950541fc2009249f6ac05399a66dcc74 Mon Sep 17 00:00:00 2001 From: utagestudio Date: Sun, 25 Oct 2026 19:48:26 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20FBX=20=E3=81=AE=E3=83=8E=E3=83=BC?= =?UTF-8?q?=E3=83=89=E5=90=8D=E3=81=8C=E9=80=A3=E7=95=AA=E4=BB=98=E3=81=8D?= =?UTF-8?q?=E3=81=A7=E3=82=82=E3=80=812=20=E5=9B=9E=E7=9B=AE=E4=BB=A5?= =?UTF-8?q?=E9=99=8D=E3=81=AE=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF=E3=81=A7?= =?UTF-8?q?=20prefab=20=E3=81=AE=E3=83=9E=E3=83=86=E3=83=AA=E3=82=A2?= =?UTF-8?q?=E3=83=AB=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FBX のノード名がもともと「Base-Ring.001」のように連番付きだと、同じ FBX を 2 回目以降に 読み込んだとき Blender が「Base-Ring.002」に付け替える。find_renderer はオブジェクト名の連番を 外して引いていたが、表の名前の側は外していなかったので一致せず、prefab の .mat が当たらずに FBX のマテリアルのままになっていた(Gothic Lights の色違いの吊り下げ灯など。Prefabs 単位で 30 か所、Scenes 単位で 14 か所のスロット)。 - core/prefab.find_renderer: 今までの照合が外れたら、表の名前の連番も外して比べ、該当する行が 1 つだけならそれを使う。複数あればどれか決められないので引かない - core/scene_import.parts_to_hide: LOD の段と無効な Renderer の判定も同じ形で照合する - 合成のシーン用パッケージに、ノード名が連番付きの Rings.fbx と、その Ring.001 だけを指す 色違いの prefab(RingRed / RingPlain)を加え、Prefabs 単位の期待値で 2 つ目にも .mat が 当たることを確かめる(修正前は RingRed が FBX のマテリアルのままで失敗する) Closes #155 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 2 +- .../expectations_synthetic_scene_prefabs.json | 21 +++++++++++- ...s_synthetic_scene_prefabs_keep_unused.json | 19 +++++++++++ tests/make_synthetic_scene_package.py | 32 +++++++++++++++++++ tests/test_prefab.py | 14 ++++++++ tests/test_scene_import.py | 16 ++++++++++ unitypackage_loader/blender_manifest.toml | 2 +- unitypackage_loader/core/prefab.py | 17 +++++++--- unitypackage_loader/core/scene_import.py | 10 +++++- 9 files changed, 125 insertions(+), 8 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 026dbfa..31133b9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -46,7 +46,7 @@ Blender から `.unitypackage` を直接読み込み、メッシュ(アーマ 1〜3 は「FBX マテリアル 1 つに .mat を 1 つ」決める順序。Unity で実際に表示されるのは Renderer の `m_Materials` で、externalObjects は FBX を置いたときの既定値にすぎないので、prefab に割り当てがあれば下記のスロット単位の分割で 1・2 の結果より優先する。 FBX がマテリアルを 1 つも持たない場合は 1〜3 の対象が無いので、prefab の割り当てだけからスロットを作る(下記「マテリアルを持たないモデル」)。 -**prefab とモデルの対応**(`core/prefab.py`、Issue #25): prefab の表はモデルごとに作る。Renderer がどのモデルのものかは、メッシュ参照(MeshRenderer と同じ GameObject の MeshFilter、または SkinnedMeshRenderer の `m_Mesh`)の GUID で決め、メッシュ参照が無い Renderer とパッケージ外のメッシュを指す Renderer は使わない。1 つの prefab が複数モデルを含む場合も Renderer 単位で振り分けるので、別モデルの同名オブジェクトには当てはまらない。読み込む単位 Models では、モデルごとに「そのモデルを使う prefab」だけを候補にし、候補をパス順に先勝ちで統合する(`PreparedPackage.table_for`)。表は `hierarchy.Expander` で展開した prefab の配置(`placements`)から作るので、名前の決め方(Unity の複製番号「 (N)」を外す、.meta の表で引いたメッシュ名を優先する)も Scenes 単位と同じ。名前で引けないとき(prefab で GameObject の名前を変えたもの)は、オブジェクト名から求めたメッシュの fileID を Renderer のメッシュ参照と照合する。FBX を置いただけの PrefabInstance で、中への上書きを 1 つも名前で引けないものは表に入れない。Models 単位の統合は、名前に加えてメッシュ参照の fileID でも先勝ちにする(Prefab Variant で名前を変えた行と、後の prefab の元の名前の行が並んだとき、パス順で先の prefab の割り当てを使うため)(Issue #71)。 +**prefab とモデルの対応**(`core/prefab.py`、Issue #25): prefab の表はモデルごとに作る。Renderer がどのモデルのものかは、メッシュ参照(MeshRenderer と同じ GameObject の MeshFilter、または SkinnedMeshRenderer の `m_Mesh`)の GUID で決め、メッシュ参照が無い Renderer とパッケージ外のメッシュを指す Renderer は使わない。1 つの prefab が複数モデルを含む場合も Renderer 単位で振り分けるので、別モデルの同名オブジェクトには当てはまらない。読み込む単位 Models では、モデルごとに「そのモデルを使う prefab」だけを候補にし、候補をパス順に先勝ちで統合する(`PreparedPackage.table_for`)。表は `hierarchy.Expander` で展開した prefab の配置(`placements`)から作るので、名前の決め方(Unity の複製番号「 (N)」を外す、.meta の表で引いたメッシュ名を優先する)も Scenes 単位と同じ。名前で引けないとき(prefab で GameObject の名前を変えたもの)は、オブジェクト名から求めたメッシュの fileID を Renderer のメッシュ参照と照合する。それでも引けなければ、表の名前の連番も外して比べ、該当する行が 1 つだけならそれを使う(`prefab.find_renderer`。Issue #155)。FBX のノード名がもともと `Base-Ring.001` のように連番付きだと、同じ FBX を 2 回目に読み込んだとき Blender が `Base-Ring.002` に付け替え、オブジェクト名の連番を外しただけでは表の `Base-Ring.001` と一致しないため(以前は 2 回目以降の色違いの prefab で .mat が当たらず、FBX のマテリアルのままだった)。連番を外すと同じになる行が複数あれば(`Body` と `Body.001` が両方ある FBX)、どれか決められないので引かない。Scenes 単位の LOD の段と無効な Renderer の判定(`scene_import.parts_to_hide`)も同じ形で照合し、使われていない部品の判定は、表の名前の連番を外した形が 1 つでも一致すれば使っている側に倒す。FBX を置いただけの PrefabInstance で、中への上書きを 1 つも名前で引けないものは表に入れない。Models 単位の統合は、名前に加えてメッシュ参照の fileID でも先勝ちにする(Prefab Variant で名前を変えた行と、後の prefab の元の名前の行が並んだとき、パス順で先の prefab の割り当てを使うため)(Issue #71)。 **読み込む単位 Prefabs**(§3.3、Issue #47): 選んだ prefab ごとに、その prefab の表だけを当てはめてモデルを読み込む(`ImportOptions.unit` / `prefab_paths`)。同じモデルを使う prefab(色違いなど)を複数選ぶと、prefab ごとにモデルを読み直す。読み直したモデルのマテリアルのうち、組み立て済みの .mat と同じものは新しく組まずに同じ Blender マテリアルを使い回し(下記「マテリアルの共有」)、prefab の表と違うスロットだけを下記の分割で差し替える。読み込めないモデルを含む prefab は、そのモデルだけ飛ばして警告に出す。 diff --git a/tests/expectations_synthetic_scene_prefabs.json b/tests/expectations_synthetic_scene_prefabs.json index 370da6d..2fc2182 100644 --- a/tests/expectations_synthetic_scene_prefabs.json +++ b/tests/expectations_synthetic_scene_prefabs.json @@ -46,9 +46,28 @@ "Unpacked": { "prefab": "Assets/Synthetic/Prefabs/Unpacked.prefab", "objects": 5 + }, + "RingPlain": { + "prefab": "Assets/Synthetic/Prefabs/RingPlain.prefab", + "objects": 1, + "mat_files": [ + "ProbeMat.mat" + ] + }, + "RingRed": { + "prefab": "Assets/Synthetic/Prefabs/RingRed.prefab", + "objects": 1, + "mat_files": [ + "ProbeRed.mat" + ], + "parts": { + "Ring.002": { + "hidden": false + } + } } }, - "warnings_max": 4, + "warnings_max": 6, "warnings_contain": [ "Booth.fbx: 1 material override(s) on an object inside the model are not read yet", "prefab Assets/Synthetic/Prefabs/Partial.prefab: 2 object(s) from model files are not used by the Unity prefabs or scene and were not imported" diff --git a/tests/expectations_synthetic_scene_prefabs_keep_unused.json b/tests/expectations_synthetic_scene_prefabs_keep_unused.json index 3ab2e8e..2ab9058 100644 --- a/tests/expectations_synthetic_scene_prefabs_keep_unused.json +++ b/tests/expectations_synthetic_scene_prefabs_keep_unused.json @@ -24,6 +24,25 @@ "Unpacked": { "prefab": "Assets/Synthetic/Prefabs/Unpacked.prefab", "objects": 5 + }, + "RingPlain": { + "prefab": "Assets/Synthetic/Prefabs/RingPlain.prefab", + "objects": 2, + "mat_files": [ + "ProbeMat.mat" + ] + }, + "RingRed": { + "prefab": "Assets/Synthetic/Prefabs/RingRed.prefab", + "objects": 2, + "mat_files": [ + "ProbeRed.mat" + ], + "parts": { + "Ring.002": { + "hidden": false + } + } } }, "warnings_contain": [ diff --git a/tests/make_synthetic_scene_package.py b/tests/make_synthetic_scene_package.py index 005318f..d850d2b 100644 --- a/tests/make_synthetic_scene_package.py +++ b/tests/make_synthetic_scene_package.py @@ -32,6 +32,10 @@ - BoothRoot: メッシュが 2 つの FBX(Booth.fbx)をそのまま置いた prefab(Booth.prefab)を、位置 (-8, 0, 8) に。 モデルの中の Renderer 1 つへのマテリアルの上書きは、どちらのメッシュか決められないので当てずに警告する(Issue #31) +シーンには置かない prefab: RingRed / RingPlain は、ノード名がもともと連番付きの FBX(Rings.fbx の Ring.001 と Rod.001)の +Ring.001 だけを指す色違い(ProbeRed / ProbeMat)。読み込む単位 Prefabs で 2 つ目を読むと Blender が Ring.002 に付け替えるが、 +それでも prefab のマテリアルを当て、Rod は使われていない部品として扱う(Issue #155)。 + Assets/Synthetic/Scenes/Menu.unity は UI(RectTransform)だけのシーン(候補には読み込めない理由付きで残る)。 """ @@ -132,6 +136,19 @@ def export_lod_fbx(path: Path) -> None: bpy.ops.export_scene.fbx(filepath=str(path), use_selection=False, add_leaf_bones=False, bake_anim=False) +def export_rings_fbx(path: Path) -> None: + """ノード名がもともと連番付き(Ring.001 / Rod.001)の FBX(Issue #155)。""" + bpy.ops.wm.read_factory_settings(use_empty=True) + material = bpy.data.materials.new("RingFbx") + for name, x in (("Ring.001", 0.0), ("Rod.001", 2.0)): + bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, 0, 0)) + obj = bpy.context.active_object + obj.name = name + obj.data.name = name + obj.data.materials.append(material) + bpy.ops.export_scene.fbx(filepath=str(path), use_selection=False, add_leaf_bones=False, bake_anim=False) + + def game_object(file_id, name, active=1): return f"--- !u!1 &{file_id}\nGameObject:\n m_Name: {name}\n m_IsActive: {active}\n" @@ -609,6 +626,21 @@ def unpacked_prefab(name, mat_guid, spike_only=False): + mesh_renderer(7202, 7203, 7200, model, mats["ProbeRed"], 4300000) )).encode(), f"fileFormatVersion: 2\nguid: {guid_of(legacy_path)}\nDefaultImporter:\n externalObjects: {{}}\n") + # ノード名がもともと連番付きの FBX と、その Ring.001 だけを指す色違いの prefab(#155) + rings_fbx = tmp / "rings.fbx" + export_rings_fbx(rings_fbx) + rings_path = "Assets/Synthetic/Models/Rings.fbx" + rings_model = add(rings_path, rings_fbx.read_bytes(), model_meta(guid_of(rings_path), {})) + for label, mat_name in (("RingRed", "ProbeRed"), ("RingPlain", "ProbeMat")): + ring_prefab_path = f"Assets/Synthetic/Prefabs/{label}.prefab" + add(ring_prefab_path, (HEADER + "".join([ + game_object(10, label), + transform(11, 10), + game_object(100, "Ring.001"), + transform(101, 100, father=11), + mesh_renderer(102, 103, 100, rings_model, mats[mat_name], mesh_file_id("Ring.001")), + ])).encode(), prefab_meta(ring_prefab_path)) + menu_path = "Assets/Synthetic/Scenes/Menu.unity" add(menu_path, (HEADER + game_object(10, "Canvas") + transform(11, 10, class_id=224, kind="RectTransform") + "--- !u!223 &12\nCanvas:\n m_GameObject: {fileID: 10}\n").encode(), diff --git a/tests/test_prefab.py b/tests/test_prefab.py index a46674b..4c8750b 100644 --- a/tests/test_prefab.py +++ b/tests/test_prefab.py @@ -410,6 +410,20 @@ def test_lookup_order(self): self.assertEqual(find_renderer(table, "Door.001").materials, [MAT_B]) # メッシュ参照のハッシュ self.assertIsNone(find_renderer(table, "Window")) + def test_numbered_node_names_renamed_by_blender(self): + from unitypackage_loader.core.prefab import find_renderer + + # FBX のノード名が「Base-Ring.001」で、2 回目の読み込みで Blender が「Base-Ring.002」に付け替えた(#155) + table = {"Base-Ring.001": RendererMaterials("Base-Ring.001", [MAT_A]), "Body": RendererMaterials("Body", [MAT_B])} + self.assertEqual(find_renderer(table, "Base-Ring.002").materials, [MAT_A]) + self.assertEqual(find_renderer(table, "Base-Ring.001").materials, [MAT_A]) # 完全一致が先 + # 連番を外すと同じになる行が複数あれば、どれか決められないので引かない + both = {"Body": RendererMaterials("Body", [MAT_A]), "Body.001": RendererMaterials("Body.001", [MAT_B])} + self.assertEqual(find_renderer(both, "Body.001").materials, [MAT_B]) + self.assertEqual(find_renderer(both, "Body.002").materials, [MAT_A]) # 連番を外した「Body」と完全一致 + ambiguous = {"Arm.001": RendererMaterials("Arm.001", [MAT_A]), "Arm.002": RendererMaterials("Arm.002", [MAT_B])} + self.assertIsNone(find_renderer(ambiguous, "Arm.003")) + class UnusedPartsTests(unittest.TestCase): """読み込む単位 Prefabs で、prefab が使っていない FBX の部品を見分ける(#151)。""" diff --git a/tests/test_scene_import.py b/tests/test_scene_import.py index 7c07543..1f7c2b5 100644 --- a/tests/test_scene_import.py +++ b/tests/test_scene_import.py @@ -99,6 +99,22 @@ def test_inactive_placement_is_not_counted_as_lod(self): self.assertEqual(result.lods, {"Bike_LOD0": 0, "Bike_LOD1": 1}) +class NumberedNodeNameTests(unittest.TestCase): + """FBX のノード名がもともと連番付きで、Blender が付け替えたオブジェクト(#155)。""" + + def test_lod_level_and_disabled_renderer(self): + h, placement = placed(renderer("Lamp_LOD1.001", lod=1), renderer("Glass.001", visible=False), renderer("Base.001")) + result = parts_to_hide(h, placement, [("Lamp_LOD1.003", True), ("Glass.002", True), ("Base.002", True)]) + self.assertEqual(result.lods, {"Lamp_LOD1.003": 1, "Glass.002": 0, "Base.002": 0}) # 隠さない LOD0 も入る + self.assertEqual(result.names, {"Lamp_LOD1.003", "Glass.002"}) + self.assertEqual((result.unused, result.hidden_lods), (0, 1)) + + def test_ambiguous_names_are_not_matched(self): + h, placement = placed(renderer("Arm.001", lod=1), renderer("Arm.002", visible=False)) + result = parts_to_hide(h, placement, [("Arm.003", True)]) + self.assertEqual((result.names, result.lods), (set(), {})) + + class UnusedInPlacementTests(unittest.TestCase): def test_same_parts_as_hidden(self): h, placement = placed(renderer("Body"), renderer("RenamedDoor", mesh_id=mesh_file_id("Door"))) diff --git a/unitypackage_loader/blender_manifest.toml b/unitypackage_loader/blender_manifest.toml index df444a0..560c171 100644 --- a/unitypackage_loader/blender_manifest.toml +++ b/unitypackage_loader/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "unitypackage_loader" -version = "1.12.0-dev.4" +version = "1.12.0-dev.5" name = "Unitypackage Importer" tagline = "Import meshes, materials and textures from .unitypackage files" maintainer = "utagestudio" diff --git a/unitypackage_loader/core/prefab.py b/unitypackage_loader/core/prefab.py index 94bffd7..ff766aa 100644 --- a/unitypackage_loader/core/prefab.py +++ b/unitypackage_loader/core/prefab.py @@ -36,16 +36,25 @@ class RendererMaterials: def find_renderer(table: dict[str, RendererMaterials], obj_name: str) -> RendererMaterials | None: - """Blender のオブジェクト名で表を引く(完全一致 → 連番を外した形 → メッシュ参照の fileID のハッシュ)。 + """Blender のオブジェクト名で表を引く(完全一致 → 連番を外した形 → メッシュ参照の fileID のハッシュ → + 表の名前の連番も外した形)。 prefab で GameObject の名前を変えてあると名前では引けないので、オブジェクト名から求めたメッシュの fileID を 表の Renderer のメッシュ参照と照合する(Scenes 単位と同じ。#71)。 + FBX のノード名がもともと「Base-Ring.001」のように連番付きだと、同じ FBX を 2 回目に読み込んだとき Blender が + 「Base-Ring.002」に付け替え、ここまでのどれでも引けない。最後に表の名前の連番も外して比べ、該当する行が + 1 つだけならそれを使う。複数あれば(「Body」と「Body.001」が両方ある FBX など)どれか決められないので引かない(#155)。 """ - rm = table.get(obj_name) or table.get(strip_numeric_suffix(obj_name)) + base = strip_numeric_suffix(obj_name) + rm = table.get(obj_name) or table.get(base) + if rm is not None: + return rm + ids = {mesh_file_id(obj_name), mesh_file_id(base)} + rm = next((r for r in table.values() if r.mesh_file_id and r.mesh_file_id in ids), None) if rm is not None: return rm - ids = {mesh_file_id(obj_name), mesh_file_id(strip_numeric_suffix(obj_name))} - return next((r for r in table.values() if r.mesh_file_id and r.mesh_file_id in ids), None) + candidates = [r for name, r in table.items() if strip_numeric_suffix(name) == base] + return candidates[0] if len(candidates) == 1 else None def tables_from_hierarchy( diff --git a/unitypackage_loader/core/scene_import.py b/unitypackage_loader/core/scene_import.py index f025692..28f7080 100644 --- a/unitypackage_loader/core/scene_import.py +++ b/unitypackage_loader/core/scene_import.py @@ -98,9 +98,15 @@ def parts_to_hide( """ objects = list(objects) unused_names = unused_in_placement(h, placement, objects) - disabled = {name for name, r in placement.renderers.items() if not r.visible} levels = {name: r.lod_level for name, r in placement.renderers.items()} by_mesh_id = {r.mesh_file_id: r.lod_level for r in placement.renderers.values() if r.mesh_file_id} + # 表の名前の連番も外した形(ノード名がもともと連番付きで、Blender が付け替えたとき。同じ形の行が 1 つだけなら使う。 + # prefab.find_renderer と同じ。#155) + by_base: dict[str, list[int]] = {} + for renderer_name, r in placement.renderers.items(): + by_base.setdefault(strip_numeric_suffix(renderer_name), []).append(r.lod_level) + disabled = {name for name, r in placement.renderers.items() if not r.visible} + disabled |= {strip_numeric_suffix(n) for n in disabled if len(by_base[strip_numeric_suffix(n)]) == 1} hide: set[str] = set() lods: dict[str, int] = {} unused_count = lod_count = 0 @@ -110,6 +116,8 @@ def parts_to_hide( level = levels.get(obj_name, levels.get(name)) if level is None and is_mesh: level = by_mesh_id.get(mesh_file_id(obj_name), by_mesh_id.get(mesh_file_id(name))) + if level is None and len(by_base.get(name, [])) == 1: + level = by_base[name][0] if level is not None: lods[obj_name] = level lod_hidden = bool(hide_lods and level) From b2599a335ee6b61ff1e2c1f8f98807c4f7a6304d Mon Sep 17 00:00:00 2001 From: utagestudio Date: Sun, 25 Oct 2026 23:51:39 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20=E3=83=90=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=201.12.0=20=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 読み込む単位 Prefabs で使われていない FBX の部品を隠す修正(#151)、使われていない部品を 読み込まない「Unused Parts」オプション(#152)、連番付きのノード名で prefab のマテリアルが 当たらない不具合の修正(#155)を含む。 Co-Authored-By: Claude Opus 5.5 --- unitypackage_loader/blender_manifest.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unitypackage_loader/blender_manifest.toml b/unitypackage_loader/blender_manifest.toml index 560c171..eb5b8b9 100644 --- a/unitypackage_loader/blender_manifest.toml +++ b/unitypackage_loader/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "unitypackage_loader" -version = "1.12.0-dev.5" +version = "1.12.0" name = "Unitypackage Importer" tagline = "Import meshes, materials and textures from .unitypackage files" maintainer = "utagestudio"