Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: lasa01/Plumber
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.6.9
Choose a base ref
...
head repository: lasa01/Plumber
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref

Commits on Jun 9, 2022

  1. Rewrite

    lasa01 committed Jun 9, 2022
    Copy the full SHA
    4ba1468 View commit details

Commits on Jun 10, 2022

  1. Copy the full SHA
    9725e88 View commit details
  2. Copy the full SHA
    bc09350 View commit details

Commits on Jun 11, 2022

  1. Update plumber_core

    lasa01 committed Jun 11, 2022
    Copy the full SHA
    57df968 View commit details

Commits on Jun 12, 2022

  1. Copy the full SHA
    bcb3e3b View commit details
  2. Update plumber_core

    Fixes #125.
    lasa01 committed Jun 12, 2022
    Copy the full SHA
    a028b3c View commit details
  3. Allow importing vmfs from game files

    Fixes #121.
    lasa01 committed Jun 12, 2022
    Copy the full SHA
    fe61da0 View commit details

Commits on Jun 18, 2022

  1. Copy the full SHA
    5c718ab View commit details
  2. Copy the full SHA
    44ef3df View commit details
  3. Copy the full SHA
    76f015d View commit details
  4. Add file browser to sidebar, UI improvements

    Fixes #126.
    lasa01 committed Jun 18, 2022
    Copy the full SHA
    41a32cb View commit details

Commits on Jun 21, 2022

  1. Copy the full SHA
    29b8f33 View commit details
  2. Copy the full SHA
    9e92450 View commit details

Commits on Jun 24, 2022

  1. Update plumber_core

    lasa01 committed Jun 24, 2022
    Copy the full SHA
    7687bd4 View commit details
  2. Bump version

    lasa01 committed Jun 24, 2022
    Copy the full SHA
    8f04a45 View commit details

Commits on Jun 26, 2022

  1. Add scale setting to mdl importer

    Fixes #129.
    lasa01 committed Jun 26, 2022
    Copy the full SHA
    dd2d567 View commit details
  2. Enable presets for importers

    lasa01 committed Jun 26, 2022
    Copy the full SHA
    2dc5b1c View commit details

Commits on Jul 2, 2022

  1. Add vtf importer

    Fixes #130.
    lasa01 committed Jul 2, 2022
    Copy the full SHA
    27fd295 View commit details
  2. Update plumber_core

    lasa01 committed Jul 2, 2022
    Copy the full SHA
    5c61fb3 View commit details
  3. Copy the full SHA
    ea41e00 View commit details
  4. Update plumber_core

    lasa01 committed Jul 2, 2022
    Copy the full SHA
    abb5c02 View commit details

Commits on Jul 9, 2022

  1. Update plumber_core

    lasa01 committed Jul 9, 2022
    Copy the full SHA
    73365bb View commit details
  2. Copy the full SHA
    1b9eb8a View commit details
  3. Copy the full SHA
    0343ad4 View commit details

Commits on Jul 10, 2022

  1. Copy the full SHA
    a37ef39 View commit details
  2. Refactor importer.rs

    lasa01 committed Jul 10, 2022
    Copy the full SHA
    a19073b View commit details
  3. Copy the full SHA
    f7600a0 View commit details

Commits on Jul 13, 2022

  1. Update plumber_core

    Show error in console when animation cannot be imported due to being stored in .ani file.
    lasa01 committed Jul 13, 2022
    Copy the full SHA
    6ddb7a0 View commit details

Commits on Jul 23, 2022

  1. Add support for detecting local search paths

    Allows importing related assets such as textures for materials from os file system when they are placed in a Source-like directory structure.
    lasa01 committed Jul 23, 2022
    Copy the full SHA
    f18746c View commit details

Commits on Jul 24, 2022

  1. Bump version

    lasa01 committed Jul 24, 2022
    Copy the full SHA
    3706757 View commit details

Commits on Jul 29, 2022

  1. Add detect from gameinfo button

    Fixes #133.
    lasa01 committed Jul 29, 2022
    Copy the full SHA
    814633b View commit details

Commits on Jul 31, 2022

  1. Update README for Plumber 1.0

    lasa01 committed Jul 31, 2022
    Copy the full SHA
    130b863 View commit details
  2. Copy the full SHA
    0439943 View commit details

Commits on Aug 6, 2022

  1. Update plumber_core

    Adds support for Vindictus props
    lasa01 committed Aug 6, 2022
    Copy the full SHA
    c3b2abb View commit details
  2. Copy the full SHA
    9d8dc9c View commit details

Commits on Oct 1, 2022

  1. Update README.md

    lasa01 authored Oct 1, 2022
    Copy the full SHA
    d59a5fa View commit details

Commits on Oct 27, 2022

  1. Copy the full SHA
    8edb668 View commit details

Commits on Nov 11, 2022

  1. Update plumber_core

    Fixes issues with paths sometimes having too many slashes
    lasa01 committed Nov 11, 2022
    Copy the full SHA
    80718ee View commit details

Commits on Dec 25, 2022

  1. Update plumber_core

    lasa01 committed Dec 25, 2022
    Copy the full SHA
    8d35a9f View commit details
  2. Fix new clippy lints

    lasa01 committed Dec 25, 2022
    Copy the full SHA
    cc14fb6 View commit details

Commits on Dec 26, 2022

  1. Add game file browser name filtering

    Suggested in #140.
    lasa01 committed Dec 26, 2022
    Copy the full SHA
    1d83397 View commit details
  2. Normalize inputted paths in game file browser

    Fixes file browsing with last slash in the path.
    Suggested in #140.
    lasa01 committed Dec 26, 2022
    Copy the full SHA
    524fe03 View commit details
  3. Call importer when full path is entered in browser

    Suggested in #140.
    lasa01 committed Dec 26, 2022
    Copy the full SHA
    47f68bc View commit details
  4. Implement recent directories for browser

    Suggested in #140.
    lasa01 committed Dec 26, 2022
    Copy the full SHA
    0f5ba7a View commit details

Commits on Dec 28, 2022

  1. Update plumber_core

    Adds support for hidden in vmf parser, fixes #143.
    lasa01 committed Dec 28, 2022
    Copy the full SHA
    6728fd9 View commit details

Commits on Jan 6, 2023

  1. Update plumber_core

    Adds core support for 4wayblend blend data.
    lasa01 committed Jan 6, 2023
    Copy the full SHA
    9e6e3bd View commit details
  2. Fix new clippy lints

    lasa01 committed Jan 6, 2023
    Copy the full SHA
    0a2bca0 View commit details

Commits on Jan 9, 2023

  1. Add support for 4WayBlend materials

    Fixes #128.
    lasa01 committed Jan 9, 2023
    Copy the full SHA
    91c670a View commit details
  2. Bump version

    lasa01 committed Jan 9, 2023
    Copy the full SHA
    9499e76 View commit details

Commits on Sep 3, 2023

  1. Update plumber_core

    Fixes possible invalid geometry in overlays.
    lasa01 committed Sep 3, 2023
    Copy the full SHA
    57a3282 View commit details
Showing with 13,375 additions and 5,327 deletions.
  1. +2 −2 .github/ISSUE_TEMPLATE/bug_report.md
  2. +62 −0 .github/workflows/build.yml
  3. +116 −0 .github/workflows/release.yml
  4. +4 −80 .gitignore
  5. +0 −9 .gitmodules
  6. +17 −0 .vscode/launch.json
  7. +1 −14 .vscode/settings.json
  8. +31 −0 .vscode/tasks.json
  9. +1,420 −0 Cargo.lock
  10. +48 −0 Cargo.toml
  11. +674 −21 LICENSE
  12. +176 −97 README.md
  13. +0 −1 autocomplete/BlenderSourceTools
  14. +0 −1 autocomplete/SourceIO
  15. +0 −1 autocomplete/afx-blender-scripts
  16. BIN img/configure_addon.gif
  17. BIN img/import_dialog.png
  18. BIN img/install_addon.gif
  19. +0 −6 install.ps1
  20. +0 −6 install.sh
  21. +0 −1,580 io_import_vmf/__init__.py
  22. +0 −134 io_import_vmf/cube2equi.py
  23. +0 −132 io_import_vmf/import_agr.py
  24. +0 −102 io_import_vmf/import_mdl.py
  25. +0 −432 io_import_vmf/import_qc.py
  26. +0 −1,107 io_import_vmf/import_vmf.py
  27. +0 −1,284 io_import_vmf/import_vmt.py
  28. +0 −180 io_import_vmf/import_vtf.py
  29. +0 −93 io_import_vmf/utils.py
  30. +0 −3 mypy.ini
  31. +0 −31 pack.py
  32. +48 −0 plumber/__init__.py
  33. +51 −0 plumber/addon.py
  34. +97 −0 plumber/asset/__init__.py
  35. +94 −0 plumber/asset/brush.py
  36. +74 −0 plumber/asset/light.py
  37. +99 −0 plumber/asset/material.py
  38. +369 −0 plumber/asset/model.py
  39. +34 −0 plumber/asset/overlay.py
  40. +87 −0 plumber/asset/prop.py
  41. +18 −0 plumber/asset/sky_camera.py
  42. +39 −0 plumber/asset/sky_equi.py
  43. +16 −0 plumber/asset/unknown_entity.py
  44. +50 −0 plumber/asset/utils.py
  45. +122 −0 plumber/benchmark.py
  46. +19 −0 plumber/blender_manifest.toml
  47. +277 −0 plumber/importer/__init__.py
  48. +108 −0 plumber/importer/mdl.py
  49. +497 −0 plumber/importer/vmf.py
  50. +84 −0 plumber/importer/vmt.py
  51. +53 −0 plumber/importer/vtf.py
  52. +243 −0 plumber/plumber.pyi
  53. +524 −0 plumber/preferences.py
  54. +635 −0 plumber/tools.py
  55. +2 −0 pyproject.toml
  56. +5 −7 requirements-dev.txt
  57. +0 −2 requirements.txt
  58. +23 −0 setup.py
  59. +25 −0 setup_trace.py
  60. +297 −0 src/asset/brush.rs
  61. +487 −0 src/asset/entities.rs
  62. +1,547 −0 src/asset/material/builder.rs
  63. +755 −0 src/asset/material/builder_base.rs
  64. +1,221 −0 src/asset/material/definitions.rs
  65. +169 −0 src/asset/material/mod.rs
  66. +510 −0 src/asset/material/nodes.rs
  67. +284 −0 src/asset/mod.rs
  68. +578 −0 src/asset/model.rs
  69. +111 −0 src/asset/overlay.rs
  70. +254 −0 src/asset/sky.rs
  71. +15 −0 src/asset/utils.rs
  72. +316 −0 src/filesystem.rs
  73. +441 −0 src/importer.rs
  74. +146 −0 src/lib.rs
  75. +0 −2 tox.ini
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -33,8 +33,8 @@ If applicable, add screenshots to help explain your problem.

**Details (please complete the following information):**
- OS: [e.g. Windows]
- Blender Version: [e.g. 2.91]
- Addon Version: [e.g. 0.6.0]
- Blender Version: [e.g. 4.3]
- Addon Version: [e.g. 1.1.2]
- Did you download a release or build the addon yourself: [e.g. downloaded a release]
- Related Game: [e.g. CS:GO]

62 changes: 62 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Build

on:
push:
paths:
- "*.rs"
- "Cargo.lock"
- "Cargo.toml"
- "setup.py"
branches:
- "**"
pull_request:
paths:
- "*.rs"
- "Cargo.lock"
- "Cargo.toml"
- "setup.py"
workflow_dispatch:

env:
CARGO_TERM_COLOR: always

jobs:
build:
strategy:
matrix:
os: [macos-13, macos-latest, windows-latest]
fail-fast: false
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: 'requirements-dev.txt'
- name: Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Build
run: python setup.py build_rust --inplace

build-manylinux:
runs-on: ubuntu-latest
container: quay.io/pypa/manylinux_2_28_x86_64

steps:
- uses: actions/checkout@v4
- name: Select Python version
run: echo "/opt/python/cp312-cp312/bin" >> $GITHUB_PATH
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
- name: Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Build
run: python setup.py build_rust --inplace
116 changes: 116 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: Release

on:
push:
tags:
- "v*.*.*"

env:
CARGO_TERM_COLOR: always

jobs:
check-version:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: 'requirements-dev.txt'
- name: Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Check version
run: |
[[ $(python setup.py --version) = ${GITHUB_REF/refs\/tags\/v/} ]] || exit 1
build:
strategy:
matrix:
os: [macos-13, macos-latest, windows-latest]
include:
- os: macos-13
blender-install: |
brew update
brew install --cask blender
filename-match: "dist/*-macos_x64.zip"
- os: macos-latest
blender-install: |
brew update
brew install --cask blender
filename-match: "dist/*-macos_arm64.zip"
- os: windows-latest
blender-install: |
choco install blender --version=4.2.2
echo "C:\Program Files\Blender Foundation\Blender 4.2\" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
filename-match: "dist/*-windows_x64.zip"
fail-fast: false
runs-on: ${{ matrix.os }}

needs: check-version

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: 'requirements-dev.txt'
- name: Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Build
run: python setup.py build_rust --inplace --release
- name: Install Blender
run: ${{ matrix.blender-install }}
- name: Package addon into Blender extension
run: |
mkdir -p dist
blender --command extension build --source-dir ./plumber --output-dir ./dist --split-platforms
- name: Release
uses: softprops/action-gh-release@v2
with:
draft: true
fail_on_unmatched_files: true
files: ${{ matrix.filename-match }}

build-manylinux:
runs-on: ubuntu-latest
container: quay.io/pypa/manylinux_2_28_x86_64

needs: check-version

steps:
- uses: actions/checkout@v4
- name: Select Python version
run: echo "/opt/python/cp312-cp312/bin" >> $GITHUB_PATH
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
- name: Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Build
run: python setup.py build_rust --inplace --release
- name: Install Blender
run: |
dnf install -y wget libXi
wget https://download.blender.org/release/Blender4.2/blender-4.2.3-linux-x64.tar.xz
tar -xf blender-4.2.3-linux-x64.tar.xz
echo "$PWD/blender-4.2.3-linux-x64" >> $GITHUB_PATH
- name: Package addon into Blender extension
run: |
mkdir -p dist
blender --command extension build --source-dir ./plumber --output-dir ./dist --split-platforms
- name: Release
uses: softprops/action-gh-release@v2
with:
draft: true
fail_on_unmatched_files: true
files: "dist/*-linux_x64.zip"
84 changes: 4 additions & 80 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# rust
/target

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -20,19 +23,12 @@ parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt
@@ -50,56 +46,7 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py
cover/

# Environments
.env
@@ -109,26 +56,3 @@ venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# Addon dependencies and packed addon
io_import_vmf/deps
io_import_vmf/bin
io_import_vmf*.zip
9 changes: 0 additions & 9 deletions .gitmodules

This file was deleted.

17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "attach",
"name": "Debug native code",
"program": "blender.exe",
"sourceLanguages": [
"rust"
]
}
]
}
15 changes: 1 addition & 14 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,3 @@
{
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.linting.pylintEnabled": false,
"python.defaultInterpreterPath": "venv\\Scripts\\python.exe",
"python.analysis.extraPaths": [
"autocomplete",
"autocomplete/afx-blender-scripts",
"autocomplete/BlenderSourceTools"
],
"python.linting.ignorePatterns": [
".vscode/*.py",
"**/site-packages/**/*.py",
"autocomplete/**/*"
]
"python.formatting.provider": "black",
}
31 changes: 31 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Extension module",
"type": "shell",
"command": "${command:python.interpreterPath} setup.py build_rust --inplace ${input:buildTarget}",
"group": "build"
},
{
"label": "Extension module with tracing",
"type": "shell",
"command": "${command:python.interpreterPath} setup_trace.py build_rust --inplace",
"group": "build"
},
],
"inputs": [
{
"id": "buildTarget",
"type": "pickString",
"description": "Build target",
"options": [
"--debug",
"--release"
],
"default": "--release",
}
]
}
1,420 changes: 1,420 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
[package]
name = "plumber"
version = "1.1.2"
authors = ["Lassi Säike"]
edition = "2021"

[lib]
name = "plumber"
crate-type = ["cdylib"]

[dependencies]
plumber_core = { git = "https://github.com/lasa01/plumber_core" }
pyo3 = { version = "0.15.1", features = [
"extension-module",
"abi3",
"abi3-py36",
] }
crossbeam-channel = "0.5.1"
ndarray = "0.15.1"
itertools = "0.10.1"
image = { version = "0.24.6", default-features = false, features = [
"tga",
"openexr",
"png",
] }
glam = "0.20.2"
tracing = { version = "0.1.37", features = ["max_level_debug"] }
rgb = "0.8.31"
float-ord = "0.3.2"
tracing-subscriber = "0.3.17"
tracing-tracy = { version = "0.10.2", optional = true }

[patch.crates-io]
serde = { git = "https://github.com/lasa01/serde", branch = "case-insensitive-attr" }
serde_derive = { git = "https://github.com/lasa01/serde", branch = "case-insensitive-attr" }

[profile.release]
strip = "debuginfo"

[profile.trace]
inherits = "release"
debug = true
strip = "none"

[features]
default = ["normal_logging"]
normal_logging = ["tracing/release_max_level_info"]
trace = ["tracing-tracy", "tracing/release_max_level_debug"]
695 changes: 674 additions & 21 deletions LICENSE

Large diffs are not rendered by default.

273 changes: 176 additions & 97 deletions README.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion autocomplete/BlenderSourceTools
Submodule BlenderSourceTools deleted from 57adb2
1 change: 0 additions & 1 deletion autocomplete/SourceIO
Submodule SourceIO deleted from 5718e5
1 change: 0 additions & 1 deletion autocomplete/afx-blender-scripts
Submodule afx-blender-scripts deleted from 12bb5d
Binary file removed img/configure_addon.gif
Binary file not shown.
Binary file modified img/import_dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/install_addon.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 0 additions & 6 deletions install.ps1

This file was deleted.

6 changes: 0 additions & 6 deletions install.sh

This file was deleted.

1,580 changes: 0 additions & 1,580 deletions io_import_vmf/__init__.py

This file was deleted.

134 changes: 0 additions & 134 deletions io_import_vmf/cube2equi.py

This file was deleted.

132 changes: 0 additions & 132 deletions io_import_vmf/import_agr.py

This file was deleted.

102 changes: 0 additions & 102 deletions io_import_vmf/import_mdl.py

This file was deleted.

432 changes: 0 additions & 432 deletions io_import_vmf/import_qc.py

This file was deleted.

1,107 changes: 0 additions & 1,107 deletions io_import_vmf/import_vmf.py

This file was deleted.

1,284 changes: 0 additions & 1,284 deletions io_import_vmf/import_vmt.py

This file was deleted.

180 changes: 0 additions & 180 deletions io_import_vmf/import_vtf.py

This file was deleted.

93 changes: 0 additions & 93 deletions io_import_vmf/utils.py

This file was deleted.

3 changes: 0 additions & 3 deletions mypy.ini

This file was deleted.

31 changes: 0 additions & 31 deletions pack.py

This file was deleted.

48 changes: 48 additions & 0 deletions plumber/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import platform
import os

is_windows = platform.system() == "Windows"


def register():
if is_windows:
# Check if the extension module was renamed on the last unregister,
# and either rename it back or delete it if the addon was updated with a newer extension module
ext_path = os.path.join(os.path.dirname(__file__), "plumber.pyd")
unloaded_ext_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "plumber.pyd.unloaded"
)

if os.path.isfile(unloaded_ext_path):
if os.path.isfile(ext_path):
try:
os.remove(unloaded_ext_path)
except OSError:
print(
"[Plumber] [WARN] old files remaining, restart Blender to finish post-update clean up"
)
else:
os.rename(unloaded_ext_path, ext_path)

from . import addon

addon.register()


def unregister():
from . import addon

addon.unregister()

if is_windows:
# Rename the extension module to allow updating the addon without restarting Blender,
# since the extension module will stay open and can't be overwritten even if the addon is unloaded
ext_path = os.path.join(os.path.dirname(__file__), "plumber.pyd")
unloaded_ext_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "plumber.pyd.unloaded"
)

try:
os.rename(ext_path, unloaded_ext_path)
except OSError:
pass
51 changes: 51 additions & 0 deletions plumber/addon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import bpy
from bpy.types import Context, Menu

from . import preferences, importer, tools, benchmark
from .importer import ImportMdl, ImportVmf, ImportVmt, ImportVtf
from .tools import IMPORT_MT_plumber_browse


class IMPORT_MT_plumber(Menu):
bl_idname = "IMPORT_MT_plumber"
bl_label = "Plumber"

def draw(self, context: Context):
self.layout.operator(
ImportVmf.bl_idname, text="Valve Map Format (.vmf)"
).from_game_fs = False
self.layout.operator(
ImportMdl.bl_idname, text="Source Model (.mdl)"
).from_game_fs = False
self.layout.operator(
ImportVmt.bl_idname, text="Valve Material Type (.vmt)"
).from_game_fs = False
self.layout.operator(
ImportVtf.bl_idname, text="Valve Texture Format (.vtf)"
).from_game_fs = False

self.layout.menu(IMPORT_MT_plumber_browse.bl_idname)


def menu_func_import(self: Menu, context: Context):
self.layout.menu(IMPORT_MT_plumber.bl_idname)


def register():
preferences.register()
importer.register()
tools.register()
benchmark.register()

bpy.utils.register_class(IMPORT_MT_plumber)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)


def unregister():
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
bpy.utils.unregister_class(IMPORT_MT_plumber)

benchmark.unregister()
tools.unregister()
importer.unregister()
preferences.unregister()
97 changes: 97 additions & 0 deletions plumber/asset/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from typing import Optional
from bpy.types import Context, Collection

from ..plumber import (
BuiltBrushEntity,
BuiltOverlay,
LoadedProp,
Material,
Model,
Light,
EnvLight,
SkyCamera,
SpotLight,
SkyEqui,
Texture,
UnknownEntity,
)
from .material import import_material, import_texture
from .model import ModelTracker
from .brush import import_brush
from .overlay import import_overlay
from .prop import apply_armatures, import_prop
from .light import import_light, import_spot_light, import_env_light
from .sky_camera import import_sky_camera
from .sky_equi import import_sky_equi
from .unknown_entity import import_unknown_entity


class AssetCallbacks:
def __init__(
self,
context: Context,
main_collection: Optional[Collection] = None,
brush_collection: Optional[Collection] = None,
overlay_collection: Optional[Collection] = None,
prop_collection: Optional[Collection] = None,
light_collection: Optional[Collection] = None,
entity_collection: Optional[Collection] = None,
apply_armatures: bool = False,
) -> None:
self.context = context
self.model_tracker = ModelTracker()
self.armatures_to_apply = []

self.main_collection = main_collection or context.collection
self.brush_collection = brush_collection or self.main_collection
self.overlay_collection = overlay_collection or self.main_collection
self.prop_collection = prop_collection or self.main_collection
self.light_collection = light_collection or self.main_collection
self.entity_collection = entity_collection or self.main_collection

self.apply_armatures = apply_armatures

def material(self, material: Material) -> None:
import_material(material)

def texture(self, texture: Texture) -> None:
import_texture(texture)

def model(self, model: Model) -> None:
self.model_tracker.import_model(model, self.prop_collection)

def brush(self, brush: BuiltBrushEntity) -> None:
import_brush(brush, self.brush_collection)

def overlay(self, overlay: BuiltOverlay) -> None:
import_overlay(overlay, self.overlay_collection)

def prop(self, prop: LoadedProp) -> None:
import_prop(
prop,
self.prop_collection,
self.model_tracker,
self.apply_armatures,
self.armatures_to_apply,
)

def light(self, light: Light) -> None:
import_light(light, self.light_collection)

def spot_light(self, light: SpotLight) -> None:
import_spot_light(light, self.light_collection)

def env_light(self, light: EnvLight) -> None:
import_env_light(light, self.context, self.light_collection)

def sky_camera(self, sky_camera: SkyCamera) -> None:
import_sky_camera(sky_camera, self.context, self.main_collection)

def sky_equi(self, sky_equi: SkyEqui) -> None:
import_sky_equi(sky_equi, self.context)

def unknown_entity(self, entity: UnknownEntity) -> None:
import_unknown_entity(entity, self.entity_collection)

def finish(self) -> None:
apply_armatures(self.armatures_to_apply)
94 changes: 94 additions & 0 deletions plumber/asset/brush.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import bpy
from bpy.types import Collection

from .utils import truncate_name
from ..plumber import BuiltBrushEntity, BuiltSolid, MergedSolids


def import_brush(brush: BuiltBrushEntity, collection: Collection) -> None:
id = brush.id()
class_name = brush.class_name()
brush_name = f"{class_name}_{id}"

merged_solids = brush.merged_solids()
if merged_solids is not None:
import_merged_solids(collection, brush_name, merged_solids)

for solid in brush.solids():
import_solid(collection, brush_name, solid)


def import_solid(collection: Collection, brush_name: str, solid: BuiltSolid) -> None:
id = solid.id()
solid_name = f"{brush_name}_{id}"
mesh = bpy.data.meshes.new(solid_name)

vertices = solid.vertices()
mesh.vertices.add(len(vertices) // 3)
mesh.loops.add(solid.loops_len())
mesh.polygons.add(solid.polygons_len())
mesh.vertices.foreach_set("co", vertices)
mesh.polygons.foreach_set("loop_total", solid.polygon_loop_totals())
mesh.polygons.foreach_set("loop_start", solid.polygon_loop_starts())
mesh.polygons.foreach_set("vertices", solid.polygon_vertices())
mesh.polygons.foreach_set("material_index", solid.polygon_material_indices())

mesh.shade_flat()

mesh.update()

uv_layer = mesh.uv_layers.new()
uv_layer.data.foreach_set("uv", solid.loop_uvs())

color_layer = mesh.vertex_colors.new(name="Col", do_init=False)
color_layer.data.foreach_set("color", solid.loop_colors())

for material in solid.materials():
material_data = bpy.data.materials.get(truncate_name(material))
if material_data is None:
material_data = bpy.data.materials.new(material)
mesh.materials.append(material_data)

obj = bpy.data.objects.new(solid_name, object_data=mesh)
obj.location = solid.position()
obj.scale = solid.scale()
collection.objects.link(obj)


def import_merged_solids(
collection: Collection, brush_name: str, merged_solids: MergedSolids
) -> None:
mesh = bpy.data.meshes.new(brush_name)

vertices = merged_solids.vertices()
mesh.vertices.add(len(vertices) // 3)
mesh.loops.add(merged_solids.loops_len())
mesh.polygons.add(merged_solids.polygons_len())
mesh.vertices.foreach_set("co", vertices)
mesh.polygons.foreach_set("loop_total", merged_solids.polygon_loop_totals())
mesh.polygons.foreach_set("loop_start", merged_solids.polygon_loop_starts())
mesh.polygons.foreach_set("vertices", merged_solids.polygon_vertices())
mesh.polygons.foreach_set(
"material_index", merged_solids.polygon_material_indices()
)

mesh.shade_flat()

mesh.update()

uv_layer = mesh.uv_layers.new()
uv_layer.data.foreach_set("uv", merged_solids.loop_uvs())

color_layer = mesh.vertex_colors.new(name="Col", do_init=False)
color_layer.data.foreach_set("color", merged_solids.loop_colors())

for material in merged_solids.materials():
material_data = bpy.data.materials.get(truncate_name(material))
if material_data is None:
material_data = bpy.data.materials.new(material)
mesh.materials.append(material_data)

obj = bpy.data.objects.new(brush_name, object_data=mesh)
obj.location = merged_solids.position()
obj.scale = merged_solids.scale()
collection.objects.link(obj)
74 changes: 74 additions & 0 deletions plumber/asset/light.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import bpy
from bpy.types import Context, Collection

from ..plumber import Light, SpotLight, EnvLight


def import_light(light: Light, collection: Collection) -> None:
name = f"light_{light.id()}"

light_data = bpy.data.lights.new(name, "POINT")
light_data.cycles.use_multiple_importance_sampling = False
light_data.color = light.color()
light_data.energy = light.energy()

obj = bpy.data.objects.new(name, object_data=light_data)
collection.objects.link(obj)

obj.location = light.position()
obj["props"] = light.properties()


def import_spot_light(light: SpotLight, collection: Collection) -> None:
name = f"light_spot_{light.id()}"

light_data = bpy.data.lights.new(name, "SPOT")
light_data.cycles.use_multiple_importance_sampling = False
light_data.color = light.color()
light_data.energy = light.energy()
light_data.spot_size = light.spot_size()
light_data.spot_blend = light.spot_blend()

obj = bpy.data.objects.new(name, object_data=light_data)
collection.objects.link(obj)

obj.location = light.position()
obj.rotation_euler = light.rotation()
obj["props"] = light.properties()


def import_env_light(light: EnvLight, context: Context, collection: Collection) -> None:
name = f"light_environment_{light.id()}"

light_data = bpy.data.lights.new(name, "SUN")
light_data.cycles.use_multiple_importance_sampling = True
light_data.color = light.sun_color()
light_data.energy = light.sun_energy()
light_data.angle = light.angle()

obj = bpy.data.objects.new(name, object_data=light_data)
collection.objects.link(obj)
obj["props"] = light.properties()

obj.location = light.position()
obj.rotation_euler = light.rotation()

if context.scene.world is None:
context.scene.world = bpy.data.worlds.new("World")

context.scene.world.use_nodes = True
nt = context.scene.world.node_tree
if nt.nodes:
# don't override imported skybox or a previous material with this
return

out_node: bpy.types.Node = nt.nodes.new("ShaderNodeOutputWorld")
out_node.location = (0, 0)

bg_node: bpy.types.Node = nt.nodes.new("ShaderNodeBackground")
bg_node.location = (-300, 0)

nt.links.new(bg_node.outputs["Background"], out_node.inputs["Surface"])

bg_node.inputs["Color"].default_value = light.ambient_color()
bg_node.inputs["Strength"].default_value = light.ambient_strength()
99 changes: 99 additions & 0 deletions plumber/asset/material.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from typing import List

import bpy
from bpy.types import ShaderNode

from .utils import truncate_name
from ..plumber import Material, Texture, TextureRef


FORMAT_MAP = {
".tga": "TARGA_RAW",
".png": "PNG",
}

NODE_INPUT_SOCKET_MAP = {
"Specular": "Specular IOR Level",
"Emission": "Emission Color",
}


def import_texture(texture: Texture) -> None:
format_ext = texture.format_ext()
texture_name = truncate_name(texture.name() + format_ext)

image_data = bpy.data.images.get(texture_name)
if image_data is None:
width = texture.width()
height = texture.height()
image_data = bpy.data.images.new(texture_name, width, height, alpha=True)
image_data.file_format = FORMAT_MAP[format_ext]
image_data.source = "FILE"
bytes = texture.bytes()
image_data.pack(data=bytes, data_len=len(bytes))
image_data.alpha_mode = "CHANNEL_PACKED"


def import_material(material: Material) -> None:
material_name = truncate_name(material.name())

material_data = bpy.data.materials.get(material_name)
if material_data is None:
material_data = bpy.data.materials.new(material_name)
material_data["path_id"] = material.name()

material_data.use_nodes = True
nt = material_data.node_tree
nt.nodes.clear()

out_node = nt.nodes.new("ShaderNodeOutputMaterial")
out_node.location = (300, 0)

built_data = material.data()
texture_ext = material.texture_ext()

for property, value in built_data.properties().items():
setattr(material_data, property, resolve_value(value, texture_ext))

built_nodes: List[ShaderNode] = []

for node in built_data.nodes():
built_node = nt.nodes.new(node.blender_id())
built_node.location = node.position()

for property, value in node.properties().items():
setattr(built_node, property, resolve_value(value, texture_ext))

for socket, value in node.socket_values().items():
socket = resolve_input_socket(socket)
built_node.inputs[socket].default_value = resolve_value(value, texture_ext)

for socket, link in node.socket_links().items():
target_node: ShaderNode = built_nodes[link.node_index()]
target_socket = target_node.outputs[link.socket()]

socket = resolve_input_socket(socket)
nt.links.new(built_node.inputs[socket], target_socket)

built_nodes.append(built_node)

shader_node = built_nodes[-1]

nt.links.new(shader_node.outputs["BSDF"], out_node.inputs["Surface"])

for texture_name, color_space in built_data.texture_color_spaces().items():
image_name = truncate_name(texture_name + texture_ext)
image = bpy.data.images[image_name]
image.colorspace_settings.name = color_space


def resolve_value(value, texture_ext: str):
if isinstance(value, TextureRef):
texture_name = truncate_name(value.path() + texture_ext)
return bpy.data.images.get(texture_name)

return value


def resolve_input_socket(socket: str):
return NODE_INPUT_SOCKET_MAP.get(socket, socket)
369 changes: 369 additions & 0 deletions plumber/asset/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
from typing import Dict, List, Optional

import bpy
from bpy.types import (
Action,
Armature,
ArmatureModifier,
Bone,
Collection,
Object,
Material,
)
from mathutils import Euler, Vector, Matrix

from .utils import find_armature_modifier, get_unknown_material, truncate_name
from ..plumber import (
BoneRestData,
LoadedAnimation,
LoadedBone,
LoadedMesh,
Model,
QuaternionData,
VectorData,
)


class ModelState:
def __init__(
self, model_obj: Object, children: List[Object], collection: Collection
) -> None:
self.object = model_obj
self.children = children
self.used = False
self.collection = collection


class ModelTracker:
imported_objects: Dict[str, ModelState]

def __init__(self) -> None:
self.imported_objects = {}

def import_model(self, model: Model, collection: Collection) -> None:
original_name = model.name()
model_name = truncate_name(original_name)

parent_obj = None
children = []

bones = model.bones()
if bones:
rest_positions = model.rest_positions()
bone_names = []
parent_obj = import_armature(
collection, model_name, bones, rest_positions, bone_names
)

animations = model.animations()
for animation in animations:
import_animation(parent_obj, bone_names, animation)

bl_materials = []
for material in model.materials():
if material is None:
material_data = get_unknown_material()
else:
material_original_name = material
material = truncate_name(material)
material_data = bpy.data.materials.get(material)
if material_data is None:
material_data = bpy.data.materials.new(material)
material_data["path_id"] = material_original_name
bl_materials.append(material_data)

meshes = model.meshes()

if len(meshes) > 1 and parent_obj is None:
parent_obj = bpy.data.objects.new(model_name, object_data=None)
collection.objects.link(parent_obj)

for mesh in meshes:
mesh_obj = import_mesh(
collection, model_name, bl_materials, mesh, bones if bones else None
)
if parent_obj is not None:
mesh_obj.parent = parent_obj
if parent_obj.type == "ARMATURE":
armature_mod: ArmatureModifier = mesh_obj.modifiers.new(
"Armature", "ARMATURE"
)
armature_mod.object = parent_obj
children.append(mesh_obj)
else:
# this only gets called if there is 1 mesh
parent_obj = mesh_obj

self.imported_objects[original_name.lower()] = ModelState(
parent_obj, children, collection
)

def get_model_copy(
self, model_name: str, collection: Collection
) -> Optional[Object]:
model_state = self.imported_objects.get(model_name.lower())

if model_state is None:
return None

if not model_state.used:
model_state.used = True

if model_state.object.name not in collection.objects:
model_state.collection.objects.unlink(model_state.object)
collection.objects.link(model_state.object)

for child in model_state.children:
model_state.collection.objects.unlink(child)
collection.objects.link(child)

return model_state.object

# if the original object is already used, create a copy
parent_copy = model_state.object.copy()
collection.objects.link(parent_copy)

for child in model_state.children:
child_copy = child.copy()
child_copy.parent = parent_copy

if parent_copy.type == "ARMATURE":
child_armature_mod = find_armature_modifier(child_copy)
if child_armature_mod is not None:
child_armature_mod.object = parent_copy

collection.objects.link(child_copy)

return parent_copy

def get_last_imported(self) -> Optional[Object]:
last = next(reversed(self.imported_objects.values()), None)

if last is None:
return None

return last.object


def import_mesh(
collection: Collection,
model_name: str,
bl_materials: List[Material],
mesh: LoadedMesh,
bones: Optional[List[LoadedBone]],
) -> Object:
mesh_name = truncate_name(f"{model_name}/{mesh.name()}")

mesh_data = bpy.data.meshes.get(mesh_name)
if mesh_data is None:
mesh_data = bpy.data.meshes.new(mesh_name)
else:
mesh_data.clear_geometry()
mesh_data.materials.clear()

mesh_data["path_id"] = mesh_name

polygons_len = mesh.polygons_len()

vertices = mesh.vertices()
mesh_data.vertices.add(len(vertices) // 3)
mesh_data.loops.add(mesh.loops_len())
mesh_data.polygons.add(mesh.polygons_len())
mesh_data.vertices.foreach_set("co", vertices)
mesh_data.polygons.foreach_set("loop_total", mesh.polygon_loop_totals())
mesh_data.polygons.foreach_set("loop_start", mesh.polygon_loop_starts())
mesh_data.polygons.foreach_set("vertices", mesh.polygon_vertices())
mesh_data.polygons.foreach_set("material_index", mesh.polygon_material_indices())
mesh_data.polygons.foreach_set("use_smooth", [True] * polygons_len)
mesh_data.update(calc_edges=True)

mesh_data.normals_split_custom_set_from_vertices(mesh.normals())

uv_layer = mesh_data.uv_layers.new()
uv_layer.data.foreach_set("uv", mesh.loop_uvs())

for bl_material in bl_materials:
mesh_data.materials.append(bl_material)

mesh_obj = bpy.data.objects.new(mesh_name, object_data=mesh_data)
collection.objects.link(mesh_obj)

if bones is not None:
for bone_index, weights in mesh.weight_groups().items():
bone_name = truncate_name(bones[bone_index].name())
vg = mesh_obj.vertex_groups.new(name=bone_name)
for vertex_index, weight in weights.items():
vg.add([vertex_index], weight, "REPLACE")

return mesh_obj


def import_armature(
collection: Collection,
model_name: str,
bones: List[LoadedBone],
rest_positions: Dict[int, BoneRestData],
bone_names: List[str],
) -> Object:
old_armature_data = bpy.data.armatures.get(model_name)
if old_armature_data is not None:
old_armature_data.name = f"{old_armature_data.name}.001"

armature_data: Armature = bpy.data.armatures.new(model_name)
armature: Object = bpy.data.objects.new(model_name, object_data=armature_data)
collection.objects.link(armature)

old_active_object = bpy.context.view_layer.objects.active
armature.select_set(True)
bpy.context.view_layer.objects.active = armature
bpy.ops.object.mode_set(mode="EDIT")
bl_bones: List[Bone] = []

for bone in bones:
bone_name = truncate_name(bone.name())
bl_bone = armature_data.edit_bones.new(bone_name)
bl_bones.append(bl_bone)
bone_names.append(bl_bone.name)

parent_bone_index = bone.parent_bone_index()
if parent_bone_index is not None:
bl_bone.parent = bl_bones[parent_bone_index]

bl_bone.tail = (0, 0, 1)
pos = Vector(bone.position())
rot = Euler(bone.rotation())
matrix = Matrix.Translation(pos) @ rot.to_matrix().to_4x4()
bl_bone.matrix = bl_bone.parent.matrix @ matrix if bl_bone.parent else matrix

bpy.ops.object.mode_set(mode="OBJECT")

for bone_i, rest_data in rest_positions.items():
bone_name = bone_names[bone_i]
bl_bone = armature.pose.bones[bone_name]

pos = Vector(rest_data.position())
rot = Euler(rest_data.rotation())
matrix = Matrix.Translation(pos) @ rot.to_matrix().to_4x4()
bl_bone.matrix = bl_bone.parent.matrix @ matrix if bl_bone.parent else matrix

armature.select_set(False)
bpy.context.view_layer.objects.active = old_active_object

return armature


def import_animation(
armature_obj: Object,
bone_names: List[str],
animation: LoadedAnimation,
) -> None:
animation_data = armature_obj.animation_data_create()

name = truncate_name(f"{armature_obj.name}/{animation.name()}")

action = bpy.data.actions.new(name)
animation_data.action = action

data = animation.data()
looping = animation.looping()

for bone_i, bone_data in data.items():
bone_name = bone_names[bone_i]
curve_basename = f'pose.bones["{bone_name}"]'

rotation = bone_data.rotation()

if isinstance(rotation, QuaternionData):
curve_name = f"{curve_basename}.rotation_quaternion"
import_quaternions(action, rotation, curve_name, looping)
armature_obj.pose.bones[bone_name].rotation_mode = "QUATERNION"
elif rotation is not None:
curve_name = f"{curve_basename}.rotation_quaternion"
import_quaternion(action, rotation, curve_name)
armature_obj.pose.bones[bone_name].rotation_mode = "QUATERNION"

position = bone_data.position()

if isinstance(position, VectorData):
curve_name = f"{curve_basename}.location"
import_vectors(action, position, curve_name, looping)
elif position is not None:
curve_name = f"{curve_basename}.location"
import_vector(action, position, curve_name)


def import_quaternions(
action: Action, data: QuaternionData, curve_name: str, looping: bool
) -> None:
curves = [action.fcurves.new(curve_name, index=i) for i in range(4)]

w_curve = curves[0]
w_values = data.w_points()
w_curve.keyframe_points.add(len(w_values) // 2)
w_curve.keyframe_points.foreach_set("co", w_values)

x_curve = curves[1]
x_values = data.x_points()
x_curve.keyframe_points.add(len(x_values) // 2)
x_curve.keyframe_points.foreach_set("co", x_values)

y_curve = curves[2]
y_values = data.y_points()
y_curve.keyframe_points.add(len(y_values) // 2)
y_curve.keyframe_points.foreach_set("co", y_values)

z_curve = curves[3]
z_values = data.z_points()
z_curve.keyframe_points.add(len(z_values) // 2)
z_curve.keyframe_points.foreach_set("co", z_values)

for curve in curves:
if looping:
curve.modifiers.new("CYCLES")
curve.update()


def import_quaternion(action: Action, data: List[float], curve_name: str) -> None:
w_curve = action.fcurves.new(curve_name, index=0)
w_curve.keyframe_points.insert(0.0, data[3])
x_curve = action.fcurves.new(curve_name, index=1)
x_curve.keyframe_points.insert(0.0, data[0])
y_curve = action.fcurves.new(curve_name, index=2)
y_curve.keyframe_points.insert(0.0, data[1])
z_curve = action.fcurves.new(curve_name, index=3)
z_curve.keyframe_points.insert(0.0, data[2])


def import_vectors(
action: Action, data: VectorData, curve_name: str, looping: bool
) -> None:
curves = [action.fcurves.new(curve_name, index=i) for i in range(3)]

x_curve = curves[0]
x_values = data.x_points()
x_curve.keyframe_points.add(len(x_values) // 2)
x_curve.keyframe_points.foreach_set("co", x_values)

y_curve = curves[1]
y_values = data.y_points()
y_curve.keyframe_points.add(len(y_values) // 2)
y_curve.keyframe_points.foreach_set("co", y_values)

z_curve = curves[2]
z_values = data.z_points()
z_curve.keyframe_points.add(len(z_values) // 2)
z_curve.keyframe_points.foreach_set("co", z_values)

for curve in curves:
if looping:
curve.modifiers.new("CYCLES")
curve.update()


def import_vector(action: Action, data: List[float], curve_name: str) -> None:
x_curve = action.fcurves.new(curve_name, index=0)
x_curve.keyframe_points.insert(0.0, data[0])
y_curve = action.fcurves.new(curve_name, index=1)
y_curve.keyframe_points.insert(0.0, data[1])
z_curve = action.fcurves.new(curve_name, index=2)
z_curve.keyframe_points.insert(0.0, data[2])
34 changes: 34 additions & 0 deletions plumber/asset/overlay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import bpy
from bpy.types import Collection

from .utils import truncate_name
from ..plumber import BuiltOverlay


def import_overlay(overlay: BuiltOverlay, collection: Collection) -> None:
id = overlay.id()
name = f"overlay_{id}"
mesh = bpy.data.meshes.new(name)

vertices = overlay.vertices()
mesh.vertices.add(len(vertices) // 3)
mesh.loops.add(overlay.loops_len())
mesh.polygons.add(overlay.polygons_len())
mesh.vertices.foreach_set("co", vertices)
mesh.polygons.foreach_set("loop_total", overlay.polygon_loop_totals())
mesh.polygons.foreach_set("loop_start", overlay.polygon_loop_starts())
mesh.polygons.foreach_set("vertices", overlay.polygon_vertices())
mesh.update()
uv_layer = mesh.uv_layers.new()
uv_layer.data.foreach_set("uv", overlay.loop_uvs())

material = truncate_name(overlay.material())
material_data = bpy.data.materials.get(material)
if material_data is None:
material_data = bpy.data.materials.new(material)
mesh.materials.append(material_data)

obj = bpy.data.objects.new(name, object_data=mesh)
obj.location = overlay.position()
obj.scale = overlay.scale()
collection.objects.link(obj)
87 changes: 87 additions & 0 deletions plumber/asset/prop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from typing import List
import bpy
from bpy.types import Collection, Object

from .utils import find_armature_modifier
from .model import ModelTracker
from ..plumber import LoadedProp, log_info


def import_prop(
prop: LoadedProp,
collection: Collection,
model_tracker: ModelTracker,
apply_armatures: bool,
armatures_to_apply: List[Object],
) -> None:
model_name = prop.model()
obj = model_tracker.get_model_copy(model_name, collection)
obj["path_id"] = model_name
obj["props"] = prop.properties()

name = f"{prop.class_name()}_{prop.id()}"

if obj is None:
obj = bpy.data.objects.new(name, object_data=None)
collection.objects.link(obj)
else:
obj.name = name

obj.location = prop.position()
obj.rotation_euler = prop.rotation()
obj.scale = prop.scale()
obj.color = prop.color()

if apply_armatures and obj.type == "ARMATURE":
armatures_to_apply.append(obj)


def apply_armatures(armatures_to_apply: List[Object]):
if not armatures_to_apply:
return

log_info(f"applying armatures for {len(armatures_to_apply)} props...")

selected_objects = bpy.context.selected_objects
active_object = bpy.context.view_layer.objects.active

for selected_obj in selected_objects:
selected_obj.select_set(False)

for obj in armatures_to_apply:
apply_armature(obj)

for selected_obj in selected_objects:
selected_obj.select_set(True)

bpy.context.view_layer.objects.active = active_object

log_info("armatures applied")


def apply_armature(obj: Object):
children: List[Object] = obj.children

for child in children:
child.select_set(True)

bpy.ops.object.make_single_user(type="SELECTED_OBJECTS", object=True, obdata=True)

name = obj.name
obj.name = f"{obj.name}-"

for child in children:
modifier = find_armature_modifier(child)

if modifier is not None:
bpy.context.view_layer.objects.active = child
bpy.ops.object.modifier_apply(modifier=modifier.name)

old_matrix_world = child.matrix_world
child.parent = None
child.matrix_world = old_matrix_world

child.name = name
child.select_set(False)

bpy.data.objects.remove(obj)
18 changes: 18 additions & 0 deletions plumber/asset/sky_camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import bpy
from bpy.types import Context, Collection

from ..plumber import SkyCamera


def import_sky_camera(
sky_camera: SkyCamera, context: Context, collection: Collection
) -> None:
name = f"sky_camera_{sky_camera.id()}"

obj = bpy.data.objects.new(name, object_data=None)
obj.location = sky_camera.position()
obj.scale = sky_camera.scale()
collection.objects.link(obj)

obj.select_set(True)
context.view_layer.objects.active = obj
39 changes: 39 additions & 0 deletions plumber/asset/sky_equi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import bpy
from bpy.types import Context, ShaderNode

from .utils import truncate_name
from ..plumber import SkyEqui


def import_sky_equi(sky_equi: SkyEqui, context: Context) -> None:
width = sky_equi.width()
height = sky_equi.height()
format = sky_equi.format()
image_name = truncate_name(f"{sky_equi.name()}.{format}")

image_data = bpy.data.images.new(image_name, width, height)

if format == "exr":
image_data.file_format = "OPEN_EXR"
else:
image_data.file_format = "TARGA_RAW"

image_data.source = "FILE"
bytes = sky_equi.bytes()
image_data.pack(data=bytes, data_len=len(bytes))

if context.scene.world is None:
context.scene.world = bpy.data.worlds.new("World")

context.scene.world.use_nodes = True
nt = context.scene.world.node_tree
nt.nodes.clear()
out_node: ShaderNode = nt.nodes.new("ShaderNodeOutputWorld")
out_node.location = (0, 0)
bg_node: ShaderNode = nt.nodes.new("ShaderNodeBackground")
bg_node.location = (-300, 0)
nt.links.new(bg_node.outputs["Background"], out_node.inputs["Surface"])
tex_node: ShaderNode = nt.nodes.new("ShaderNodeTexEnvironment")
tex_node.image = image_data
tex_node.location = (-600, 0)
nt.links.new(tex_node.outputs["Color"], bg_node.inputs["Color"])
16 changes: 16 additions & 0 deletions plumber/asset/unknown_entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import bpy
from bpy.types import Collection

from ..plumber import UnknownEntity


def import_unknown_entity(entity: UnknownEntity, collection: Collection) -> None:
name = f"{entity.class_name()}_{entity.id()}"

obj = bpy.data.objects.new(name, object_data=None)
obj.location = entity.position()
obj.rotation_euler = entity.rotation()
obj.scale = entity.scale()
obj["props"] = entity.properties()

collection.objects.link(obj)
50 changes: 50 additions & 0 deletions plumber/asset/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from hashlib import md5
from base64 import urlsafe_b64encode
from posixpath import split, splitext
from typing import Optional
import bpy

_HASH_LEN = 6
_B64_LEN = 8


def _hashed(s: str) -> str:
return urlsafe_b64encode(md5(s.encode("utf-8")).digest()[:_HASH_LEN])[
:_B64_LEN
].decode("ascii")


def truncate_name(name: str, maxlen: int = 59) -> str:
name = name.replace("\\", "/").strip("/")
if len(name) <= maxlen:
return name
path, basename = split(name)
max_path_len = maxlen - (len(basename) + _B64_LEN + 2)
if max_path_len <= 0:
name, extension = splitext(name)
return f"~{_hashed(name)}{extension}"
path_split = -max_path_len
path_discard, path_keep = path[:path_split], path[path_split:]
if "/" in path_keep:
extra_discard, final_keep = path_keep.split("/", maxsplit=1)
final_keep += "/"
else:
extra_discard, final_keep = path_keep, ""
final_discard = path_discard + extra_discard
return f"~{_hashed(final_discard)}/{final_keep}{basename}"


def get_unknown_material() -> bpy.types.Material:
material = bpy.data.materials.get("?.vmt")
if material is None:
material = bpy.data.materials.new("?.vmt")
return material


def find_armature_modifier(
obj: bpy.types.Object,
) -> Optional[bpy.types.ArmatureModifier]:
for modifier in obj.modifiers:
if modifier.type == "ARMATURE":
return modifier
return None
122 changes: 122 additions & 0 deletions plumber/benchmark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from typing import Set
import time
from statistics import mean

import bpy
from bpy.types import Context
from bpy.props import StringProperty, IntProperty
from bpy.app.handlers import persistent

from .importer import ImporterOperator, ImporterOperatorProps
from .preferences import AddonPreferences

from .plumber import log_info

HANDLER_KEY = "PLUMBER_BENCHMARK_HANDLER"

BENCHMARKS_LEFT = 0
IMPORT_SETTINGS = {}
BENCHMARK_TIMES = []


class BenchmarkVmf(
ImporterOperator,
ImporterOperatorProps,
):
"""Benchmark Source Engine VMF map import"""

bl_idname = "import_scene.plumber_vmf_benchmark"
bl_label = "Benchmark VMF import"
bl_options = {"REGISTER"}

filename_ext = ".vmf"

filter_glob: StringProperty(
default="*.vmf",
options={"HIDDEN"},
maxlen=255,
)

map_data_path: StringProperty(
name="Embedded files path", default="", description="Leave empty to auto-detect"
)

import_count: IntProperty(
name="Import count",
default=5,
)

def execute(self, context: Context) -> Set[str]:
log_info(f"Starting benchmarking vmf import with {self.import_count} imports")

log_info(f"Warming up with first import")

bpy.ops.import_scene.plumber_vmf(
game=self.game,
filepath=self.filepath,
map_data_path=self.map_data_path,
)

global BENCHMARKS_LEFT, IMPORT_SETTINGS, BENCHMARK_TIMES

BENCHMARKS_LEFT = self.import_count
IMPORT_SETTINGS = {
"game": self.game,
"filepath": self.filepath,
"map_data_path": self.map_data_path,
}
BENCHMARK_TIMES = []

log_info(f"Starting measurements...")
bpy.app.handlers.load_post.append(persistent_benchmarker)
bpy.app.timers.register(load_empty_blend_file, first_interval=1)

# Note: missing return statement on purpose, otherwise Blender crashes after benchmark :D

def draw(self, context: Context):
layout = self.layout

layout.prop(self, "map_data_path")
layout.prop(self, "import_count")


def load_empty_blend_file():
bpy.ops.wm.read_homefile(use_empty=True)


@persistent
def persistent_benchmarker(_):
global BENCHMARKS_LEFT, IMPORT_SETTINGS, BENCHMARK_TIMES

start = time.perf_counter()
bpy.ops.import_scene.plumber_vmf(**IMPORT_SETTINGS)
end = time.perf_counter()

BENCHMARK_TIMES.append(end - start)
BENCHMARKS_LEFT -= 1

if BENCHMARKS_LEFT <= 0:
mean_time = mean(BENCHMARK_TIMES)
log_info(f"Benchmark finished. Mean time: {mean_time:.4f} s")

bpy.app.handlers.load_post.remove(persistent_benchmarker)
else:
load_empty_blend_file()


def register():
preferences: AddonPreferences = bpy.context.preferences.addons[
__package__
].preferences

if preferences.enable_benchmarking:
bpy.utils.register_class(BenchmarkVmf)


def unregister():
preferences: AddonPreferences = bpy.context.preferences.addons[
__package__
].preferences

if preferences.enable_benchmarking:
bpy.utils.unregister_class(BenchmarkVmf)
19 changes: 19 additions & 0 deletions plumber/blender_manifest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
schema_version = "1.0.0"

id = "plumber"
version = "1.1.2"
name = "Plumber"
tagline = "Imports Source 1 engine maps, models, materials and textures"
maintainer = "Lassi Säike <lassi.saike01@gmail.com>"
type = "add-on"
website = "https://github.com/lasa01/Plumber"
tags = ["Import-Export"]
blender_version_min = "4.2.0"
license = ["SPDX:GPL-3.0-or-later"]
platforms = ["windows-x64", "macos-x64", "macos-arm64", "linux-x64"]

[permissions]
files = "Import/extract assets from/to disk, detect installed games"

[build]
paths_exclude_pattern = ["__pycache__/", "/.git/", "/*.zip", "/*.pyi"]
277 changes: 277 additions & 0 deletions plumber/importer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
from typing import Set
from os.path import basename, dirname

import bpy
from bpy.props import EnumProperty, BoolProperty, StringProperty
from bpy.types import Context, Operator, Panel, UILayout

from ..plumber import FileSystem
from ..preferences import AddonPreferences

from .. import __package__ as ADDON_NAME


class ImporterOperatorProps:
game: EnumProperty(
items=AddonPreferences.game_enum_items,
name="Game",
description="Used for opening required assets",
options={"HIDDEN"},
)

filepath: StringProperty(
name="Path",
maxlen=1024,
options={"HIDDEN"},
)


class ImporterOperator(Operator, ImporterOperatorProps):
def get_game_fs(self, context: Context):
if self.game == "NONE":
return FileSystem.empty()
else:
preferences = context.preferences.addons[ADDON_NAME].preferences
game = preferences.games[int(self.game)]
return game.get_file_system()

def get_threads_suggestion(self, context: Context) -> int:
preferences = context.preferences.addons[ADDON_NAME].preferences
# leave room for blender's thread
return preferences.threads - 1

def get_target_fps(self, context: Context) -> float:
scene = context.scene
return scene.render.fps / scene.render.fps_base

def invoke(self, context: Context, event) -> Set[str]:
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}

def draw(self, context: Context):
pass


def update_recent_entries(context: Context, game: str, path: str):
if game == "NONE":
return

recent_entries = context.scene.plumber_recent_entries.get(game)

if recent_entries is None:
recent_entries = context.scene.plumber_recent_entries.add()
recent_entries.name = game

for recent_entry in recent_entries.recent_entries:
if recent_entry.path == path:
return

add_recent_entry(path, recent_entries.recent_entries)

scene_browser = context.scene.plumber_browser
if scene_browser.game == game:
add_recent_entry(path, scene_browser.recent_entries_temp)

if update_recent_entries.browser_operator_entries is not None:
add_recent_entry(path, update_recent_entries.browser_operator_entries)


update_recent_entries.browser_operator_entries = None


def add_recent_entry(path: str, recent_entries):
while len(recent_entries) >= 10:
recent_entries.remove(0)

recent_entry = recent_entries.add()
recent_entry.name = basename(path)
recent_entry.path = path


class GameFileImporterOperatorProps:
from_game_fs: BoolProperty(options={"HIDDEN"})


class GameFileImporterOperator(
ImporterOperator, ImporterOperatorProps, GameFileImporterOperatorProps
):
def invoke(self, context: Context, event) -> Set[str]:
if self.from_game_fs:
update_recent_entries(context, self.game, dirname(self.filepath))
return context.window_manager.invoke_props_dialog(self)
else:
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}


class DisableCommonPanel:
pass


class PLUMBER_PT_importer_common(Panel):
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_label = ""
bl_parent_id = "FILE_PT_operator"
bl_options = {"HIDE_HEADER"}

@classmethod
def poll(cls, context: Context) -> bool:
operator = context.space_data.active_operator
return isinstance(operator, ImporterOperatorProps) and not isinstance(
operator, DisableCommonPanel
)

def draw(self, context: Context) -> None:
layout: UILayout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
operator = context.space_data.active_operator
layout.prop(operator, "game")


class MaterialImporterOperatorProps:
simple_materials: BoolProperty(
name="Simple materials",
description="Import simple, exporter-friendly versions of materials",
default=False,
)

texture_format: EnumProperty(
name="Texture format",
description="Format to use for imported image textures",
items=[
("Tga", "TGA", "Truevision TGA"),
("Png", "PNG", "Portable Network Graphigs"),
],
default="Png",
)

texture_interpolation: EnumProperty(
name="Texture interpolation",
description="Interpolation type to use for image textures",
items=[
("Linear", "Linear", "Linear interpolation"),
("Closest", "Closest", "No interpolation"),
("Cubic", "Cubic", "Cubic interpolation"),
("Smart", "Smart", "Bicubic when magnifying, else bilinear"),
],
default="Linear",
)

allow_culling: BoolProperty(
name="Allow backface culling",
description="Enable backface culling for materials which don't disable it",
default=False,
)

editor_materials: BoolProperty(
name="Import editor materials",
description="Import materials visible inside Hammer instead of invisible materials",
default=False,
)

@staticmethod
def draw_props(
layout: UILayout, operator: "MaterialImporterOperatorProps", context: Context
):
layout.use_property_split = True
layout.prop(operator, "simple_materials")
layout.prop(operator, "texture_format")
layout.prop(operator, "texture_interpolation")
layout.prop(operator, "allow_culling")
layout.prop(operator, "editor_materials")


class MaterialToggleOperatorProps(MaterialImporterOperatorProps):
import_materials: BoolProperty(
name="Import materials",
default=True,
)

@staticmethod
def draw_props(
layout: UILayout, operator: "MaterialToggleOperatorProps", context: Context
):
layout.prop(operator, "import_materials")
box = layout.box()
box.enabled = operator.import_materials
MaterialImporterOperatorProps.draw_props(box, operator, context)


class PLUMBER_PT_importer_materials(Panel):
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_label = "Materials"
bl_parent_id = "FILE_PT_operator"
bl_options = {"DEFAULT_CLOSED"}

@classmethod
def poll(cls, context: Context) -> bool:
operator = context.space_data.active_operator
return isinstance(operator, MaterialToggleOperatorProps)

def draw_header(self, context: Context) -> None:
layout = self.layout
operator = context.space_data.active_operator
layout.prop(operator, "import_materials", text="")

def draw(self, context: Context):
layout = self.layout
operator = context.space_data.active_operator

layout.enabled = operator.import_materials

MaterialImporterOperatorProps.draw_props(layout, operator, context)


class ModelImporterOperatorProps:
import_animations: BoolProperty(name="Import animations", default=True)

@staticmethod
def draw_props(
layout: UILayout, operator: "ModelImporterOperatorProps", context: Context
):
layout.prop(operator, "import_animations")


from .vmf import (
ImportVmf,
PLUMBER_PT_vmf_geometry,
PLUMBER_PT_vmf_lights,
PLUMBER_PT_vmf_main,
PLUMBER_PT_vmf_map_data,
PLUMBER_PT_vmf_props,
PLUMBER_PT_vmf_sky,
)
from .mdl import ImportMdl, PLUMBER_PT_mdl_main
from .vmt import ImportVmt, PLUMBER_PT_vmt_main
from .vtf import ImportVtf


CLASSES = [
PLUMBER_PT_importer_common,
PLUMBER_PT_vmf_map_data,
PLUMBER_PT_vmf_geometry,
PLUMBER_PT_vmf_lights,
PLUMBER_PT_vmf_sky,
PLUMBER_PT_vmf_props,
PLUMBER_PT_importer_materials,
PLUMBER_PT_vmf_main,
PLUMBER_PT_vmt_main,
PLUMBER_PT_mdl_main,
ImportVmf,
ImportMdl,
ImportVmt,
ImportVtf,
]


def register():
for cls in CLASSES:
bpy.utils.register_class(cls)


def unregister():
for cls in reversed(CLASSES):
bpy.utils.unregister_class(cls)
108 changes: 108 additions & 0 deletions plumber/importer/mdl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from typing import Set

from bpy.types import Context, Panel
from bpy.props import StringProperty, FloatProperty

from . import (
GameFileImporterOperator,
GameFileImporterOperatorProps,
ImporterOperatorProps,
MaterialToggleOperatorProps,
ModelImporterOperatorProps,
)
from ..asset import AssetCallbacks
from ..plumber import Importer


class ImportMdl(
GameFileImporterOperator,
ImporterOperatorProps,
GameFileImporterOperatorProps,
ModelImporterOperatorProps,
MaterialToggleOperatorProps,
):
"""Import Source Engine MDL model"""

bl_idname = "import_scene.plumber_mdl"
bl_label = "Import MDL"
bl_options = {"REGISTER", "UNDO", "PRESET"}

filename_ext = ".mdl"

filter_glob: StringProperty(
default="*.mdl",
options={"HIDDEN"},
maxlen=255,
)

scale: FloatProperty(
name="Scale",
default=0.01,
min=1e-6,
max=1e6,
soft_min=0.001,
soft_max=1.0,
)

def execute(self, context: Context) -> Set[str]:
fs = self.get_game_fs(context)
asset_callbacks = AssetCallbacks(context)

try:
importer = Importer(
fs,
asset_callbacks,
self.get_threads_suggestion(context),
import_materials=self.import_materials,
target_fps=self.get_target_fps(context),
simple_materials=self.simple_materials,
allow_culling=self.allow_culling,
editor_materials=self.editor_materials,
texture_format=self.texture_format,
texture_interpolation=self.texture_interpolation,
root_search=None if self.from_game_fs else (self.filepath, "models"),
)
except OSError as err:
self.report({"ERROR"}, f"could not open file system: {err}")
return {"CANCELLED"}

try:
importer.import_mdl(
self.filepath,
self.from_game_fs,
import_animations=self.import_animations,
)
except OSError as err:
self.report({"ERROR"}, f"could not import mdl: {err}")
return {"CANCELLED"}

imported_obj = asset_callbacks.model_tracker.get_last_imported()
imported_obj.scale = (self.scale, self.scale, self.scale)

return {"FINISHED"}

def draw(self, context: Context):
if self.from_game_fs:
ModelImporterOperatorProps.draw_props(self.layout, self, context)
MaterialToggleOperatorProps.draw_props(self.layout, self, context)

self.layout.prop(self, "scale")


class PLUMBER_PT_mdl_main(Panel):
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_label = ""
bl_parent_id = "FILE_PT_operator"
bl_options = {"HIDE_HEADER"}

@classmethod
def poll(cls, context: Context) -> bool:
operator = context.space_data.active_operator
return operator.bl_idname == "IMPORT_SCENE_OT_plumber_mdl"

def draw(self, context: Context) -> None:
operator = context.space_data.active_operator
ModelImporterOperatorProps.draw_props(self.layout, operator, context)

self.layout.prop(operator, "scale")
497 changes: 497 additions & 0 deletions plumber/importer/vmf.py

Large diffs are not rendered by default.

84 changes: 84 additions & 0 deletions plumber/importer/vmt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from typing import Set

from bpy.types import Context, Panel
from bpy.props import StringProperty

from . import (
GameFileImporterOperator,
GameFileImporterOperatorProps,
ImporterOperatorProps,
MaterialImporterOperatorProps,
)
from ..asset import AssetCallbacks
from ..plumber import Importer


class ImportVmt(
GameFileImporterOperator,
ImporterOperatorProps,
GameFileImporterOperatorProps,
MaterialImporterOperatorProps,
):
"""Import Source Engine VMT material"""

bl_idname = "import_scene.plumber_vmt"
bl_label = "Import VMT"
bl_options = {"REGISTER", "UNDO", "PRESET"}

filename_ext = ".vmt"

filter_glob: StringProperty(
default="*.vmt",
options={"HIDDEN"},
maxlen=255,
)

def execute(self, context: Context) -> Set[str]:
fs = self.get_game_fs(context)

try:
importer = Importer(
fs,
AssetCallbacks(context),
self.get_threads_suggestion(context),
import_materials=True,
simple_materials=self.simple_materials,
allow_culling=self.allow_culling,
editor_materials=self.editor_materials,
texture_interpolation=self.texture_interpolation,
texture_format=self.texture_format,
root_search=None if self.from_game_fs else (self.filepath, "materials"),
)
except OSError as err:
self.report({"ERROR"}, f"could not open file system: {err}")
return {"CANCELLED"}

try:
importer.import_vmt(self.filepath, self.from_game_fs)
except OSError as err:
self.report({"ERROR"}, f"could not import vmt: {err}")
return {"CANCELLED"}

return {"FINISHED"}

def draw(self, context: Context):
if self.from_game_fs:
MaterialImporterOperatorProps.draw_props(self.layout, self, context)


class PLUMBER_PT_vmt_main(Panel):
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_label = ""
bl_parent_id = "FILE_PT_operator"
bl_options = {"HIDE_HEADER"}

@classmethod
def poll(cls, context: Context) -> bool:
operator = context.space_data.active_operator
return operator.bl_idname == "IMPORT_SCENE_OT_plumber_vmt"

def draw(self, context: Context) -> None:
MaterialImporterOperatorProps.draw_props(
self.layout, context.space_data.active_operator, context
)
53 changes: 53 additions & 0 deletions plumber/importer/vtf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import Set

from bpy.types import Context
from bpy.props import StringProperty

from . import (
GameFileImporterOperator,
GameFileImporterOperatorProps,
ImporterOperatorProps,
)
from ..asset import AssetCallbacks
from ..plumber import Importer


class ImportVtf(
GameFileImporterOperator,
ImporterOperatorProps,
GameFileImporterOperatorProps,
):
"""Import Source Engine VTF texture"""

bl_idname = "import_scene.plumber_vtf"
bl_label = "Import VTF"
bl_options = {"REGISTER", "UNDO"}

filename_ext = ".vtf"

filter_glob: StringProperty(
default="*.vtf",
options={"HIDDEN"},
maxlen=255,
)

def execute(self, context: Context) -> Set[str]:
fs = self.get_game_fs(context)

try:
importer = Importer(
fs,
AssetCallbacks(context),
self.get_threads_suggestion(context),
)
except OSError as err:
self.report({"ERROR"}, f"could not open file system: {err}")
return {"CANCELLED"}

try:
importer.import_vtf(self.filepath, self.from_game_fs)
except OSError as err:
self.report({"ERROR"}, f"could not import vtf: {err}")
return {"CANCELLED"}

return {"FINISHED"}
243 changes: 243 additions & 0 deletions plumber/plumber.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
from typing import Any, Dict, List, Optional, Tuple, Union

class FileSystem:
def __init__(self, name: str, search_paths: List[Tuple[str, str]]) -> None: ...
@staticmethod
def empty() -> "FileSystem": ...
def name(self) -> str: ...
def search_paths(self) -> List[Tuple[str, str]]: ...
def with_search_path(self, search_path: Tuple[str, str]) -> "FileSystem": ...
def browse(self) -> "FileBrowser": ...
def extract(self, path: str, is_dir: bool, target_dir: str): ...

def discover_filesystems() -> List[FileSystem]: ...
def filesystem_from_gameinfo(path: str) -> FileSystem: ...
def log_error(error: str) -> None: ...
def log_info(info: str) -> None: ...
def version() -> str: ...

class FileBrowser:
def read_dir(self, dir: str) -> List["FileBrowserEntry"]: ...

class FileBrowserEntry:
def name(self) -> str: ...
def path(self) -> str: ...
def kind(self) -> str: ...

class SkyEqui:
def name(self) -> str: ...
def width(self) -> int: ...
def height(self) -> int: ...
def format(self) -> str: ...
def bytes(self) -> bytes: ...

class Texture:
def name(self) -> str: ...
def width(self) -> int: ...
def height(self) -> int: ...
def format_ext(self) -> str: ...
def bytes(self) -> bytes: ...

class Material:
def name(self) -> str: ...
def data(self) -> BuiltMaterialData: ...
def texture_ext(self) -> str: ...

Value = Union[
bool,
float,
List[float],
str,
"TextureRef",
]

NodeSocketId = Union[int, str]

class BuiltMaterialData:
def properties(self) -> Dict[str, Value]: ...
def nodes(self) -> List["BuiltNode"]: ...
def texture_color_spaces(self) -> Dict[str, str]: ...

class BuiltNode:
def blender_id(self) -> str: ...
def position(self) -> List[float]: ...
def properties(self) -> Dict[str, Value]: ...
def socket_values(self) -> Dict[NodeSocketId, Value]: ...
def socket_links(self) -> Dict[NodeSocketId, "BuiltNodeSocketRef"]: ...

class BuiltNodeSocketRef:
def node_index(self) -> int: ...
def socket(self) -> NodeSocketId: ...

class TextureRef:
def path(self) -> str: ...

class LoadedProp:
def model(self) -> str: ...
def class_name(self) -> str: ...
def id(self) -> int: ...
def position(self) -> List[float]: ...
def rotation(self) -> List[float]: ...
def scale(self) -> List[float]: ...
def color(self) -> List[float]: ...
def properties(self) -> Dict[str, str]: ...

class QuaternionData:
def x_points(self) -> List[float]: ...
def y_points(self) -> List[float]: ...
def z_points(self) -> List[float]: ...
def w_points(self) -> List[float]: ...

class VectorData:
def x_points(self) -> List[float]: ...
def y_points(self) -> List[float]: ...
def z_points(self) -> List[float]: ...

class BoneAnimationData:
def rotation(
self,
) -> List[float] | QuaternionData | None: ...
def position(self) -> List[float] | VectorData | None: ...

class BoneRestData:
def rotation(self) -> List[float]: ...
def position(self) -> List[float]: ...

class LoadedAnimation:
def name(self) -> str: ...
def data(self) -> Dict[int, BoneAnimationData]: ...
def looping(self) -> bool: ...

class LoadedBone:
def name(self) -> str: ...
def parent_bone_index(self) -> Optional[int]: ...
def position(self) -> List[float]: ...
def rotation(self) -> List[float]: ...

class LoadedMesh:
def name(self) -> str: ...
def vertices(self) -> List[float]: ...
def loops_len(self) -> int: ...
def polygons_len(self) -> int: ...
def polygon_loop_totals(self) -> List[int]: ...
def polygon_loop_starts(self) -> List[int]: ...
def polygon_vertices(self) -> List[int]: ...
def polygon_material_indices(self) -> List[int]: ...
def loop_uvs(self) -> List[float]: ...
def normals(self) -> List[List[float]]: ...
def weight_groups(self) -> Dict[int, Dict[int, float]]: ...

class Model:
def name(self) -> str: ...
def meshes(self) -> List[LoadedMesh]: ...
def materials(self) -> List[Optional[str]]: ...
def bones(self) -> List[LoadedBone]: ...
def animations(self) -> List[LoadedAnimation]: ...
def rest_positions(self) -> Dict[int, BoneRestData]: ...

class MergedSolids:
def no_draw(self) -> bool: ...
def position(self) -> List[float]: ...
def scale(self) -> List[float]: ...
def vertices(self) -> List[float]: ...
def loops_len(self) -> int: ...
def polygons_len(self) -> int: ...
def polygon_loop_totals(self) -> List[int]: ...
def polygon_loop_starts(self) -> List[int]: ...
def polygon_vertices(self) -> List[int]: ...
def polygon_material_indices(self) -> List[int]: ...
def loop_uvs(self) -> List[float]: ...
def loop_colors(self) -> List[float]: ...
def materials(self) -> List[str]: ...

class BuiltSolid:
def id(self) -> int: ...
def no_draw(self) -> bool: ...
def position(self) -> List[float]: ...
def scale(self) -> List[float]: ...
def vertices(self) -> List[float]: ...
def loops_len(self) -> int: ...
def polygons_len(self) -> int: ...
def polygon_loop_totals(self) -> List[int]: ...
def polygon_loop_starts(self) -> List[int]: ...
def polygon_vertices(self) -> List[int]: ...
def polygon_material_indices(self) -> List[int]: ...
def loop_uvs(self) -> List[float]: ...
def loop_colors(self) -> List[float]: ...
def materials(self) -> List[str]: ...

class BuiltBrushEntity:
def id(self) -> int: ...
def class_name(self) -> str: ...
def merged_solids(self) -> Optional[MergedSolids]: ...
def solids(self) -> List[BuiltSolid]: ...

class BuiltOverlay:
def id(self) -> int: ...
def position(self) -> List[float]: ...
def scale(self) -> List[float]: ...
def vertices(self) -> List[float]: ...
def loops_len(self) -> int: ...
def polygons_len(self) -> int: ...
def polygon_loop_totals(self) -> List[int]: ...
def polygon_loop_starts(self) -> List[int]: ...
def polygon_vertices(self) -> List[int]: ...
def polygon_material_indices(self) -> List[int]: ...
def loop_uvs(self) -> List[float]: ...
def material(self) -> str: ...

class Light:
def id(self) -> int: ...
def position(self) -> List[float]: ...
def color(self) -> List[float]: ...
def energy(self) -> float: ...
def properties(self) -> Dict[str, str]: ...

class SpotLight:
def id(self) -> int: ...
def position(self) -> List[float]: ...
def rotation(self) -> List[float]: ...
def color(self) -> List[float]: ...
def energy(self) -> float: ...
def spot_size(self) -> float: ...
def spot_blend(self) -> float: ...
def properties(self) -> Dict[str, str]: ...

class EnvLight:
def id(self) -> int: ...
def position(self) -> List[float]: ...
def rotation(self) -> List[float]: ...
def sun_color(self) -> List[float]: ...
def sun_energy(self) -> float: ...
def ambient_color(self) -> List[float]: ...
def ambient_strength(self) -> float: ...
def angle(self) -> float: ...
def properties(self) -> Dict[str, str]: ...

class SkyCamera:
def id(self) -> int: ...
def position(self) -> List[float]: ...
def scale(self) -> List[float]: ...

class UnknownEntity:
def class_name(self) -> str: ...
def id(self) -> int: ...
def position(self) -> List[float]: ...
def rotation(self) -> List[float]: ...
def scale(self) -> List[float]: ...
def properties(self) -> Dict[str, str]: ...

class Importer:
def __init__(
self,
file_system: FileSystem,
callback_obj: Any,
threads_suggestion: int,
**kwargs
) -> None: ...
def import_vmf(self, path: str, from_game: bool, **kwargs) -> None: ...
def import_mdl(self, path: str, from_game: bool, **kwargs) -> None: ...
def import_vmt(self, path: str, from_game: bool) -> None: ...
def import_vtf(self, path: str, from_game: bool) -> None: ...
def stage_mdl(self, path: str, **kwargs) -> None: ...
def import_assets(self) -> None: ...
Loading