|
|
@@ -38,6 +38,10 @@ parser.add_argument('--merge-buildings', action='store_true',
|
|
|
parser.add_argument('--lod1-footprint-only', action='store_true',
|
|
|
help='Use each surveyed GIS footprint for LOD1/coarse geometry, including asset buildings')
|
|
|
parser.add_argument('--near-architecture', action='store_true', help='Add bounded window reveals and storey bands at LOD0')
|
|
|
+parser.add_argument('--massing-storey-bands', action='store_true', help='Experimental continuous storey bands on massing LOD0; requires a facade profile or --near-architecture')
|
|
|
+parser.add_argument('--massing-facade-profile', choices=('off', 'compact', 'full'), default='off',
|
|
|
+ help='Opt-in facade quality for surveyed massing: compact windows/bands/eaves without deep reveals, or full near architecture')
|
|
|
+parser.add_argument('--compact-window-texture', help='Opt-in estimated window-frame texture manifest; compact profile only')
|
|
|
parser.add_argument('--roof-textures', help='JSON mapping building IDs to sourced roof image paths and ENU UV bounds')
|
|
|
parser.add_argument('--draco', action='store_true', help='Enable Draco mesh compression in GLB export')
|
|
|
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
|
|
|
@@ -62,7 +66,63 @@ ASSET_FIT = json.loads((ROOT / 'tools/city-massing/asset-fit.json').read_text(en
|
|
|
WALL_TONES = ('Plaster_Cream', 'Plaster_Ivory', 'Plaster_Sand', 'Concrete_Warm')
|
|
|
ROOF_TONES = ('Roof_Slate', 'Roof_Slate_Light', 'Roof_Terracotta', 'Roof_Terra_Light')
|
|
|
ROOF_TEXTURES = json.loads(Path(args.roof_textures).read_text(encoding='utf-8')) if args.roof_textures else {}
|
|
|
-ARCHITECTURE = args.near_architecture
|
|
|
+def facade_profile_flags(profile, near_architecture, storey_bands):
|
|
|
+ """Resolve explicit massing quality without silently upgrading the default path."""
|
|
|
+ if profile == 'full' and not near_architecture:
|
|
|
+ raise RuntimeError('--massing-facade-profile full requires --near-architecture')
|
|
|
+ if storey_bands and not (near_architecture or profile != 'off'):
|
|
|
+ raise RuntimeError('--massing-storey-bands requires --near-architecture or a massing-facade-profile')
|
|
|
+ return {
|
|
|
+ 'profile': profile,
|
|
|
+ 'architecture': near_architecture or profile in ('compact', 'full'),
|
|
|
+ 'fullReveals': near_architecture or profile == 'full',
|
|
|
+ # Compact already gets the ordinary per-edge storey rhythm; the extra
|
|
|
+ # boundary band remains an explicit opt-in for controlled experiments.
|
|
|
+ 'bands': storey_bands,
|
|
|
+ }
|
|
|
+
|
|
|
+PROFILE_FLAGS = facade_profile_flags(args.massing_facade_profile, args.near_architecture, args.massing_storey_bands)
|
|
|
+FACADE_PROFILE = PROFILE_FLAGS['profile']
|
|
|
+FULL_REVEALS = PROFILE_FLAGS['fullReveals']
|
|
|
+ARCHITECTURE = PROFILE_FLAGS['architecture']
|
|
|
+BANDS = PROFILE_FLAGS['bands']
|
|
|
+WINDOW_TEXTURE = None
|
|
|
+if args.compact_window_texture:
|
|
|
+ if FACADE_PROFILE != 'compact' or args.near_architecture:
|
|
|
+ raise RuntimeError('--compact-window-texture requires compact without --near-architecture')
|
|
|
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
+ from window_texture import load_window_texture, window_quad_uv
|
|
|
+ WINDOW_TEXTURE = load_window_texture(args.compact_window_texture)
|
|
|
+
|
|
|
+
|
|
|
+def compact_scope_ids(plan, profile, full_reveals=False, bands=False):
|
|
|
+ ids = plan.get('compactFacadeBuildingIds')
|
|
|
+ if ids is None:
|
|
|
+ return None
|
|
|
+ if profile != 'compact' or full_reveals or bands:
|
|
|
+ raise ValueError('compactFacadeBuildingIds requires compact without reveals or extra bands')
|
|
|
+ if not isinstance(ids, list) or not ids or any(not isinstance(i, str) for i in ids) or len(ids) != len(set(ids)):
|
|
|
+ raise ValueError('compactFacadeBuildingIds must be a nonempty unique ID list')
|
|
|
+ records = {building['id']: building for building in plan['buildings']}
|
|
|
+ if any(i not in records or records[i]['kind'] != 'massing' for i in ids):
|
|
|
+ raise ValueError('compactFacadeBuildingIds must name existing massing records only')
|
|
|
+ return frozenset(ids)
|
|
|
+
|
|
|
+
|
|
|
+COMPACT_BUILDING_IDS = compact_scope_ids(plan, FACADE_PROFILE, FULL_REVEALS, BANDS)
|
|
|
+
|
|
|
+
|
|
|
+def facade_operation(building, operation, *values, **options):
|
|
|
+ """Use legacy facade rules for nonselected context; restore state even on failure."""
|
|
|
+ global FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE
|
|
|
+ if COMPACT_BUILDING_IDS is None or building['id'] in COMPACT_BUILDING_IDS:
|
|
|
+ return operation(*values, **options)
|
|
|
+ previous = FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE
|
|
|
+ try:
|
|
|
+ FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE = 'off', False, False, False, None
|
|
|
+ return operation(*values, **options)
|
|
|
+ finally:
|
|
|
+ FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE = previous
|
|
|
|
|
|
|
|
|
def select_asset(building):
|
|
|
@@ -137,13 +197,69 @@ def facade_window_count(edge_length, level):
|
|
|
"""Small, bounded facade detail count; LOD1 keeps one readable window per edge."""
|
|
|
if level == 'lod1':
|
|
|
return 1
|
|
|
- # Near architecture gets a denser, still bounded facade rhythm; coarse LOD stays intentionally sparse.
|
|
|
- return max(1, min(16 if ARCHITECTURE else 3, int(edge_length / (2.8 if ARCHITECTURE else 3.5))))
|
|
|
+ # Compact LOD0 uses physical-scale bays. The cap bounds the work on unusually
|
|
|
+ # long survey edges; walls beyond 105.6m still require a separate review.
|
|
|
+ if FACADE_PROFILE == 'compact':
|
|
|
+ return 0 if edge_length < 2.4 else max(1, min(32, int(edge_length / 3.2)))
|
|
|
+ # Full near architecture gets a denser, still bounded facade rhythm; coarse LOD stays sparse.
|
|
|
+ return max(1, min(20 if FULL_REVEALS else 3, int(edge_length / (2.4 if FULL_REVEALS else 3.5))))
|
|
|
+
|
|
|
+
|
|
|
+def facade_entrance_edge(footprint):
|
|
|
+ """Compact ties use surveyed endpoint coordinates; legacy profiles keep their order."""
|
|
|
+ if FACADE_PROFILE != 'compact':
|
|
|
+ return max(range(len(footprint)), key=lambda i: math.dist(footprint[i], footprint[(i + 1) % len(footprint)]))
|
|
|
+ return min(range(len(footprint)), key=lambda i: (
|
|
|
+ -round(math.dist(footprint[i], footprint[(i + 1) % len(footprint)]), 6),
|
|
|
+ tuple(sorted((tuple(footprint[i]), tuple(footprint[(i + 1) % len(footprint)]))))))
|
|
|
+
|
|
|
+
|
|
|
+def facade_panel_halfwidth(edge_length, width_ratio=0.42, physical_window=False):
|
|
|
+ """Shared panel sizing for generated geometry and entrance-clearance checks."""
|
|
|
+ return (min(0.8, edge_length * 0.5) if physical_window else
|
|
|
+ min(edge_length * 0.22, max(0.45, edge_length / 3.5 * width_ratio)))
|
|
|
+
|
|
|
+
|
|
|
+def facade_window_positions(edge_length, level, reserve_entrance=False):
|
|
|
+ """Keep upper-floor rhythm; reserve 15cm between compact ground windows and door."""
|
|
|
+ count = facade_window_count(edge_length, level)
|
|
|
+ positions = [(index + 1) / (count + 1) for index in range(count)]
|
|
|
+ if not reserve_entrance or FACADE_PROFILE != 'compact' or not count:
|
|
|
+ return positions
|
|
|
+ span = edge_length / (count + 1) * 0.34
|
|
|
+ window_half = facade_panel_halfwidth(2 * span, physical_window=level == 'lod0')
|
|
|
+ door_half = facade_panel_halfwidth(min(edge_length, 3.5), width_ratio=0.72)
|
|
|
+ clearance = door_half + window_half + 0.15
|
|
|
+ adjusted = []
|
|
|
+ for t in positions:
|
|
|
+ offset = (t - 0.5) * edge_length
|
|
|
+ if abs(offset) >= clearance:
|
|
|
+ adjusted.append(t)
|
|
|
+ continue
|
|
|
+ if abs(offset) < 1e-9:
|
|
|
+ continue # a centered window occupies the entrance; keep the upper one
|
|
|
+ center = edge_length * 0.5 + math.copysign(clearance, offset)
|
|
|
+ # Do not solve a door conflict by making a wall-end or neighboring-window conflict.
|
|
|
+ if center - window_half < 0.15 or center + window_half > edge_length - 0.15:
|
|
|
+ continue
|
|
|
+ if any(abs(center - other * edge_length) < 2 * window_half + 0.15
|
|
|
+ for other in positions if other != t):
|
|
|
+ continue
|
|
|
+ adjusted.append(center / edge_length)
|
|
|
+ return adjusted
|
|
|
|
|
|
|
|
|
-def massing_triangles(footprint, floors, level):
|
|
|
+def massing_triangles(footprint, floors, level, facade_scope='building', facade_mask=None):
|
|
|
"""Reference triangles for a massing prism plus lightweight facade details."""
|
|
|
+ if facade_scope not in ('building', 'shell-only'):
|
|
|
+ raise ValueError('unknown massing facade scope: ' + str(facade_scope))
|
|
|
base = prism_triangles(len(footprint))
|
|
|
+ if facade_scope == 'shell-only':
|
|
|
+ return base
|
|
|
+ mask = facade_mask or {}
|
|
|
+ hidden_windows = mask.get('windows', set())
|
|
|
+ hidden_bands = mask.get('bands', set())
|
|
|
+ longest = facade_entrance_edge(footprint)
|
|
|
windows = 0
|
|
|
for i in range(len(footprint)):
|
|
|
a, b = footprint[i], footprint[(i + 1) % len(footprint)]
|
|
|
@@ -152,19 +268,32 @@ def massing_triangles(footprint, floors, level):
|
|
|
# add_panel/add_window_reveal reject a sub-0.35m window span. Surveyed
|
|
|
# cornices/notches have short edges; they remain in the solid, without windows.
|
|
|
if length / (count + 1) * 0.68 >= 0.35:
|
|
|
- windows += 2 * count * floors
|
|
|
- architectural = (windows * 4 + 2 * len(footprint) * max(0, floors - 1)) if ARCHITECTURE and level == 'lod0' else 0
|
|
|
- return base + windows + architectural + 12 # bounded reveals/bands and one closed entrance
|
|
|
+ windows += 2 * sum(len(facade_window_positions(length, level,
|
|
|
+ reserve_entrance=i == longest and floor == 0 and not mask.get('door')))
|
|
|
+ for floor in range(floors) if (i, floor) not in hidden_windows)
|
|
|
+ roof_edges = sum(math.hypot(footprint[(i + 1) % len(footprint)][0] - a[0], footprint[(i + 1) % len(footprint)][1] - a[1]) >= .35 for i, a in enumerate(footprint)) if FULL_REVEALS else 0
|
|
|
+ storey_bands = sum((i, floor) not in hidden_bands for i in range(len(footprint))
|
|
|
+ for floor in range(1, floors))
|
|
|
+ bands = storey_bands if BANDS and ARCHITECTURE and level == 'lod0' else 0
|
|
|
+ architectural = ((windows * 4 if FULL_REVEALS else 0)
|
|
|
+ + 2 * storey_bands
|
|
|
+ + 2 * roof_edges + bands * 2) if ARCHITECTURE and level == 'lod0' else 0
|
|
|
+ return base + windows + architectural + (0 if mask.get('door') else 12)
|
|
|
|
|
|
|
|
|
def poly_prism(footprint, bottom, top, wall_material, roof_material, anchor, floors, level,
|
|
|
- window_material, door_material):
|
|
|
+ window_material, door_material, facade_scope='building', facade_mask=None):
|
|
|
"""Build a surveyed-outline building with bounded facade details.
|
|
|
|
|
|
The solid uses the exact GIS ring. Windows are shallow, outward-facing quads repeated per
|
|
|
floor and door is one first-floor quad on the longest wall. They add a readable facade without
|
|
|
adding separate Blender objects or changing the building footprint.
|
|
|
"""
|
|
|
+ if facade_scope not in ('building', 'shell-only'):
|
|
|
+ raise ValueError('unknown massing facade scope: ' + str(facade_scope))
|
|
|
+ mask = facade_mask or {}
|
|
|
+ hidden_windows = mask.get('windows', set())
|
|
|
+ hidden_bands = mask.get('bands', set())
|
|
|
# Create the mesh directly: repeated scene-wide select/cube operators make city exports quadratic.
|
|
|
# This is an exterior-only massing shell: no rooms, floors, ceilings, or interior props.
|
|
|
mesh = bpy.data.meshes.new('Surveyed_building')
|
|
|
@@ -204,20 +333,21 @@ def poly_prism(footprint, bottom, top, wall_material, roof_material, anchor, flo
|
|
|
floor_base = max(bottom, az) if ARCHITECTURE else bottom
|
|
|
floor_height = max(2.4, (top - floor_base) / max(1, floors))
|
|
|
|
|
|
- def edge_panel_points(a, b, z0, z1, inset, width_ratio=0.42):
|
|
|
+ def edge_panel_points(a, b, z0, z1, inset, width_ratio=0.42, physical_window=False):
|
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
|
length = math.hypot(dx, dy)
|
|
|
if length < 0.35 or z1 <= z0:
|
|
|
return None
|
|
|
nx, ny = dy / length, -dx / length
|
|
|
- half = min(length * 0.22, max(0.45, length / 3.5 * width_ratio))
|
|
|
+ half = facade_panel_halfwidth(length, width_ratio, physical_window)
|
|
|
cx, cy = (a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5
|
|
|
tx, ty = dx / length, dy / length
|
|
|
return [(cx - tx * half + nx * inset, cy - ty * half + ny * inset),
|
|
|
(cx + tx * half + nx * inset, cy + ty * half + ny * inset)]
|
|
|
|
|
|
def add_panel(a, b, z0, z1, material_index, inset=0.035):
|
|
|
- points = edge_panel_points(a, b, z0, z1, inset)
|
|
|
+ points = edge_panel_points(a, b, z0, z1, inset,
|
|
|
+ physical_window=FACADE_PROFILE == 'compact' and level == 'lod0')
|
|
|
if not points:
|
|
|
return
|
|
|
base = len(vertices)
|
|
|
@@ -276,16 +406,20 @@ def poly_prism(footprint, bottom, top, wall_material, roof_material, anchor, flo
|
|
|
(base + 1, base + 5, base + 7, base + 3)])
|
|
|
materials.extend([material_index] * 6)
|
|
|
|
|
|
- # One or a few windows per wall and floor, with a dark blue material that cannot read as a flat cube.
|
|
|
- for i in range(n):
|
|
|
+ longest = facade_entrance_edge(footprint)
|
|
|
+ # Reviewed attachments retain closed GIS geometry without independent house details.
|
|
|
+ for i in range(n if facade_scope == 'building' else 0):
|
|
|
a, b = footprint[i], footprint[(i + 1) % n]
|
|
|
count = facade_window_count(math.hypot(b[0] - a[0], b[1] - a[1]), level)
|
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
|
for floor in range(max(1, floors)):
|
|
|
- z0 = floor_base + floor * floor_height + floor_height * 0.34
|
|
|
- z1 = min(top - 0.08, z0 + floor_height * 0.36)
|
|
|
- for window in range(count):
|
|
|
- t = (window + 1) / (count + 1)
|
|
|
+ if (i, floor) in hidden_windows:
|
|
|
+ continue
|
|
|
+ compact_window = FACADE_PROFILE == 'compact' and level == 'lod0'
|
|
|
+ z0 = floor_base + floor * floor_height + floor_height * (0.30 if compact_window else 0.34)
|
|
|
+ z1 = min(top - 0.08, z0 + floor_height * (0.45 if compact_window else 0.36))
|
|
|
+ for t in facade_window_positions(math.hypot(dx, dy), level,
|
|
|
+ reserve_entrance=i == longest and floor == 0 and not mask.get('door')):
|
|
|
center = (a[0] + dx * t, a[1] + dy * t)
|
|
|
span = math.hypot(dx, dy) / (count + 1) * 0.34
|
|
|
wa = (center[0] - dx / (math.hypot(dx, dy) or 1) * span,
|
|
|
@@ -293,10 +427,12 @@ def poly_prism(footprint, bottom, top, wall_material, roof_material, anchor, flo
|
|
|
wb = (center[0] + dx / (math.hypot(dx, dy) or 1) * span,
|
|
|
center[1] + dy / (math.hypot(dx, dy) or 1) * span)
|
|
|
add_panel(wa, wb, z0, z1, 2)
|
|
|
- if ARCHITECTURE and level == 'lod0':
|
|
|
+ if FULL_REVEALS and level == 'lod0':
|
|
|
add_window_reveal(wa, wb, z0, z1)
|
|
|
if ARCHITECTURE and level == 'lod0':
|
|
|
for floor in range(1, max(1, floors)):
|
|
|
+ if (i, floor) in hidden_bands:
|
|
|
+ continue
|
|
|
z = floor_base + floor * floor_height
|
|
|
# A thin full-edge storey band uses the darker door tone, not extra objects.
|
|
|
length = math.hypot(dx, dy) or 1
|
|
|
@@ -307,25 +443,67 @@ def poly_prism(footprint, bottom, top, wall_material, roof_material, anchor, flo
|
|
|
faces.append((base, base + 2, base + 3, base + 1))
|
|
|
materials.append(3)
|
|
|
|
|
|
- # One door on the longest wall, visible from at least one approach direction.
|
|
|
- longest = max(range(n), key=lambda i: math.hypot(footprint[(i + 1) % n][0] - footprint[i][0],
|
|
|
- footprint[(i + 1) % n][1] - footprint[i][1]))
|
|
|
+ if facade_scope == 'building' and BANDS and ARCHITECTURE and level == 'lod0':
|
|
|
+ for i in range(n):
|
|
|
+ a, b = footprint[i], footprint[(i + 1) % n]
|
|
|
+ dx, dy = b[0] - a[0], b[1] - a[1]
|
|
|
+ length = math.hypot(dx, dy)
|
|
|
+ if length < .35: continue
|
|
|
+ nx, ny = dy / length, -dx / length
|
|
|
+ for floor in range(1, max(1, floors)):
|
|
|
+ if (i, floor) in hidden_bands:
|
|
|
+ continue
|
|
|
+ z = floor_base + floor * floor_height
|
|
|
+ inset = .028
|
|
|
+ base = len(vertices)
|
|
|
+ vertices.extend([(a[0] - ax + nx * inset, a[1] - ay + ny * inset, z - .035 - az),
|
|
|
+ (b[0] - ax + nx * inset, b[1] - ay + ny * inset, z - .035 - az),
|
|
|
+ (b[0] - ax + nx * inset, b[1] - ay + ny * inset, z + .035 - az),
|
|
|
+ (a[0] - ax + nx * inset, a[1] - ay + ny * inset, z + .035 - az)])
|
|
|
+ faces.append((base, base + 2, base + 3, base + 1)); materials.append(3)
|
|
|
+
|
|
|
+
|
|
|
+ if facade_scope == 'building' and FULL_REVEALS and level == 'lod0':
|
|
|
+ for i in range(n):
|
|
|
+ a, b = footprint[i], footprint[(i + 1) % n]
|
|
|
+ length = math.hypot(b[0] - a[0], b[1] - a[1])
|
|
|
+ if length < 0.35: continue
|
|
|
+ dx, dy = b[0] - a[0], b[1] - a[1]
|
|
|
+ norm = math.hypot(dx, dy) or 1
|
|
|
+ nx, ny = dy / norm, -dx / norm
|
|
|
+ inset = 0.045
|
|
|
+ low, high = top - 0.18, top + 0.08
|
|
|
+ base = len(vertices)
|
|
|
+ vertices.extend([(a[0] - ax + nx * inset, a[1] - ay + ny * inset, low - az),
|
|
|
+ (b[0] - ax + nx * inset, b[1] - ay + ny * inset, low - az),
|
|
|
+ (b[0] - ax + nx * inset, b[1] - ay + ny * inset, high - az),
|
|
|
+ (a[0] - ax + nx * inset, a[1] - ay + ny * inset, high - az)])
|
|
|
+ faces.append((base, base + 1, base + 2, base + 3)); materials.append(0)
|
|
|
+
|
|
|
+ # One door on the original longest wall, visible from at least one approach direction.
|
|
|
edge_a = footprint[longest]
|
|
|
edge_b = footprint[(longest + 1) % n]
|
|
|
door_h = min(2.2, max(1.8, floor_height * 0.76))
|
|
|
- add_box_panel(edge_a, edge_b, floor_base + 0.08, min(top - 0.05, floor_base + door_h), 3)
|
|
|
+ if facade_scope == 'building' and not mask.get('door'):
|
|
|
+ add_box_panel(edge_a, edge_b, floor_base + 0.08, min(top - 0.05, floor_base + door_h), 3)
|
|
|
mesh.from_pydata(vertices, [], faces)
|
|
|
obj.location = anchor
|
|
|
+ for material in (wall_material, roof_material, window_material, door_material):
|
|
|
+ mesh.materials.append(material)
|
|
|
+ mesh.polygons.foreach_set('material_index', materials)
|
|
|
+ # This slot belongs to our authored window planes, never to library assets.
|
|
|
+ obj['cad_authored_window_slot'] = 2
|
|
|
edit = bmesh.new()
|
|
|
edit.from_mesh(mesh)
|
|
|
- bmesh.ops.recalc_face_normals(edit, faces=edit.faces)
|
|
|
+ # An isolated plane has no volume from which bmesh can infer outside.
|
|
|
+ # Keep authored windows in every profile, plus the existing compact planes.
|
|
|
+ normal_faces = [face for face in edit.faces if not (
|
|
|
+ (face.material_index == 2 or FACADE_PROFILE == 'compact')
|
|
|
+ and all(edge.is_boundary for edge in face.edges))]
|
|
|
+ bmesh.ops.recalc_face_normals(edit, faces=normal_faces)
|
|
|
edit.to_mesh(mesh)
|
|
|
edit.free()
|
|
|
mesh.update()
|
|
|
- for material in (wall_material, roof_material, window_material, door_material):
|
|
|
- mesh.materials.append(material)
|
|
|
- mesh.polygons.foreach_set('material_index', materials)
|
|
|
- mesh.update()
|
|
|
return obj
|
|
|
|
|
|
|
|
|
@@ -364,13 +542,28 @@ def build_geometry(building):
|
|
|
raise RuntimeError(building['id'] + ' massing prism has zero volume')
|
|
|
# Both runtime levels use the surveyed ring. LOD1 reduces facade detail, not the GIS footprint;
|
|
|
# replacing an irregular ring with its hull was the source of visibly misplaced road-covering mass.
|
|
|
- return {'lod0': (outline, massing_triangles(outline, building['floors'], 'lod0')),
|
|
|
- 'lod1': (outline, massing_triangles(outline, building['floors'], 'lod1'))}
|
|
|
+ scope = building.get('massingFacadeScope', 'building')
|
|
|
+ masks = PARTY_WALL_MASKS.get(building['id'], {})
|
|
|
+ return {'lod0': (outline, massing_triangles(outline, building['floors'], 'lod0', scope, masks.get('lod0'))),
|
|
|
+ 'lod1': (outline, massing_triangles(outline, building['floors'], 'lod1', scope, masks.get('lod1')))}
|
|
|
|
|
|
|
|
|
placements, rejected, expected_triangles = [], [], {}
|
|
|
+selected_input = plan['buildings'][:args.limit or None]
|
|
|
+PARTY_WALL_MASKS = {}
|
|
|
+if any(building.get('partyWallNeighbors') for building in selected_input):
|
|
|
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
+ from party_wall_facades import party_wall_masks
|
|
|
+ if FULL_REVEALS or BANDS:
|
|
|
+ raise RuntimeError('Reviewed party-wall masks require compact without reveals or extra storey bands')
|
|
|
+ PARTY_WALL_MASKS = party_wall_masks(selected_input, as_ccw, FACADE_PROFILE)
|
|
|
+if any('massingFacadeScope' in building for building in selected_input):
|
|
|
+ # Blender's script runner does not always add the script directory to sys.path.
|
|
|
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
+ from building_composition import validate_composition_scopes
|
|
|
+ validate_composition_scopes(selected_input, FACADE_PROFILE)
|
|
|
library_ids = {entry['id'] for entry in entries}
|
|
|
-for building in plan['buildings'][:args.limit or None]:
|
|
|
+for building in selected_input:
|
|
|
if building['kind'] == 'asset':
|
|
|
# A named variant that is not in the library is a broken plan, not a building to skip.
|
|
|
unknown = set(building.get('preferredAssets') or []).difference(library_ids)
|
|
|
@@ -384,13 +577,17 @@ for building in plan['buildings'][:args.limit or None]:
|
|
|
expected_triangles[building['id']] = None # filled from the library once templates load
|
|
|
placements.append((building, entry, sx, sy))
|
|
|
elif building['kind'] == 'massing':
|
|
|
- geometry = build_geometry(building)
|
|
|
+ geometry = facade_operation(building, build_geometry, building)
|
|
|
expected_triangles[building['id']] = {level: geometry[level][1] for level in geometry}
|
|
|
placements.append((building, None, None, None))
|
|
|
else:
|
|
|
raise RuntimeError(building['id'] + ' has unknown kind ' + str(building['kind']))
|
|
|
if not placements or len({b['id'] for b, _, _, _ in placements}) != len(placements):
|
|
|
raise RuntimeError('The seated plan does not yield unique, nonempty placements')
|
|
|
+placed_ids = {building['id'] for building, _, _, _ in placements}
|
|
|
+for building, _, _, _ in placements:
|
|
|
+ if building.get('massingFacadeScope') == 'shell-only' and building['composition']['primaryId'] not in placed_ids:
|
|
|
+ raise RuntimeError('Attachment primary did not pass asset placement: ' + building['id'])
|
|
|
|
|
|
# ---------------------------------------------------------------- scene setup
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
@@ -475,7 +672,7 @@ if decimated_templates:
|
|
|
expected_triangles[building['id']] = {}
|
|
|
for level in ('lod0', 'lod1'):
|
|
|
if args.lod1_footprint_only and level == 'lod1':
|
|
|
- expected_triangles[building['id']][level] = build_geometry(building)[level][1]
|
|
|
+ expected_triangles[building['id']][level] = facade_operation(building, build_geometry, building)[level][1]
|
|
|
continue
|
|
|
template_name = entry['joined_object'] if level == 'lod0' else entry['lod1']['joined_object']
|
|
|
fallback = (entry['validation'] if level == 'lod0' else entry['lod1']['validation'])['triangles']
|
|
|
@@ -484,7 +681,7 @@ else:
|
|
|
for building, entry, _, _ in placements:
|
|
|
if entry:
|
|
|
expected_triangles[building['id']] = {
|
|
|
- level: (build_geometry(building)[level][1] if args.lod1_footprint_only and level == 'lod1'
|
|
|
+ level: (facade_operation(building, build_geometry, building)[level][1] if args.lod1_footprint_only and level == 'lod1'
|
|
|
else (entry['validation'] if level == 'lod0' else entry['lod1']['validation'])['triangles'])
|
|
|
for level in ('lod0', 'lod1')}
|
|
|
|
|
|
@@ -531,13 +728,45 @@ def sourced_roof(building_id, fallback):
|
|
|
return material
|
|
|
|
|
|
|
|
|
+def compact_window_material():
|
|
|
+ name = 'Compact_Window_Frame_' + WINDOW_TEXTURE['sha256'][:12]
|
|
|
+ material = bpy.data.materials.get(name)
|
|
|
+ if material:
|
|
|
+ return material
|
|
|
+ material = bpy.data.materials.new(name)
|
|
|
+ material.use_nodes = True
|
|
|
+ shader = material.node_tree.nodes.get('Principled BSDF')
|
|
|
+ shader.inputs['Base Color'].default_value = (1, 1, 1, 1)
|
|
|
+ shader.inputs['Roughness'].default_value = 0.65
|
|
|
+ shader.inputs['Metallic'].default_value = 0.0
|
|
|
+ texture = material.node_tree.nodes.new('ShaderNodeTexImage')
|
|
|
+ texture.image = bpy.data.images.load(WINDOW_TEXTURE['path'], check_existing=True)
|
|
|
+ texture.extension = 'CLIP'
|
|
|
+ material.node_tree.links.new(texture.outputs['Color'], shader.inputs['Base Color'])
|
|
|
+ return material
|
|
|
+
|
|
|
+
|
|
|
+def apply_window_uv(obj):
|
|
|
+ faces = [face for face in obj.data.polygons if face.material_index == 2]
|
|
|
+ if not faces:
|
|
|
+ return
|
|
|
+ uv = obj.data.uv_layers.active or obj.data.uv_layers.new(name='UVMap')
|
|
|
+ for face in faces:
|
|
|
+ points = [tuple(obj.data.vertices[obj.data.loops[index].vertex_index].co)
|
|
|
+ for index in face.loop_indices]
|
|
|
+ for index, value in zip(face.loop_indices, window_quad_uv(points)):
|
|
|
+ uv.data[index].uv = value
|
|
|
+
|
|
|
+
|
|
|
def apply_roof_uv(obj, building_id):
|
|
|
record = ROOF_TEXTURES.get(building_id)
|
|
|
if not record:
|
|
|
return
|
|
|
low, high = record['boundsENU']
|
|
|
- uv = obj.data.uv_layers.new(name='UVMap')
|
|
|
- for loop in obj.data.loops:
|
|
|
+ uv = obj.data.uv_layers.active or obj.data.uv_layers.new(name='UVMap')
|
|
|
+ roof_loops = (obj.data.loops[index] for face in obj.data.polygons
|
|
|
+ if face.material_index == 1 for index in face.loop_indices)
|
|
|
+ for loop in roof_loops:
|
|
|
point = obj.data.vertices[loop.vertex_index].co + obj.location
|
|
|
uv.data[loop.index].uv = ((point.x - low[0]) / (high[0] - low[0]),
|
|
|
(point.y - low[1]) / (high[1] - low[1]))
|
|
|
@@ -582,24 +811,35 @@ def make_cell(group, level):
|
|
|
slot.link = 'OBJECT'
|
|
|
slot.material = material
|
|
|
else:
|
|
|
- geometry = build_geometry(building)[level]
|
|
|
+ geometry = facade_operation(building, build_geometry, building)[level]
|
|
|
outline = geometry[0]
|
|
|
wall, roof, window, door = massing_tone(building['id'])
|
|
|
+ textured_windows = (WINDOW_TEXTURE is not None and level == 'lod0'
|
|
|
+ and (COMPACT_BUILDING_IDS is None or building['id'] in COMPACT_BUILDING_IDS)
|
|
|
+ and building.get('massingFacadeScope', 'building') == 'building')
|
|
|
+ if textured_windows:
|
|
|
+ window = compact_window_material()
|
|
|
if level == 'lod0':
|
|
|
roof = sourced_roof(building['id'], roof)
|
|
|
top_z = top + building['heightM']
|
|
|
- obj = poly_prism(outline, bottom, top_z, wall, roof, building['positionENU'],
|
|
|
+ obj = facade_operation(building, poly_prism, outline, bottom, top_z, wall, roof, building['positionENU'],
|
|
|
max(1, int(building.get('floors') or round(building['heightM'] / 3.2))), level,
|
|
|
- window, door)
|
|
|
+ window, door, facade_scope=building.get('massingFacadeScope', 'building'),
|
|
|
+ facade_mask=PARTY_WALL_MASKS.get(building['id'], {}).get(level))
|
|
|
# Self-check the prism is a solid with outward normals. If the ring somehow came out
|
|
|
# clockwise, rebuild once reversed instead of silently emitting an inside-out building.
|
|
|
if prism_volume_sign(obj) <= 0:
|
|
|
bpy.data.meshes.remove(obj.data, do_unlink=True)
|
|
|
- obj = poly_prism(list(reversed(outline)), bottom, top_z, wall, roof, building['positionENU'],
|
|
|
+ if building['id'] in PARTY_WALL_MASKS:
|
|
|
+ raise RuntimeError('Reviewed party-wall prism needs normal correction; do not reuse reversed edge masks: ' + building['id'])
|
|
|
+ obj = facade_operation(building, poly_prism, list(reversed(outline)), bottom, top_z, wall, roof, building['positionENU'],
|
|
|
max(1, int(building.get('floors') or round(building['heightM'] / 3.2))), level,
|
|
|
- window, door)
|
|
|
+ window, door, facade_scope=building.get('massingFacadeScope', 'building'),
|
|
|
+ facade_mask=PARTY_WALL_MASKS.get(building['id'], {}).get(level))
|
|
|
if prism_volume_sign(obj) <= 0:
|
|
|
raise RuntimeError(building['id'] + ' massing prism is inside-out or flat')
|
|
|
+ if textured_windows:
|
|
|
+ apply_window_uv(obj)
|
|
|
if level == 'lod0':
|
|
|
apply_roof_uv(obj, building['id'])
|
|
|
obj.name = building['id']
|
|
|
@@ -640,11 +880,16 @@ def merge_cell_objects(objects, collection):
|
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
|
vertices, faces, face_material_names, face_uvs = [], [], [], []
|
|
|
material_names = {}
|
|
|
+ preserve_planar_faces = []
|
|
|
+ scope_ids = globals().get('COMPACT_BUILDING_IDS')
|
|
|
+ has_uv = False
|
|
|
for obj in objects:
|
|
|
evaluated = obj.evaluated_get(depsgraph)
|
|
|
mesh = evaluated.to_mesh()
|
|
|
+ has_uv = has_uv or mesh.uv_layers.active is not None
|
|
|
matrix = obj.matrix_world.copy()
|
|
|
base = len(vertices)
|
|
|
+ authored_window_slot = obj.get('cad_authored_window_slot')
|
|
|
for vertex in mesh.vertices:
|
|
|
vertices.append(tuple(matrix @ vertex.co))
|
|
|
for polygon in mesh.polygons:
|
|
|
@@ -653,6 +898,8 @@ def merge_cell_objects(objects, collection):
|
|
|
if len(set(corner_indices)) < 3:
|
|
|
continue
|
|
|
faces.append(corner_indices)
|
|
|
+ compact_plane = FACADE_PROFILE == 'compact' and (scope_ids is None or getattr(obj, 'name', '') in scope_ids)
|
|
|
+ preserve_planar_faces.append(compact_plane or polygon.material_index == authored_window_slot)
|
|
|
uv_layer = mesh.uv_layers.active
|
|
|
face_uvs.append([tuple(uv_layer.data[i].uv) if uv_layer else (0.0, 0.0)
|
|
|
for i in polygon.loop_indices])
|
|
|
@@ -664,7 +911,7 @@ def merge_cell_objects(objects, collection):
|
|
|
evaluated.to_mesh_clear()
|
|
|
merged_mesh = bpy.data.meshes.new('cell_merged')
|
|
|
merged_mesh.from_pydata(vertices, [], faces)
|
|
|
- if ROOF_TEXTURES:
|
|
|
+ if ROOF_TEXTURES or (WINDOW_TEXTURE is not None and has_uv):
|
|
|
uv_layer = merged_mesh.uv_layers.new(name='UVMap')
|
|
|
for polygon, values in zip(merged_mesh.polygons, face_uvs):
|
|
|
for loop_index, value in zip(polygon.loop_indices, values):
|
|
|
@@ -674,7 +921,14 @@ def merge_cell_objects(objects, collection):
|
|
|
merged_mesh.polygons.foreach_set('material_index', [material_names[n] for n in face_material_names])
|
|
|
edit = bmesh.new()
|
|
|
edit.from_mesh(merged_mesh)
|
|
|
- bmesh.ops.recalc_face_normals(edit, faces=edit.faces)
|
|
|
+ # Preserve authored windows in every profile and the existing selected compact
|
|
|
+ # planes. Assets/foundations without that provenance keep their normal path.
|
|
|
+ normal_faces = list(edit.faces)
|
|
|
+ if len(normal_faces) != len(preserve_planar_faces):
|
|
|
+ raise RuntimeError('Merged face provenance changed')
|
|
|
+ normal_faces = [face for face, preserve in zip(normal_faces, preserve_planar_faces)
|
|
|
+ if not (preserve and all(edge.is_boundary for edge in face.edges))]
|
|
|
+ bmesh.ops.recalc_face_normals(edit, faces=normal_faces)
|
|
|
edit.to_mesh(merged_mesh)
|
|
|
edit.free()
|
|
|
merged_mesh.update()
|
|
|
@@ -870,7 +1124,19 @@ lod0_records = [cell['lod0'] for cell in manifest_cells]
|
|
|
manifest = {'scope': plan.get('scope', 'surveyed CAD/BIM massing sample; not full-city reconstruction'),
|
|
|
'origin': plan['origin'], 'notice': plan.get('notice'),
|
|
|
'provenance': {'source': plan.get('source'), 'plan': PLAN.name, 'planPath': str(PLAN),
|
|
|
- 'nearArchitecture': ARCHITECTURE, 'satelliteRoofTextures': len(ROOF_TEXTURES),
|
|
|
+ 'nearArchitecture': ARCHITECTURE, 'massingFacadeProfile': FACADE_PROFILE,
|
|
|
+ 'compactFacadeBuildingIds': sorted(COMPACT_BUILDING_IDS) if COMPACT_BUILDING_IDS is not None else None,
|
|
|
+ 'massingFacadeLayout': 'physical-windows-entrance-v2' if FACADE_PROFILE == 'compact' else 'legacy-panels',
|
|
|
+ 'compactWindowTexture': ({key: WINDOW_TEXTURE[key] for key in
|
|
|
+ ('sha256', 'source', 'rasterBytes', 'rgba8FullMipEstimate')}
|
|
|
+ if WINDOW_TEXTURE else None),
|
|
|
+ 'shellOnlyAttachmentIds': [b['id'] for b, _, _, _ in placements
|
|
|
+ if b.get('massingFacadeScope') == 'shell-only'],
|
|
|
+ 'reviewedPartyWallIds': sorted(PARTY_WALL_MASKS),
|
|
|
+ 'partyWallReview': plan.get('partyWallReview'),
|
|
|
+ 'compositionReview': plan.get('compositionReview'),
|
|
|
+ 'estimatedMassingStoreyBands': BANDS,
|
|
|
+ 'satelliteRoofTextures': len(ROOF_TEXTURES),
|
|
|
'facadeFidelity': 'procedural estimate; not facade photography or surveyed BIM',
|
|
|
'gisFootprints': True, 'lod1FootprintOnly': bool(args.lod1_footprint_only),
|
|
|
'facadeDetails': True, 'roadsProcedural': False, 'closedRoofCaps': True},
|