715 lines
28 KiB
Python
715 lines
28 KiB
Python
"""Export a Blender scene (from generate_scene.py) as a Cesium-ready GLB.
|
|
|
|
The authoring scene intentionally uses a few Blender-only nodes (for example
|
|
the grass tint and procedural tree crown variation). glTF has a smaller
|
|
material vocabulary, so this exporter creates temporary, export-only PBR
|
|
materials, unwraps the meshes, and embeds all referenced images in the GLB.
|
|
|
|
The model remains in a local ENU frame: X east, Y north, Z up. Use the
|
|
companion JSON file to place the GLB with Cesium.Transforms.eastNorthUpToFixedFrame.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
import numpy as np
|
|
|
|
# --factory-startup does not put the script's own directory on sys.path, so the
|
|
# osmassets package next to this file is not importable without this.
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
if _HERE not in sys.path:
|
|
sys.path.insert(0, _HERE)
|
|
|
|
from osmassets.materials import CESIUM_EXPORT_PROPERTY, link_alpha_clip # noqa: E402
|
|
|
|
|
|
# Marks a material this exporter produced, so a second pass over an instanced
|
|
# mesh's shared slots can recognise its own output and leave it alone.
|
|
EXPORT_PREFIX = "Cesium "
|
|
|
|
# Top-level collections are the authoring scene's stable semantic boundary.
|
|
# Their numeric prefixes are draw-order labels in Blender, not part of the
|
|
# Cesium asset contract below.
|
|
SEMANTIC_ASSETS = (
|
|
("roads", "道路", ("03_Roads",)),
|
|
("buildings", "建筑", ("04_Buildings",)),
|
|
("vegetation", "绿化与设施", ("02_Green", "05_Props")),
|
|
("water", "水体", ("01_Water",)),
|
|
)
|
|
|
|
# Fraction of its own albedo a cut-out foliage material emits, to keep the
|
|
# shadowed side of a crown off Cesium's near-black ambient floor. Kept well
|
|
# under the 0.18 the buildings use: a tree still has to read as lit from one
|
|
# side, it just must not go to black.
|
|
FOLIAGE_EMISSION = 0.25
|
|
|
|
# Multiplier on a cut-out foliage albedo before export.
|
|
#
|
|
# This legacy profile exists for dark alpha-cut foliage from older .blend files.
|
|
# Rendered at true albedo those assets are correct, but nothing else in this
|
|
# scene is at true albedo. Every other material goes through Cesium export
|
|
# contracts (grass mixes 72% toward a bright green, the ribbed facade 86% toward
|
|
# white, 0.18 emission on the buildings), all hand-tuned against Cesium's
|
|
# washed-out default lighting. A new asset dropped in untuned is the one thing
|
|
# rendering honestly, and next to the rest it reads as black.
|
|
#
|
|
# A gain rather than a tint, because a tint is what the other materials use and
|
|
# it is wrong here: they are single-surface, this is an atlas holding leaves,
|
|
# bark and fruit at once, and mixing it toward green would turn the trunk
|
|
# green. Scaling preserves the hue relationships and just lifts the whole
|
|
# thing into the same exposure as its neighbours.
|
|
FOLIAGE_ALBEDO_GAIN = 2.1
|
|
|
|
# Saturation multiplier applied with the gain, around each texel's own
|
|
# luminance. The gain alone lifts the crown to the right brightness but leaves
|
|
# it reading grey-green at distance: alpha-cut atlases are often desaturated to
|
|
# begin with, and mip-averaging a crown mixes leaves with bark and sky-gaps,
|
|
# pulling it further toward neutral exactly when the tree gets small.
|
|
#
|
|
# Scaling the distance from luminance pushes the leaves green without touching
|
|
# what is already neutral much, and without the hue shift a green tint would
|
|
# force on the trunk — bark just becomes a warmer brown, which it should be.
|
|
FOLIAGE_SATURATION = 1.75
|
|
|
|
# Shapespark foliage is already graded brighter than legacy cut-out foliage.
|
|
# Reusing the heavy gain/saturation/emission makes it read yellow-green and
|
|
# glowing in Cesium, so these card materials get a gentler export profile.
|
|
SHAPESPARK_FOLIAGE_ALBEDO_GAIN = 1.12
|
|
SHAPESPARK_FOLIAGE_SATURATION = 1.0
|
|
SHAPESPARK_FOLIAGE_EMISSION = 0.06
|
|
SHAPESPARK_FOLIAGE_PREFIXES = (
|
|
"branch-", "shrubbery-", "high-grass-", "hedge-",
|
|
"clover-", "flowers-",
|
|
)
|
|
|
|
# Legacy fallback for .blend files created before materials carried their own
|
|
# `cesium_export` custom property. New scenes should get these values from
|
|
# catalog.MATERIALS[*]["cesium"], serialized by osmassets.materials.from_spec().
|
|
EXPORT_TINTS = {
|
|
"Grass": ((0.12, 0.48, 0.08), 0.72),
|
|
"Tree Crown Dark": ((0.06, 0.22, 0.05), 0.18),
|
|
"Tree Crown Light": ((0.16, 0.42, 0.09), 0.16),
|
|
"Scrub Ground Cover": ((0.08, 0.28, 0.07), 0.28),
|
|
"Office White Plaster Facade": ((0.92, 0.94, 0.92), 0.38),
|
|
"Office White Metal Facade": ((0.92, 0.94, 0.92), 0.68),
|
|
"Office Light Flat Roof": ((0.82, 0.86, 0.88), 0.35),
|
|
"Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.86),
|
|
"Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.58),
|
|
}
|
|
|
|
EXPORT_METALLIC_OVERRIDES = {
|
|
"Office White Plaster Facade": 0.0,
|
|
"Office White Metal Facade": 0.0,
|
|
"Industrial White Ribbed Facade": 0.08,
|
|
}
|
|
|
|
EXPORT_BASE_COLOR_OVERRIDES = {
|
|
"Tree Crown": (0.11, 0.34, 0.075),
|
|
"Tree Crown Dark": (0.065, 0.24, 0.055),
|
|
"Tree Crown Light": (0.14, 0.40, 0.085),
|
|
"Office White Plaster Facade": (0.93, 0.94, 0.91),
|
|
"Office White Metal Facade": (0.93, 0.94, 0.91),
|
|
"Office Light Flat Roof": (0.88, 0.90, 0.88),
|
|
}
|
|
|
|
EXPORT_EMISSION_OVERRIDES = {
|
|
"Tree Crown": ((0.04, 0.11, 0.035), 0.02),
|
|
"Tree Crown Dark": ((0.025, 0.07, 0.02), 0.015),
|
|
"Tree Crown Light": ((0.045, 0.12, 0.03), 0.015),
|
|
"Office White Plaster Facade": ((0.93, 0.94, 0.91), 0.18),
|
|
"Office White Metal Facade": ((0.93, 0.94, 0.91), 0.18),
|
|
"Office Light Flat Roof": ((0.88, 0.90, 0.88), 0.14),
|
|
"Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.18),
|
|
"Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.12),
|
|
}
|
|
|
|
|
|
def cli_args():
|
|
values = {"blend": None, "glb": None, "metadata": None}
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
i = 0
|
|
while i < len(argv):
|
|
if argv[i].startswith("--") and i + 1 < len(argv):
|
|
values[argv[i][2:]] = argv[i + 1]
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
if not values.get("blend"):
|
|
raise RuntimeError("--blend is required")
|
|
if not values.get("glb"):
|
|
raise RuntimeError("--glb is required")
|
|
if not values.get("metadata"):
|
|
raise RuntimeError("--metadata is required")
|
|
return values
|
|
|
|
|
|
def image_for(material, want_normal=False):
|
|
candidates = []
|
|
for node in material.node_tree.nodes:
|
|
if node.type != "TEX_IMAGE" or not node.image:
|
|
continue
|
|
name = os.path.basename(node.image.name).lower()
|
|
is_normal = "_nor_" in name or "_normal" in name or "_bump" in name
|
|
if is_normal == want_normal:
|
|
candidates.append(node.image)
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def source_color(material):
|
|
color = tuple(material.diffuse_color[:3])
|
|
if len(color) != 3:
|
|
return (0.5, 0.5, 0.5)
|
|
return color
|
|
|
|
|
|
def cesium_contract(material):
|
|
payload = material.get(CESIUM_EXPORT_PROPERTY)
|
|
if not payload:
|
|
return {}
|
|
try:
|
|
if isinstance(payload, str):
|
|
payload = json.loads(payload)
|
|
except (TypeError, ValueError):
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def principled_bsdf(material):
|
|
if not material.use_nodes:
|
|
return None
|
|
for node in material.node_tree.nodes:
|
|
if node.type == "BSDF_PRINCIPLED":
|
|
return node
|
|
return None
|
|
|
|
|
|
def source_principled_value(material, input_name, fallback):
|
|
node = principled_bsdf(material)
|
|
if not node or input_name not in node.inputs:
|
|
return fallback
|
|
return node.inputs[input_name].default_value
|
|
|
|
|
|
def source_texture_scale(material):
|
|
for node in material.node_tree.nodes:
|
|
if node.type == "MAPPING":
|
|
return tuple(node.inputs["Scale"].default_value[:3])
|
|
return (1.0, 1.0, 1.0)
|
|
|
|
|
|
def source_alpha_clipped(material):
|
|
"""Whether the source material carries its silhouette in a texture alpha.
|
|
|
|
Two conditions, because either alone gives a wrong answer. A link into the
|
|
Principled Alpha input is not enough: retired glTF lawn assets arrived with
|
|
a Math node wired there even though the JPEG diffuse was opaque, and taking
|
|
that at face value re-encoded an opaque texture as a PNG and made Cesium
|
|
alpha-test hundreds of tufts for nothing. An alpha channel alone is not
|
|
enough either; it can be fully opaque.
|
|
|
|
So ask both — the author wired alpha, and the texture actually cuts.
|
|
"""
|
|
node = principled_bsdf(material)
|
|
if not node or "Alpha" not in node.inputs:
|
|
return False
|
|
if not node.inputs["Alpha"].links:
|
|
return False
|
|
diffuse = image_for(material, want_normal=False)
|
|
return diffuse is not None and image_has_cutout(diffuse)
|
|
|
|
|
|
def foliage_export_profile(material):
|
|
name = material.name.lower()
|
|
if name.startswith(SHAPESPARK_FOLIAGE_PREFIXES):
|
|
return (
|
|
SHAPESPARK_FOLIAGE_ALBEDO_GAIN,
|
|
SHAPESPARK_FOLIAGE_SATURATION,
|
|
SHAPESPARK_FOLIAGE_EMISSION,
|
|
)
|
|
return FOLIAGE_ALBEDO_GAIN, FOLIAGE_SATURATION, FOLIAGE_EMISSION
|
|
|
|
|
|
_CUTOUT_CACHE = {}
|
|
|
|
|
|
def image_has_cutout(image, threshold=0.5):
|
|
"""Whether any of the image's texels are transparent enough to be cut.
|
|
|
|
A full pass over the pixel buffer, so memoise it — the exporter asks once
|
|
per material and several materials can share one texture.
|
|
"""
|
|
if image.name in _CUTOUT_CACHE:
|
|
return _CUTOUT_CACHE[image.name]
|
|
width, height = image.size
|
|
result = False
|
|
if width and height:
|
|
alpha = np.empty(width * height * 4, dtype=np.float32)
|
|
image.pixels.foreach_get(alpha)
|
|
result = bool((alpha[3::4] < threshold).any())
|
|
_CUTOUT_CACHE[image.name] = result
|
|
return result
|
|
|
|
|
|
def tinted_image(source, name, tint, factor):
|
|
existing = bpy.data.images.get(name)
|
|
if existing:
|
|
return existing
|
|
width, height = source.size
|
|
pixels = np.empty(width * height * 4, dtype=np.float32)
|
|
source.pixels.foreach_get(pixels)
|
|
rgba = pixels.reshape((-1, 4))
|
|
rgba[:, :3] = rgba[:, :3] * (1.0 - factor) + np.asarray(
|
|
tint, dtype=np.float32) * factor
|
|
result = bpy.data.images.new(name, width=width, height=height, alpha=True)
|
|
result.file_format = "PNG"
|
|
result.colorspace_settings.name = "sRGB"
|
|
result.pixels.foreach_set(pixels)
|
|
result.pack()
|
|
return result
|
|
|
|
|
|
def cesium_tinted_image(material, source, tint):
|
|
if not tint or not source:
|
|
return source
|
|
color, factor = tint
|
|
safe_name = material.name.replace(" ", "_")
|
|
return tinted_image(
|
|
source, f"{EXPORT_PREFIX}{safe_name} Baked", color, factor)
|
|
|
|
|
|
def alpha_dilated_image(source, name, threshold=0.5, passes=8, gain=1.0,
|
|
saturation=1.0):
|
|
"""Flood the opaque colour outward underneath the cut-out, and lift it.
|
|
|
|
Some legacy leaf-card atlases write pure black wherever a card is cut away.
|
|
An alpha mask hides that at full resolution, but Cesium mip-maps the texture
|
|
and every mip level averages those black texels into the leaf edges, so the
|
|
crown grows a dark fringe that thickens with distance. Blender's preview
|
|
renders at mip 0 and never shows it, which is why this only surfaces in the
|
|
viewer.
|
|
|
|
Replacing the colour under the cut-out with its nearest opaque neighbours
|
|
leaves no black to bleed. Alpha is copied through untouched, so the
|
|
silhouette is byte-for-byte what it was.
|
|
|
|
`gain` and `saturation` grade the result into the same exposure and colour
|
|
as the rest of the scene — see FOLIAGE_ALBEDO_GAIN and FOLIAGE_SATURATION.
|
|
Both are applied after the flood so the filled border keeps matching the
|
|
leaves it was copied from, and the result is clipped at 1.0.
|
|
"""
|
|
existing = bpy.data.images.get(name)
|
|
if existing:
|
|
return existing
|
|
width, height = source.size
|
|
pixels = np.empty(width * height * 4, dtype=np.float32)
|
|
source.pixels.foreach_get(pixels)
|
|
rgba = pixels.reshape((height, width, 4))
|
|
rgb = rgba[..., :3].copy()
|
|
filled = rgba[..., 3] >= threshold
|
|
|
|
# Each pass pushes the colour one texel further out, so `passes` is how
|
|
# many mip levels' worth of filter footprint gets covered.
|
|
for _ in range(passes):
|
|
if filled.all():
|
|
break
|
|
weight = filled[..., None].astype(np.float32)
|
|
total = np.zeros_like(rgb)
|
|
count = np.zeros((height, width, 1), dtype=np.float32)
|
|
for shift, axis in ((1, 0), (-1, 0), (1, 1), (-1, 1)):
|
|
total += np.roll(rgb * weight, shift, axis=axis)
|
|
count += np.roll(weight, shift, axis=axis)
|
|
edge = (~filled) & (count[..., 0] > 0)
|
|
rgb[edge] = total[edge] / count[edge]
|
|
filled = filled | edge
|
|
|
|
if saturation != 1.0:
|
|
# Rec.709 luminance, so the push is around perceived brightness rather
|
|
# than the channel average.
|
|
luma = rgb @ np.asarray([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
rgb = luma[..., None] + (rgb - luma[..., None]) * saturation
|
|
if gain != 1.0 or saturation != 1.0:
|
|
rgb = np.clip(rgb * gain, 0.0, 1.0)
|
|
|
|
dilated = rgba.copy()
|
|
dilated[..., :3] = rgb
|
|
result = bpy.data.images.new(name, width=width, height=height, alpha=True)
|
|
result.file_format = "PNG"
|
|
result.colorspace_settings.name = "sRGB"
|
|
result.pixels.foreach_set(dilated.ravel())
|
|
result.pack()
|
|
return result
|
|
|
|
|
|
def tree_crown_image():
|
|
name = "Cesium Tree Crown Baked"
|
|
existing = bpy.data.images.get(name)
|
|
if existing:
|
|
return existing
|
|
size = 256
|
|
x, y = np.meshgrid(
|
|
np.linspace(0.0, 1.0, size, dtype=np.float32),
|
|
np.linspace(0.0, 1.0, size, dtype=np.float32),
|
|
)
|
|
noise = (
|
|
np.sin((x * 17.0 + y * 7.0) * np.pi) * 0.24 +
|
|
np.sin((x * 43.0 - y * 31.0) * np.pi) * 0.13 +
|
|
np.sin((x * 89.0 + y * 67.0) * np.pi) * 0.07
|
|
)
|
|
noise = np.clip(0.5 + noise, 0.0, 1.0)[..., None]
|
|
dark = np.asarray((0.04, 0.17, 0.04), dtype=np.float32)
|
|
light = np.asarray((0.17, 0.46, 0.115), dtype=np.float32)
|
|
rgb = dark + (light - dark) * noise
|
|
rgba = np.concatenate(
|
|
(rgb, np.ones((size, size, 1), dtype=np.float32)), axis=2)
|
|
result = bpy.data.images.new(name, width=size, height=size, alpha=True)
|
|
result.file_format = "PNG"
|
|
result.colorspace_settings.name = "sRGB"
|
|
result.pixels.foreach_set(rgba.ravel())
|
|
result.pack()
|
|
return result
|
|
|
|
|
|
def make_export_material(material):
|
|
contract = cesium_contract(material)
|
|
result = material.copy()
|
|
result.name = EXPORT_PREFIX + material.name
|
|
if CESIUM_EXPORT_PROPERTY in result:
|
|
del result[CESIUM_EXPORT_PROPERTY]
|
|
result.use_nodes = True
|
|
nodes = result.node_tree.nodes
|
|
links = result.node_tree.links
|
|
nodes.clear()
|
|
|
|
output = nodes.new("ShaderNodeOutputMaterial")
|
|
output.location = (520, 0)
|
|
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
|
|
bsdf.location = (250, 0)
|
|
has_base_color_override = (
|
|
"base_color" in contract or
|
|
material.name in EXPORT_BASE_COLOR_OVERRIDES
|
|
)
|
|
base_color = contract.get("base_color", EXPORT_BASE_COLOR_OVERRIDES.get(
|
|
material.name, source_color(material)))
|
|
bsdf.inputs["Base Color"].default_value = (*base_color, 1.0)
|
|
bsdf.inputs["Roughness"].default_value = source_principled_value(
|
|
material, "Roughness", 0.8)
|
|
bsdf.inputs["Metallic"].default_value = contract.get(
|
|
"metallic", EXPORT_METALLIC_OVERRIDES.get(
|
|
material.name, source_principled_value(material, "Metallic", 0.0)))
|
|
emission = contract.get(
|
|
"emission", EXPORT_EMISSION_OVERRIDES.get(material.name))
|
|
if emission:
|
|
emission_color, emission_strength = emission
|
|
if "Emission Color" in bsdf.inputs:
|
|
bsdf.inputs["Emission Color"].default_value = (*emission_color, 1.0)
|
|
elif "Emission" in bsdf.inputs:
|
|
bsdf.inputs["Emission"].default_value = (*emission_color, 1.0)
|
|
if "Emission Strength" in bsdf.inputs:
|
|
bsdf.inputs["Emission Strength"].default_value = emission_strength
|
|
links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
|
|
|
|
diffuse = image_for(material, want_normal=False)
|
|
normal = image_for(material, want_normal=True)
|
|
# Foliage that carries its silhouette in the texture's alpha has to keep
|
|
# that channel; every other material is flattened to opaque below.
|
|
alpha_clipped = source_alpha_clipped(material)
|
|
foliage_gain, foliage_saturation, foliage_emission = foliage_export_profile(material)
|
|
if material.name == "Tree Crown":
|
|
diffuse = tree_crown_image()
|
|
else:
|
|
tint = contract.get("tint", EXPORT_TINTS.get(material.name))
|
|
diffuse = cesium_tinted_image(material, diffuse, tint)
|
|
if alpha_clipped and diffuse is not None:
|
|
safe_name = material.name.replace(" ", "_")
|
|
diffuse = alpha_dilated_image(
|
|
diffuse, f"{EXPORT_PREFIX}{safe_name} Dilated",
|
|
gain=foliage_gain, saturation=foliage_saturation)
|
|
if has_base_color_override:
|
|
diffuse = None
|
|
normal = None
|
|
mapping = None
|
|
if diffuse or normal:
|
|
texcoord = nodes.new("ShaderNodeTexCoord")
|
|
texcoord.location = (-650, 0)
|
|
mapping = nodes.new("ShaderNodeMapping")
|
|
mapping.location = (-450, 0)
|
|
mapping.inputs["Scale"].default_value = source_texture_scale(material)
|
|
links.new(texcoord.outputs["UV"], mapping.inputs["Vector"])
|
|
|
|
diffuse_node = None
|
|
if diffuse:
|
|
image = nodes.new("ShaderNodeTexImage")
|
|
image.location = (-200, 80)
|
|
image.image = diffuse
|
|
image.extension = "REPEAT"
|
|
links.new(mapping.outputs["Vector"], image.inputs["Vector"])
|
|
links.new(image.outputs["Color"], bsdf.inputs["Base Color"])
|
|
diffuse_node = image
|
|
|
|
if normal:
|
|
normal_tex = nodes.new("ShaderNodeTexImage")
|
|
normal_tex.location = (-200, -180)
|
|
normal_tex.image = normal
|
|
normal_tex.image.colorspace_settings.name = "Non-Color"
|
|
normal_tex.extension = "REPEAT"
|
|
normal_map = nodes.new("ShaderNodeNormalMap")
|
|
normal_map.location = (20, -160)
|
|
normal_map.inputs["Strength"].default_value = 0.52
|
|
links.new(mapping.outputs["Vector"], normal_tex.inputs["Vector"])
|
|
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
|
|
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
|
|
|
|
if alpha_clipped and diffuse_node is not None:
|
|
# A leaf crown is a handful of quads whose shape lives entirely in this
|
|
# channel. Pinning Alpha to 1.0 — which is what the rest of the scene
|
|
# wants — exports those quads whole, and the cut-away regions of a
|
|
# SpeedTree atlas are black, so Cesium draws black slabs.
|
|
link_alpha_clip(result, diffuse_node.outputs["Alpha"], bsdf,
|
|
cutoff=material.alpha_threshold)
|
|
# Lift the crown out of Cesium's ambient. The preview configures no
|
|
# environment map, so anything the sun does not hit directly falls to a
|
|
# weak default spherical-harmonic term — which is why every other
|
|
# material here carries an emission override. A crown is mostly
|
|
# self-shadowed leaf cards facing away from the sun, so at distance it
|
|
# collapses into one dark mass while a sunlit close-up still reads fine.
|
|
#
|
|
# Feed the diffuse back in as the emissive texture rather than using a
|
|
# flat colour: a constant would wash the bark with leaf green, whereas
|
|
# this floors every texel at a fraction of its own albedo. It costs no
|
|
# extra bytes — the exporter points emissiveTexture at the image the
|
|
# base colour already uses.
|
|
if "Emission Color" in bsdf.inputs:
|
|
links.new(diffuse_node.outputs["Color"], bsdf.inputs["Emission Color"])
|
|
elif "Emission" in bsdf.inputs:
|
|
links.new(diffuse_node.outputs["Color"], bsdf.inputs["Emission"])
|
|
if "Emission Strength" in bsdf.inputs:
|
|
bsdf.inputs["Emission Strength"].default_value = foliage_emission
|
|
elif "Alpha" in bsdf.inputs:
|
|
bsdf.inputs["Alpha"].default_value = 1.0
|
|
return result
|
|
|
|
|
|
def unwrap_mesh(obj):
|
|
if obj.type != "MESH" or not obj.data.polygons:
|
|
return
|
|
# Imported assets ship authored UVs that map onto their own texture atlas;
|
|
# smart_project would scramble the leaves. Only the procedurally built
|
|
# meshes arrive without a UV layer, so that is the reliable discriminator.
|
|
if obj.data.uv_layers:
|
|
return
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
bpy.ops.object.mode_set(mode="EDIT")
|
|
bpy.ops.mesh.select_all(action="SELECT")
|
|
try:
|
|
bpy.ops.uv.smart_project(island_margin=0.025, area_weight=0.0)
|
|
finally:
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
|
|
|
|
def apply_mesh_modifiers(obj):
|
|
if obj.type != "MESH":
|
|
return
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
for modifier in list(obj.modifiers):
|
|
try:
|
|
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
|
except RuntimeError:
|
|
pass
|
|
|
|
|
|
def triangulate_mesh(obj):
|
|
"""Split n-gons into triangles ahead of the exporter.
|
|
|
|
glTF has no n-gons, so the exporter triangulates on the way out regardless
|
|
— doing it here does not change a single output triangle. What it changes
|
|
is tangents: Blender can only build a tangent basis on tris and quads, and
|
|
every footprint this pipeline extrudes from OSM is an n-gon, so with
|
|
export_tangents on each one logged "切向空间只能只算三角/四边形" and shipped
|
|
without a basis. Triangulating first turns ~55 failures into tangents.
|
|
|
|
Skipped for meshes that are already triangles, which covers the instanced
|
|
props — those share one datablock across hundreds of objects and
|
|
modifier_apply refuses to touch multi-user data.
|
|
"""
|
|
if obj.type != "MESH" or not obj.data.polygons:
|
|
return
|
|
if all(len(polygon.vertices) <= 3 for polygon in obj.data.polygons):
|
|
return
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
modifier = obj.modifiers.new("ExportTriangulate", "TRIANGULATE")
|
|
modifier.min_vertices = 4
|
|
try:
|
|
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
|
except RuntimeError:
|
|
# Multi-user data. The exporter still triangulates it, we just lose the
|
|
# tangent basis for that mesh.
|
|
obj.modifiers.remove(modifier)
|
|
|
|
|
|
def export_glb(filepath, meshes):
|
|
"""Export a prepared object subset without changing authoring visibility."""
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for obj in meshes:
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = meshes[0] if meshes else None
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=filepath,
|
|
export_format="GLB",
|
|
use_selection=True,
|
|
export_apply=False,
|
|
export_texcoords=True,
|
|
export_normals=True,
|
|
export_materials="EXPORT",
|
|
export_image_format="AUTO",
|
|
export_tangents=True,
|
|
export_extras=True,
|
|
export_cameras=False,
|
|
export_lights=False,
|
|
)
|
|
|
|
|
|
def semantic_asset_specs(glb_path, meshes):
|
|
"""Derive optional inspection assets from the scene's named collections."""
|
|
stem, extension = os.path.splitext(glb_path)
|
|
by_collection = {}
|
|
for collection in bpy.context.scene.collection.children:
|
|
by_collection[collection.name] = set(collection.all_objects)
|
|
|
|
specs = []
|
|
for asset_id, label, collection_names in SEMANTIC_ASSETS:
|
|
objects = set()
|
|
for name in collection_names:
|
|
objects.update(by_collection.get(name, set()))
|
|
subset = [obj for obj in meshes if obj in objects]
|
|
if not subset:
|
|
continue
|
|
path = stem + "-" + asset_id + extension
|
|
specs.append({
|
|
"id": asset_id,
|
|
"label": label,
|
|
"path": path,
|
|
"meshes": subset,
|
|
})
|
|
return specs
|
|
|
|
|
|
def export(args):
|
|
if not os.path.exists(args["blend"]):
|
|
raise FileNotFoundError(args["blend"])
|
|
bpy.ops.wm.open_mainfile(filepath=args["blend"])
|
|
|
|
material_map = {}
|
|
meshes = []
|
|
unwrapped = set()
|
|
for obj in bpy.context.scene.objects:
|
|
if obj.type != "MESH":
|
|
continue
|
|
if obj.name == "Ground Plane":
|
|
continue
|
|
if obj.hide_viewport or obj.hide_render:
|
|
continue
|
|
meshes.append(obj)
|
|
apply_mesh_modifiers(obj)
|
|
# Hundreds of grass tufts share four mesh datablocks; unwrapping and
|
|
# triangulating are properties of the mesh, so once per datablock.
|
|
if obj.data.name not in unwrapped:
|
|
unwrapped.add(obj.data.name)
|
|
triangulate_mesh(obj)
|
|
unwrap_mesh(obj)
|
|
for slot in obj.material_slots:
|
|
if not slot.material:
|
|
continue
|
|
source = slot.material
|
|
# Instanced props share one mesh datablock, and material slots live
|
|
# on the mesh, so the first tree already swapped in the export
|
|
# material for all 181 of them. Without this the next instance
|
|
# wraps that result again — "Cesium Cesium Cesium ..." — and since
|
|
# the baked-image cache is keyed by material name, every round
|
|
# embeds another multi-megabyte copy of the same texture.
|
|
if source.name.startswith(EXPORT_PREFIX):
|
|
continue
|
|
if source.name not in material_map:
|
|
material_map[source.name] = make_export_material(source)
|
|
slot.material = material_map[source.name]
|
|
|
|
export_glb(args["glb"], meshes)
|
|
semantic_assets = semantic_asset_specs(args["glb"], meshes)
|
|
for asset in semantic_assets:
|
|
export_glb(asset["path"], asset["meshes"])
|
|
|
|
scene = bpy.context.scene
|
|
try:
|
|
bounds = json.loads(scene.get("osm_bounds", "{}"))
|
|
except (TypeError, ValueError):
|
|
bounds = {}
|
|
if bounds:
|
|
center_lon = (bounds["min_lon"] + bounds["max_lon"]) / 2.0
|
|
center_lat = (bounds["min_lat"] + bounds["max_lat"]) / 2.0
|
|
else:
|
|
center_lon = center_lat = 0.0
|
|
metadata = {
|
|
"asset": os.path.basename(args["glb"]),
|
|
"assets": [{
|
|
"id": "main",
|
|
"label": "Scene",
|
|
"type": "model",
|
|
"url": os.path.basename(args["glb"]),
|
|
"enabled": True,
|
|
}] + [{
|
|
"id": asset["id"],
|
|
"label": asset["label"],
|
|
"type": "model",
|
|
"url": os.path.basename(asset["path"]),
|
|
"enabled": False,
|
|
"category": "semantic",
|
|
} for asset in semantic_assets],
|
|
"coordinate_system": "local ENU meters (X east, Y north, Z up)",
|
|
"heading_correction_degrees": -90.0,
|
|
"anchor": {"longitude": center_lon, "latitude": center_lat, "height": 0.35},
|
|
"bounds": bounds,
|
|
"source_osm": scene.get("source_osm", ""),
|
|
"source_geojson": scene.get("source_geojson", ""),
|
|
"scene_stats": {
|
|
"buildings": scene.get("building_count", 0),
|
|
"industrial_buildings": scene.get("industrial_building_count", 0),
|
|
"lake_polygons": scene.get("lake_count", 0),
|
|
"trees": scene.get("tree_count", 0),
|
|
"grass_polygons": scene.get("grass_count", 0),
|
|
"scrub_polygons": scene.get("scrub_count", 0),
|
|
"fountains": scene.get("fountain_count", 0),
|
|
},
|
|
"cesium_js": (
|
|
"const p = Cesium.Cartesian3.fromDegrees(" +
|
|
f"{center_lon:.8f}, {center_lat:.8f}, 0.35);\n" +
|
|
"const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);\n" +
|
|
"const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(-90.0));\n" +
|
|
"const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4());\n" +
|
|
"Cesium.Model.fromGltfAsync({ url: '" + os.path.basename(args["glb"]) +
|
|
"', modelMatrix }).then(model => viewer.scene.primitives.add(model));"
|
|
),
|
|
}
|
|
os.makedirs(os.path.dirname(args["metadata"]), exist_ok=True)
|
|
with open(args["metadata"], "w", encoding="utf-8") as handle:
|
|
json.dump(metadata, handle, ensure_ascii=False, indent=2)
|
|
handle.write("\n")
|
|
|
|
print("CESIUM_EXPORT_DONE", json.dumps({
|
|
"glb": args["glb"], "metadata": args["metadata"],
|
|
"meshes": len(meshes), "materials": len(material_map),
|
|
"semantic_assets": [asset["id"] for asset in semantic_assets],
|
|
"anchor": [center_lon, center_lat],
|
|
}, ensure_ascii=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
export(cli_args())
|