feat(materials): generated facade texture maps, sampled world-aligned
New Tools/generate_facade_textures.py: deterministic, stdlib-only PNG generator for four facade surface sets (ashlar stone, stucco, asphalt, concrete), each as base colour (_BC) plus roughness (_R). Pure zlib encoder and hash-based value noise, so re-running produces byte-identical files (verified by md5sum); ~14 s for eight 512² maps. ue_build_demo_map.py imports them into /Game/Hollywood/Textures and samples them world-aligned: WorldPosition (cm) * 1/tile_cm -> ComponentMask(R,G) -> TextureSample UVs, with per-material tile sizes (stone 440, stucco 260, asphalt 900, concrete 250 cm). A textured material skips the graph coursing deliberately - overlaying both would double the course pattern. Textures imported 8/8, hero 4/4 slots assigned, map saved; screenshots re-rendered.
This commit is contained in:
1 parent
0f928531df
commit
73e535a24b
39 files changed
+401
-48
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -550,3 +550,21 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
||||
a 4th element on the shot tuples; a long lens (42–55°) fills the frame from
|
||||
across the street while the camera stays level, so verticals stay parallel.
|
||||
`review-hero-facade-front` sky 39 → 20 %, `demo-spawn-view` 23 → 16 %.
|
||||
|
||||
## 2026-11-09 — facade texture maps (0.1.4)
|
||||
|
||||
- `Tools/generate_facade_textures.py` writes deterministic 512² base-colour and
|
||||
roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG
|
||||
encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and
|
||||
byte-identical on a re-run (checked by md5sum).
|
||||
- `ue_build_demo_map.py` imports them to `/Game/Hollywood/Textures` and samples
|
||||
them **world-aligned**: WorldPosition (cm) → ×(1/tile_cm) → ComponentMask(R,G)
|
||||
→ TextureSample UVs. `tile_cm` per material: stone 440, stucco 260, asphalt 900,
|
||||
concrete 250.
|
||||
- Gotchas: `MaterialExpressionTextureSample`'s UV input pin is **"UVs"**, and the
|
||||
output pin for colour is **"RGB"** / roughness **"R"**. `ComponentMask` pins are
|
||||
R/G/B/A booleans. A textured material skips the graph coursing on purpose —
|
||||
overlaying both would double the course pattern.
|
||||
- The script needs `ROOT` in `ue_build_demo_map.py` and `sys` imported; forgetting
|
||||
either fails with `NameError` and an exit code of 0 from the commandlet (always
|
||||
read the result file under /tmp/gigline).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,6 +16,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob |
|
||||
| `generate_facade_textures.py` | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. |
|
||||
| `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. |
|
||||
| `fbx_export_preset.py` | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. |
|
||||
| `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing |
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the facade texture maps the demo materials sample.
|
||||
|
||||
The materials started as procedural graphs only (noise + coursing), which reads
|
||||
as tinted stone but has no grain. These maps add the grain: an ashlar stone
|
||||
course pattern, a rendered stucco tooth, asphalt aggregate and concrete pores,
|
||||
each as base colour plus a roughness map so the specular breaks up.
|
||||
|
||||
Standard library only — the PNG encoder is the same pure-zlib writer used for the
|
||||
MPFB skin, so this runs on the host without numpy or Pillow.
|
||||
|
||||
python3 Tools/generate_facade_textures.py # write to /tmp/gigline/textures
|
||||
python3 Tools/generate_facade_textures.py --size 1024
|
||||
python3 Tools/generate_facade_textures.py --out Art/blender/textures
|
||||
|
||||
Output names match the Unreal import destination `/Game/Hollywood/Textures`:
|
||||
|
||||
T_StoneAshlar_BC.png T_StoneAshlar_R.png
|
||||
T_Stucco_BC.png T_Stucco_R.png
|
||||
T_Asphalt_BC.png T_Asphalt_R.png
|
||||
T_Concrete_BC.png T_Concrete_R.png
|
||||
|
||||
Determinism: every pattern is a deterministic hash of the pixel coordinate, so a
|
||||
re-run reproduces the same files byte for byte (no RNG state, no seam drift).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
# --- PNG -------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_png(path, width, height, rgb_rows):
|
||||
"""Write 8-bit RGB rows (bytes) to a PNG. `rgb_rows` is one bytes per row."""
|
||||
raw = bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0) # filter type 0
|
||||
raw += rgb_rows[y]
|
||||
|
||||
def chunk(tag, data):
|
||||
c = struct.pack(">I", len(data)) + tag + data
|
||||
c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
return c
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # colour type 2 = RGB
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
|
||||
png += chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(png)
|
||||
return path
|
||||
|
||||
|
||||
# --- deterministic value noise --------------------------------------------
|
||||
|
||||
|
||||
def _hash2(x, y, seed=0):
|
||||
"""Deterministic 0..1 hash of an integer cell. No RNG, fully reproducible."""
|
||||
n = (x * 374761393 + y * 668265263 + seed * 1442695040888963407) & 0xFFFFFFFF
|
||||
n = (n ^ (n >> 13)) * 1274126177 & 0xFFFFFFFF
|
||||
return ((n ^ (n >> 16)) & 0xFFFF) / 65535.0
|
||||
|
||||
|
||||
def value_noise(x, y, seed=0):
|
||||
"""Smooth bilinear value noise at continuous (x, y) in cell units."""
|
||||
xi, yi = math.floor(x), math.floor(y)
|
||||
fx, fy = x - xi, y - yi
|
||||
# smoothstep weights so cells blend without visible lattice lines
|
||||
sx = fx * fx * (3.0 - 2.0 * fx)
|
||||
sy = fy * fy * (3.0 - 2.0 * fy)
|
||||
n00 = _hash2(xi, yi, seed)
|
||||
n10 = _hash2(xi + 1, yi, seed)
|
||||
n01 = _hash2(xi, yi + 1, seed)
|
||||
n11 = _hash2(xi + 1, yi + 1, seed)
|
||||
return (n00 * (1 - sx) + n10 * sx) * (1 - sy) + (n01 * (1 - sx) + n11 * sx) * sy
|
||||
|
||||
|
||||
def fractal(x, y, octaves=4, seed=0):
|
||||
"""Fractal sum of value noise, normalised to 0..1."""
|
||||
total, amp, freq, norm = 0.0, 1.0, 1.0, 0.0
|
||||
for _ in range(octaves):
|
||||
total += value_noise(x * freq, y * freq, seed) * amp
|
||||
norm += amp
|
||||
amp *= 0.5
|
||||
freq *= 2.0
|
||||
return total / norm
|
||||
|
||||
|
||||
def clamp8(value):
|
||||
return max(0, min(255, int(value * 255.0 + 0.5)))
|
||||
|
||||
|
||||
# --- patterns --------------------------------------------------------------
|
||||
# Each returns (base_rgb_function, roughness_function) over u, v in 0..1.
|
||||
|
||||
|
||||
def stone_ashlar(courses=8, per_course=4):
|
||||
"""Coursed ashlar: horizontal beds, staggered vertical joints, stone grain."""
|
||||
course_h = 1.0 / courses
|
||||
|
||||
def block_uv(u, v):
|
||||
row = int(v / course_h)
|
||||
shift = 0.5 * (row % 2) / per_course # stagger alternate courses
|
||||
col = int(((u + shift) % 1.0) * per_course)
|
||||
return row, col, (v % course_h) / course_h, ((u + shift) % 1.0) * per_course - col
|
||||
|
||||
def base(u, v, px, py):
|
||||
row, col, fj, fi = block_uv(u, v)
|
||||
tone = 0.62 + 0.10 * _hash2(row, col, 11) # each block a little different
|
||||
grain = 0.06 * (fractal(px * 0.15, py * 0.15, 5, 3) - 0.5) * 2.0
|
||||
# joint: a dark band along the bed and the head of each block
|
||||
joint = 0.0
|
||||
if fj < 0.055 or fi < 0.055:
|
||||
joint = -0.22
|
||||
value = tone + grain + joint
|
||||
return (value * 1.02, value, value * 0.94) # faintly warm stone
|
||||
|
||||
def rough(u, v, px, py):
|
||||
row, col, fj, fi = block_uv(u, v)
|
||||
base_r = 0.66 + 0.10 * _hash2(row, col, 29)
|
||||
if fj < 0.055 or fi < 0.055:
|
||||
base_r = 0.86 # joints are matte
|
||||
return base_r + 0.06 * fractal(px * 0.2, py * 0.2, 3, 7)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
def stucco():
|
||||
def base(u, v, px, py):
|
||||
blot = fractal(px * 0.03, py * 0.03, 4, 21)
|
||||
tooth = fractal(px * 0.9, py * 0.9, 3, 22)
|
||||
value = 0.58 + 0.12 * (blot - 0.5) + 0.05 * (tooth - 0.5)
|
||||
return (value * 1.03, value, value * 0.96)
|
||||
|
||||
def rough(u, v, px, py):
|
||||
return 0.74 + 0.18 * fractal(px * 0.06, py * 0.06, 3, 31)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
def asphalt():
|
||||
def base(u, v, px, py):
|
||||
agg = fractal(px * 1.4, py * 1.4, 3, 41) # fine aggregate
|
||||
patch = fractal(px * 0.02, py * 0.02, 3, 42) # repair patches
|
||||
value = 0.055 + 0.05 * (patch - 0.5) + 0.06 * (agg - 0.5)
|
||||
return (value, value, value * 1.05) # cool bitumen
|
||||
|
||||
def rough(u, v, px, py):
|
||||
return 0.90 + 0.08 * fractal(px * 0.5, py * 0.5, 2, 43)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
def concrete():
|
||||
def base(u, v, px, py):
|
||||
pores = fractal(px * 0.8, py * 0.8, 3, 51)
|
||||
tone = fractal(px * 0.04, py * 0.04, 4, 52)
|
||||
value = 0.50 + 0.10 * (tone - 0.5) + 0.05 * (pores - 0.5)
|
||||
return (value * 1.01, value, value * 0.97)
|
||||
|
||||
def rough(u, v, px, py):
|
||||
return 0.84 + 0.10 * fractal(px * 0.15, py * 0.15, 3, 53)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
TEXTURES = {
|
||||
"T_StoneAshlar": stone_ashlar(),
|
||||
"T_Stucco": stucco(),
|
||||
"T_Asphalt": asphalt(),
|
||||
"T_Concrete": concrete(),
|
||||
}
|
||||
|
||||
|
||||
def render(name, pattern, size):
|
||||
"""Render one texture; returns {suffix: path} for _BC and _R."""
|
||||
base_fn, rough_fn = pattern
|
||||
paths = {}
|
||||
for suffix, fn in (("BC", base_fn), ("R", rough_fn)):
|
||||
# Wrap in a function so the inner loop stays tight.
|
||||
rows = []
|
||||
for y in range(size):
|
||||
v = y / size
|
||||
row = bytearray()
|
||||
for x in range(size):
|
||||
u = x / size
|
||||
value = fn(u, v, x / size * size / 64.0, y / size * size / 64.0)
|
||||
if isinstance(value, tuple):
|
||||
row += bytes((clamp8(value[0]), clamp8(value[1]), clamp8(value[2])))
|
||||
else:
|
||||
grey = clamp8(value)
|
||||
row += bytes((grey, grey, grey))
|
||||
rows.append(bytes(row))
|
||||
path = os.path.join(OUT_DIR, f"{name}_{suffix}.png")
|
||||
write_png(path, size, size, rows)
|
||||
paths[suffix] = path
|
||||
return paths
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
global OUT_DIR
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default="/tmp/gigline/textures",
|
||||
help="output directory (default /tmp/gigline/textures)")
|
||||
ap.add_argument("--size", type=int, default=512, help="edge length in pixels")
|
||||
args = ap.parse_args(argv)
|
||||
OUT_DIR = args.out
|
||||
|
||||
print(f"writing {len(TEXTURES) * 2} maps at {args.size}² to {OUT_DIR}")
|
||||
for name, pattern in TEXTURES.items():
|
||||
paths = render(name, pattern, args.size)
|
||||
for suffix, path in paths.items():
|
||||
print(f" {os.path.basename(path):28} {os.path.getsize(path):>8} bytes")
|
||||
return 0
|
||||
|
||||
|
||||
OUT_DIR = "/tmp/gigline/textures"
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -16,10 +16,13 @@
|
||||
# deterministically instead of stacking duplicates.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
MAP = "/Game/Hollywood/DemoMap"
|
||||
# Repository root, so the texture generator can be invoked by path.
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
RESULT = "/tmp/gigline/demo-map.txt"
|
||||
lines = []
|
||||
|
||||
@@ -50,7 +53,46 @@ def spawn(cls, loc, label, rot=None):
|
||||
return actor
|
||||
|
||||
|
||||
def ensure_material(mat_name, spec):
|
||||
TEXTURE_DIR = "/Game/Hollywood/Textures"
|
||||
|
||||
|
||||
def import_texture(png_path, asset_name):
|
||||
"""Import a generated PNG as a Texture2D (idempotent: replaces the asset)."""
|
||||
if not os.path.exists(png_path):
|
||||
log(f"texture missing: {png_path}")
|
||||
return None
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = png_path
|
||||
task.destination_path = TEXTURE_DIR
|
||||
task.destination_name = asset_name
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
ui = unreal.TextureImportUI() if hasattr(unreal, "TextureImportUI") else None
|
||||
if ui:
|
||||
task.options = ui
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
return unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{asset_name}")
|
||||
|
||||
|
||||
def ensure_textures():
|
||||
"""Generate/point at the facade maps and import them. Returns {name: asset}."""
|
||||
out_dir = "/tmp/gigline/textures"
|
||||
gen = os.path.join(ROOT, "Tools", "generate_facade_textures.py")
|
||||
if os.path.exists(gen) and not os.path.exists(os.path.join(out_dir, "T_StoneAshlar_BC.png")):
|
||||
import subprocess
|
||||
subprocess.run([sys.executable, gen, "--out", out_dir, "--size", "512"], check=False)
|
||||
imported = {}
|
||||
for name in ("T_StoneAshlar_BC", "T_StoneAshlar_R", "T_Stucco_BC", "T_Stucco_R",
|
||||
"T_Asphalt_BC", "T_Asphalt_R", "T_Concrete_BC", "T_Concrete_R"):
|
||||
asset = import_texture(os.path.join(out_dir, name + ".png"), name)
|
||||
if asset:
|
||||
imported[name] = asset
|
||||
log(f"textures imported: {len(imported)}/8")
|
||||
return imported
|
||||
|
||||
|
||||
def ensure_material(mat_name, spec, textures=None):
|
||||
"""Create/reuse a look-developed material under /Game/Hollywood/Site.
|
||||
|
||||
Blender's flat diffuse colours do not survive the FBX -> Unreal import (the
|
||||
@@ -165,6 +207,44 @@ def ensure_material(mat_name, spec):
|
||||
link(joint, tinted, "Alpha")
|
||||
base_color_expr = tinted
|
||||
|
||||
# If a texture set is supplied for this material, sample it world-aligned and
|
||||
# use it instead of the graph-only coursing: the map carries the coursing and
|
||||
# the grain, so overlaying both would double the pattern.
|
||||
tex = (textures or {}).get(spec.get("texture", ""))
|
||||
if tex:
|
||||
bc = (textures or {}).get(spec.get("texture", "") + "_BC")
|
||||
# world position (cm) -> UV: one texture tile every tile_cm centimetres
|
||||
tile_cm = float(spec.get("tile_cm", 300.0))
|
||||
uv_scale = node(unreal.MaterialExpressionMultiply)
|
||||
link(wp, uv_scale, "A")
|
||||
link(scalar(1.0 / tile_cm), uv_scale, "B")
|
||||
mask_xy = node(unreal.MaterialExpressionComponentMask)
|
||||
for pin in ("R", "G"):
|
||||
try:
|
||||
mask_xy.set_editor_property(pin, True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
mask_xy.set_editor_property("B", False)
|
||||
mask_xy.set_editor_property("A", False)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
link(uv_scale, mask_xy)
|
||||
if bc:
|
||||
sample = node(unreal.MaterialExpressionTextureSample)
|
||||
sample.set_editor_property("texture", bc)
|
||||
link(mask_xy, sample, "UVs")
|
||||
lib.connect_material_property(sample, "RGB",
|
||||
unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
base_color_expr = sample
|
||||
r_map = (textures or {}).get(spec.get("texture", "") + "_R")
|
||||
if r_map:
|
||||
r_sample = node(unreal.MaterialExpressionTextureSample)
|
||||
r_sample.set_editor_property("texture", r_map)
|
||||
link(mask_xy, r_sample, "UVs")
|
||||
lib.connect_material_property(r_sample, "R",
|
||||
unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
|
||||
lib.connect_material_property(base_color_expr, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
|
||||
# Roughness: lerp(base, base + variation, noise) so the highlight breaks up.
|
||||
@@ -337,9 +417,11 @@ def build():
|
||||
# Look-developed surface materials (see ensure_material for the graph).
|
||||
MATERIAL_SPECS = {
|
||||
"M_Asphalt": dict(dark=(0.040, 0.040, 0.044), bright=(0.072, 0.072, 0.078),
|
||||
roughness=0.92, specular=0.22, noise_scale=0.006),
|
||||
roughness=0.92, specular=0.22, noise_scale=0.006,
|
||||
texture="T_Asphalt", tile_cm=900.0),
|
||||
"M_Concrete": dict(dark=(0.44, 0.42, 0.39), bright=(0.56, 0.54, 0.50),
|
||||
roughness=0.88, specular=0.28, noise_scale=0.004),
|
||||
roughness=0.88, specular=0.28, noise_scale=0.004,
|
||||
texture="T_Concrete", tile_cm=250.0),
|
||||
"M_KerbConcrete": dict(dark=(0.47, 0.45, 0.42), bright=(0.60, 0.58, 0.54),
|
||||
roughness=0.85, specular=0.30, noise_scale=0.008),
|
||||
"M_Ground": dict(dark=(0.22, 0.20, 0.17), bright=(0.33, 0.30, 0.26),
|
||||
@@ -352,12 +434,13 @@ def build():
|
||||
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.76, 0.72, 0.64),
|
||||
roughness=0.70, specular=0.32, noise_scale=0.005,
|
||||
courses_m=0.55, joint_width=0.11,
|
||||
joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12),
|
||||
joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12,
|
||||
texture="T_StoneAshlar", tile_cm=440.0),
|
||||
# Rendered/stucco frontage: no coursing, but a fine tooth and a wider
|
||||
# roughness range than before, because flat stucco is what read as plastic.
|
||||
"M_Stucco": dict(dark=(0.50, 0.46, 0.40), bright=(0.68, 0.64, 0.57),
|
||||
roughness=0.76, specular=0.25, noise_scale=0.0025,
|
||||
roughness_variation=0.20),
|
||||
roughness_variation=0.20, texture="T_Stucco", tile_cm=260.0),
|
||||
# Stone in shadow: the hero's recessed wall behind the colonnade. Same
|
||||
# coursing as the face, a stop darker, so depth reads without a light.
|
||||
"M_FacadeStoneShadow": dict(dark=(0.40, 0.37, 0.32), bright=(0.52, 0.49, 0.43),
|
||||
@@ -385,9 +468,10 @@ def build():
|
||||
"Kerb_North": "M_KerbConcrete",
|
||||
"Crossing_Markings": "M_RoadPaint",
|
||||
}
|
||||
textures = ensure_textures()
|
||||
built = {}
|
||||
for mat_name, spec in MATERIAL_SPECS.items():
|
||||
built[mat_name] = ensure_material(mat_name, spec)
|
||||
built[mat_name] = ensure_material(mat_name, spec, textures)
|
||||
|
||||
imported = 0
|
||||
for name, mat_name in SURFACE_MATERIALS.items():
|
||||
|
||||
Reference in new issue
Block a user