Kaynağa Gözat

feat(tools): 完善城市模型生成与校验工具

ChenJZ 15 saat önce
ebeveyn
işleme
fe28ac815c

+ 309 - 43
tools/city-massing/assemble-cad-bim.py

@@ -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},

Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 2
tools/city-massing/build-model-integration-decision-package.mjs


+ 88 - 0
tools/city-massing/building_composition.py

@@ -0,0 +1,88 @@
+"""Conservative adjacency candidates for narrow surveyed outlines; no polygon union."""
+import math
+
+
+def signed_area(ring):
+    ox, oy = ring[0]
+    return sum((a[0]-ox)*(b[1]-oy)-(b[0]-ox)*(a[1]-oy)
+               for a,b in zip(ring,ring[1:]+ring[:1])) / 2
+
+
+def shared_boundary_length(first, second, tolerance=.06):
+    """Measure overlapping opposite-facing edges, merging duplicate overlap intervals."""
+    a = first if signed_area(first)>0 else list(reversed(first))
+    b = second if signed_area(second)>0 else list(reversed(second))
+    total = 0
+    for p,q in zip(a,a[1:]+a[:1]):
+        dx,dy = q[0]-p[0],q[1]-p[1]
+        length = math.hypot(dx,dy)
+        if length <= tolerance:
+            continue
+        ux,uy = dx/length,dy/length
+        intervals = []
+        for r,s in zip(b,b[1:]+b[:1]):
+            if (s[0]-r[0])*ux+(s[1]-r[1])*uy >= 0:
+                continue  # CCW neighbors run opposite ways along a shared boundary
+            distances = [abs((v[0]-p[0])*uy-(v[1]-p[1])*ux) for v in (r,s)]
+            if max(distances)>tolerance:
+                continue
+            t = [(v[0]-p[0])*ux+(v[1]-p[1])*uy for v in (r,s)]
+            low,high = max(0,min(t)),min(length,max(t))
+            if high>low:
+                intervals.append((low,high))
+        end = -math.inf
+        for low,high in sorted(intervals):
+            total += max(0,high-max(end,low))
+            end = max(end,high)
+    return total
+
+
+def attachment_candidates(strip, buildings):
+    """Geometry evidence only; ambiguity must be resolved before assigning an export role."""
+    short,long = sorted((strip['width'],strip['depth']))
+    if strip['kind']!='massing' or short>2 or short<=0 or long<4 or long/short<4:
+        return []
+    area = abs(signed_area(strip['footprintENU']))
+    candidates = []
+    for host in buildings:
+        if host['id']==strip['id'] or min(host['width'],host['depth'])<4:
+            continue
+        if abs(signed_area(host['footprintENU']))<area*4:
+            continue
+        shared = shared_boundary_length(strip['footprintENU'],host['footprintENU'])
+        if shared>=long*.7:
+            candidates.append({'primaryId':host['id'],'sharedBoundaryM':round(shared,4),
+                               'sharedToLongSideRatio':round(shared/long,4)})
+    return sorted(candidates,key=lambda row:(-row['sharedBoundaryM'],row['primaryId']))
+
+
+def validate_composition_scopes(buildings, profile):
+    pairs = {}
+    for building in buildings:
+        scope = building.get('massingFacadeScope', 'building')
+        if scope not in ('building', 'shell-only'):
+            raise ValueError('unsupported facade scope')
+        if scope == 'shell-only':
+            composition = building.get('composition', {})
+            if profile != 'compact' or composition.get('role') != 'attached-narrow-outline':
+                raise ValueError('shell-only requires an explicit compact attachment role')
+            pairs[building['id']] = composition.get('primaryId')
+    if pairs:
+        annotate_reviewed_attachments(buildings, pairs)
+
+
+def annotate_reviewed_attachments(buildings, reviewed_pairs):
+    """Annotate an isolated plan; reject missing hosts, ambiguity or incorrect pairs."""
+    by_id = {b['id']:b for b in buildings}
+    if len(by_id)!=len(buildings):
+        raise ValueError('duplicate building IDs')
+    for strip_id,primary_id in reviewed_pairs.items():
+        if strip_id not in by_id or primary_id not in by_id or primary_id in reviewed_pairs:
+            raise ValueError('missing or invalid attachment primary')
+        candidates = attachment_candidates(by_id[strip_id],buildings)
+        if len(candidates)!=1 or candidates[0]['primaryId']!=primary_id:
+            raise ValueError('ambiguous or unsupported attachment: '+strip_id)
+    return [{**building, 'massingFacadeScope':'shell-only',
+             'composition':{'role':'attached-narrow-outline', 'primaryId':reviewed_pairs[building['id']],
+                            'geometryUnchanged':True}}
+            if building['id'] in reviewed_pairs else dict(building) for building in buildings]

+ 12 - 0
tools/city-massing/instance-safe-primitives.mjs

@@ -24,6 +24,18 @@ export function decodePrimitive(json,bin,p){
         return{positions:attr('POSITION'),normals:attr('NORMAL'),indices}
     }finally{if(status)decoder.destroy(status);decoder.destroy(m);decoder.destroy(db);decoder.destroy(dec)}
 }
+// Decode a generic per-point float attribute (e.g. COLOR_0) carried inside the draco payload.
+// Returns null when the primitive has no such draco attribute. (Plain-accessor attributes are read
+// by callers directly from the bufferView.)
+export function decodePrimAttribute(json,bin,p,semantic,components=3){
+    const e=p.extensions?.KHR_draco_mesh_compression
+    if(!e||e.attributes[semantic]===undefined)return null
+    const v=json.bufferViews[e.bufferView],b=bin.subarray(v.byteOffset||0,(v.byteOffset||0)+v.byteLength)
+    const dec=new decoder.Decoder(),db=new decoder.DecoderBuffer(),m=new decoder.Mesh();let status
+    try{db.Init(new Int8Array(b.buffer,b.byteOffset,b.byteLength),b.byteLength);status=dec.DecodeBufferToMesh(db,m);assert.ok(status.ok(),status.error_msg())
+        const a=dec.GetAttributeByUniqueId(m,e.attributes[semantic]),size=m.num_points()*4*components,ptr=decoder._malloc(size)
+        try{assert.ok(dec.GetAttributeDataArrayForAllPoints(m,a,decoder.DT_FLOAT32,size,ptr));return [...new Float32Array(decoder.HEAPF32.buffer,ptr,m.num_points()*components)]}finally{decoder._free(ptr)}
+    }finally{if(status)decoder.destroy(status);decoder.destroy(m);decoder.destroy(db);decoder.destroy(dec)}}
 export function rotate(v,q){const [x,y,z,w]=q,u=[y*v[2]-z*v[1],z*v[0]-x*v[2],x*v[1]-y*v[0]],uu=[y*u[2]-z*u[1],z*u[0]-x*u[2],x*u[1]-y*u[0]];return v.map((a,i)=>a+2*(w*u[i]+uu[i]))}
 export function safeNormalDot(normals,scale){let min=1;for(let i=0;i<normals.length;i+=3){const n=normals.slice(i,i+3),a=n.map((v,j)=>v*scale[j]),b=n.map((v,j)=>v/scale[j]);const dot=a.reduce((s,v,j)=>s+v*b[j],0)/Math.hypot(...a)/Math.hypot(...b);min=Math.min(min,dot)}return min}
 export function instanceSafePrimitives(bytes){

+ 98 - 0
tools/city-massing/party_wall_facades.py

@@ -0,0 +1,98 @@
+"""Skip facade details only on explicitly reviewed, fully shared massing edges.
+
+No walls, roof caps, terrain coordinates or heights are changed. Partial-edge
+contacts stay untouched, and higher exposed storeys are never hidden by a low neighbor.
+"""
+import math
+from building_composition import signed_area
+
+
+def _covers_edge(a, b, ring, tolerance=.011):
+    dx,dy=b[0]-a[0],b[1]-a[1]
+    length=math.hypot(dx,dy)
+    if length<=tolerance:
+        return False
+    ux,uy=dx/length,dy/length
+    intervals=[]
+    for p,q in zip(ring,ring[1:]+ring[:1]):
+        if (q[0]-p[0])*ux+(q[1]-p[1])*uy>=0:
+            continue
+        if max(abs((v[0]-a[0])*uy-(v[1]-a[1])*ux) for v in (p,q))>tolerance:
+            continue
+        along=[(v[0]-a[0])*ux+(v[1]-a[1])*uy for v in (p,q)]
+        low,high=max(0,min(along)),min(length,max(along))
+        if high>low:
+            intervals.append((low,high))
+    end=0
+    for low,high in sorted(intervals):
+        if low>end+tolerance:
+            return False
+        end=max(end,high)
+    return end>=length-tolerance
+
+
+def party_wall_masks(buildings, normalize, profile):
+    """Derive per-level masks from real neighbors; never trust serialized edge indices."""
+    by_id={b['id']:b for b in buildings}
+    if len(by_id)!=len(buildings):
+        raise ValueError('duplicate party-wall building IDs')
+    specified=[b for b in buildings if b.get('partyWallNeighbors')]
+    if specified and profile!='compact':
+        raise ValueError('reviewed party walls require compact profile')
+    outlines={b['id']:normalize(b['footprintENU']) for b in buildings}
+    masks={}
+    for building in specified:
+        ids=building['partyWallNeighbors']
+        if not isinstance(ids,list) or not ids or len(set(ids))!=len(ids):
+            raise ValueError('invalid party-wall neighbor IDs')
+        if building['kind']!='massing' or building.get('massingFacadeScope','building')!='building':
+            raise ValueError('party-wall details require an ordinary surveyed massing shell')
+        ring=outlines[building['id']]
+        hosts=[]
+        for neighbor_id in ids:
+            neighbor=by_id.get(neighbor_id)
+            if neighbor is None or neighbor is building or neighbor['kind']!='massing':
+                raise ValueError('party-wall neighbor missing or not surveyed massing')
+            if building['id'] not in neighbor.get('partyWallNeighbors',[]):
+                raise ValueError('party-wall review must be reciprocal')
+            other=outlines[neighbor_id]
+            if len(ring)<3 or len(other)<3 or signed_area(other)<=0:
+                raise ValueError('invalid party-wall outline')
+            edges=[i for i,a in enumerate(ring) if _covers_edge(a,ring[(i+1)%len(ring)],other)]
+            if not edges:
+                raise ValueError('no fully shared edge for reviewed party wall')
+            low,high=neighbor['foundationBottom'],neighbor['positionENU'][2]+neighbor['heightM']
+            if not math.isfinite(low) or not math.isfinite(high) or high<=low:
+                raise ValueError('invalid party-wall neighbor heights')
+            hosts.append((edges,low,high))
+        base=max(building['foundationBottom'],building['positionENU'][2])
+        top=building['positionENU'][2]+building['heightM']
+        floors=building['floors']
+        if floors<1 or top<=base or not math.isfinite(top):
+            raise ValueError('invalid party-wall building heights')
+        floor_height=max(2.4,(top-base)/floors)
+        # Match compact's deterministic longest-edge tie break. Door masks and
+        # actual reservation must refer to the same surveyed wall after ring rotation.
+        longest=min(range(len(ring)),key=lambda i:(
+            -round(math.dist(ring[i],ring[(i+1)%len(ring)]),6),
+            tuple(sorted((tuple(ring[i]),tuple(ring[(i+1)%len(ring)]))))))
+        def hidden(edge,low,high):
+            return high>low and any(edge in indices and low>=z0 and high<=z1
+                                   for indices,z0,z1 in hosts)
+        levels={}
+        for level in ('lod0','lod1'):
+            compact=level=='lod0'
+            windows=set()
+            for i in range(len(ring)):
+                for floor in range(floors):
+                    low=base+floor*floor_height+floor_height*(.30 if compact else .34)
+                    high=min(top-.08,low+floor_height*(.45 if compact else .36))
+                    if hidden(i,low,high):
+                        windows.add((i,floor))
+            bands={(i,floor) for i in range(len(ring)) for floor in range(1,floors)
+                   if hidden(i,base+floor*floor_height-.045,base+floor*floor_height+.045)}
+            door_low=base+.08
+            door_high=min(top-.05,base+min(2.2,max(1.8,floor_height*.76)))
+            levels[level]={'windows':windows,'bands':bands,'door':hidden(longest,door_low,door_high)}
+        masks[building['id']]=levels
+    return masks

+ 1 - 1
tools/city-massing/prepare-negative-estate-roof-textures.mjs

@@ -7,7 +7,7 @@ import jpeg from 'jpeg-js'
 import {PNG} from 'pngjs'
 import {enuToGeodetic} from './roof-geodesy.mjs'
 import {roofRasterSize,sharpenRoofRgba} from './roof-raster-quality.mjs'
-const dir=resolve('.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/negative-estate-pilot/input-v2'),plan=JSON.parse(readFileSync(join(dir,'seated-plan.json')))
+const dir=resolve('.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/negative-estate-pilot/safe-v2'),plan=JSON.parse(readFileSync(join(dir,'seated-plan.json')))
 const tiles=new Map(),zoom=19,n=2**zoom,textures={},sha=b=>createHash('sha256').update(b).digest('hex')
 function tile(x,y){const path=`D:/GIS/GIS/yuanlingPic3/${zoom}/${x}/${n-1-y}.png`;if(!tiles.has(path)){const bytes=readFileSync(path),image=bytes[0]===255?jpeg.decode(bytes):PNG.sync.read(bytes);assert.equal(image.width,256);assert.equal(image.height,256);tiles.set(path,{image,path,sha256:sha(bytes)})}return tiles.get(path)}
 for(const b of plan.buildings){

+ 2 - 2
tools/city-massing/render-near-building.py

@@ -12,11 +12,11 @@ from mathutils.geometry import tessellate_polygon
 root = Path(__file__).resolve().parents[2]
 source = Path(__file__).with_name('assemble-cad-bim.py')
 module = ast.parse(source.read_text(encoding='utf-8'))
-functions = {'ring_area', 'as_ccw', 'prism_triangles', 'facade_window_count', 'poly_prism',
+functions = {'ring_area', 'as_ccw', 'prism_triangles', 'facade_window_count', 'facade_entrance_edge', 'facade_panel_halfwidth', 'facade_window_positions', 'poly_prism',
              'prism_volume_sign', 'sourced_roof', 'apply_roof_uv'}
 textures = json.loads((root / 'tools/city-massing/city/cad-bim/near-estate-pilot-input/roof-textures.json').read_text())
 namespace = {'bpy': bpy, 'bmesh': bmesh, 'math': math, 'Vector': Vector,
-             'tessellate_polygon': tessellate_polygon, 'ARCHITECTURE': True, 'ROOF_TEXTURES': textures}
+             'tessellate_polygon': tessellate_polygon, 'ARCHITECTURE': True, 'FULL_REVEALS': True, 'FACADE_PROFILE': 'full', 'BANDS': False, 'ROOF_TEXTURES': textures}
 exec(compile(ast.Module(body=[node for node in module.body if isinstance(node, ast.FunctionDef)
                              and node.name in functions], type_ignores=[]), str(source), 'exec'), namespace)
 bpy.ops.object.select_all(action='SELECT')

+ 25 - 8
tools/city-massing/seat-cad-bim.mjs

@@ -1,12 +1,24 @@
 // Seat every CAD BIM building on the original DEM.
 // Flat ground is seated directly; a gently sloped footprint gets a bounded stone plinth; steeper
 // footprints are refused rather than hidden behind a fabricated platform.
-import { readFileSync, writeFileSync } from 'node:fs'
+import { readFileSync, writeFileSync, existsSync } from 'node:fs'
+import { resolve } from 'node:path'
 import { Cartesian3, Matrix4 } from 'cesium'
+import { localSeatingLevels } from './terrain-height-frame.mjs'
+
+// Existing published assets keep their original coordinates. New candidates opt
+// into the corrected frame; never silently reseat the accepted city in place.
+const useLocalHeightFrame = process.argv.includes('--local-height-frame')
 
 const dir = 'tools/city-massing/city/cad-bim/'
-const plan = JSON.parse(readFileSync(dir + 'plan.json'))
-const sampled = JSON.parse(readFileSync(dir + 'terrain-samples.json'))
+const option = name => { const at = process.argv.indexOf(name); return at < 0 ? null : process.argv[at + 1] }
+const output = option('--output') || dir + 'seated-plan.json'
+if (useLocalHeightFrame && (!option('--plan') || !option('--samples') || !option('--output') ||
+    resolve(output) === resolve(dir + 'seated-plan.json') || existsSync(output))) {
+    throw new Error('local height frame requires explicit candidate --plan, --samples and separate --output')
+}
+const plan = JSON.parse(readFileSync(option('--plan') || dir + 'plan.json'))
+const sampled = JSON.parse(readFileSync(option('--samples') || dir + 'terrain-samples.json'))
 if (plan.samples.length !== sampled.heights.length) throw new Error('DEM sample count does not match the plan')
 
 const FLAT_RELIEF = 2.5
@@ -101,8 +113,10 @@ for (const building of plan.buildings) {
             continue
         }
     }
-    const min = Math.min(...heights)
-    const max = Math.max(...heights)
+    const levels = useLocalHeightFrame ? localSeatingLevels(plan.transform,
+        building.sampleIndices.map(i => plan.samples[i]), heights) : null
+    const min = levels ? levels.minimum : Math.min(...heights)
+    const max = levels ? levels.maximum : Math.max(...heights)
     const relief = max - min
     const heightM = Math.min(MAX_HEIGHT_M, Math.max(FLOOR_HEIGHT_M, building.floors * FLOOR_HEIGHT_M))
     if (relief > FLAT_RELIEF) {
@@ -122,6 +136,7 @@ for (const building of plan.buildings) {
     const floorZ = max - 0.25
     const centroid = building.sampleIndices[building.sampleIndices.length - 1]
     const anchor = plan.samples[centroid]
+    const anchorHeight = levels ? levels.ellipsoidMaximum - 0.25 : floorZ
     buildings.push({
         id: building.id,
         kind: building.kind,
@@ -137,10 +152,12 @@ for (const building of plan.buildings) {
         heightM: Math.round(heightM * 100) / 100,
         heightSource: building.heightSource,
         positionENU: [
-            Math.round(local(anchor.lon, anchor.lat, floorZ).x * 100) / 100,
-            Math.round(local(anchor.lon, anchor.lat, floorZ).y * 100) / 100,
+            Math.round(local(anchor.lon, anchor.lat, anchorHeight).x * 100) / 100,
+            Math.round(local(anchor.lon, anchor.lat, anchorHeight).y * 100) / 100,
             Math.round(floorZ * 100) / 100,
         ],
+        ...(levels ? { heightFrame: levels.heightFrame,
+            terrainEllipsoidMin: levels.ellipsoidMinimum, terrainEllipsoidMax: levels.ellipsoidMaximum } : {}),
         foundationBottom: Math.round((min - 3) * 100) / 100,
         terrainMin: Math.round(min * 100) / 100,
         terrainMax: Math.round(max * 100) / 100,
@@ -156,7 +173,7 @@ const out = {
     construction,
     notice: plan.disclaimer + ' Seated on the original quantized-mesh DEM; steep footprints stay excluded.',
 }
-writeFileSync(dir + 'seated-plan.json', JSON.stringify(out, null, 2))
+writeFileSync(output, JSON.stringify(out, null, 2))
 console.log(JSON.stringify({
     planned: plan.buildings.length,
     seated: buildings.length,

+ 23 - 0
tools/city-massing/terrain-height-frame.mjs

@@ -0,0 +1,23 @@
+// Original DEM ellipsoid heights must be converted before use as ENU mesh Z.
+import assert from 'node:assert/strict'
+import { Cartesian3, Matrix4 } from 'cesium'
+
+export function terrainInLocalFrame(transform, samples, heights) {
+    assert.equal(samples.length, heights.length, 'terrain point/height mismatch')
+    const inverse = Matrix4.inverseTransformation(Matrix4.fromArray(transform), new Matrix4())
+    return samples.map((sample, index) => {
+        assert.ok(Number.isFinite(heights[index]) && heights[index] > 1, 'invalid original DEM height')
+        const local = Matrix4.multiplyByPoint(inverse,
+            Cartesian3.fromDegrees(sample.lon, sample.lat, heights[index]), new Cartesian3())
+        return [local.x, local.y, local.z]
+    })
+}
+
+export function localSeatingLevels(transform, samples, heights) {
+    const points = terrainInLocalFrame(transform, samples, heights)
+    const minimum = Math.min(...points.map(p => p[2])), maximum = Math.max(...points.map(p => p[2]))
+    return { minimum, maximum, relief: maximum - minimum,
+        floorZ: maximum - .25, foundationBottom: minimum - 3,
+        ellipsoidMinimum: Math.min(...heights), ellipsoidMaximum: Math.max(...heights),
+        heightFrame: 'project-local-ENU-up' }
+}

+ 5 - 2
tools/city-massing/texture-coarse-roofs.mjs

@@ -14,12 +14,13 @@ function getTile(x,y,z){const path=`D:/GIS/GIS/yuanlingPic3/${z}/${x}/${2**z-1-y
     assert.ok(existsSync(path),'missing roof imagery '+path);const bytes=readFileSync(path),image=bytes[0]===0xff?jpeg.decode(bytes):PNG.sync.read(bytes)
     assert.equal(image.width,256);assert.equal(image.height,256)
     images.set(path,image);sources.set(path,{path,sha256:createHash('sha256').update(bytes).digest('hex')});if(images.size>128)images.delete(images.keys().next().value);return image}
-export function textureCoarseRoofs(bytes,{maxDimension=256,zoom=19,metresPerPixel=null,sharpenAmount=0,jpegQuality=85}={}){
+export function textureCoarseRoofs(bytes,{maxDimension=256,zoom=19,metresPerPixel=null,sharpenAmount=0,jpegQuality=85,preserveTexturedRoofs=false}={}){
     assert.ok(Number.isInteger(jpegQuality)&&jpegQuality>=1&&jpegQuality<=100)
     assert.ok(Number.isFinite(sharpenAmount)&&sharpenAmount>=0&&sharpenAmount<=0.5)
     const {json,bin}=parseGlb(bytes),t=json.nodes[0].translation||[0,0,0]
     assert.equal(json.nodes.length,1);assert.ok(!json.nodes[0].rotation&&!json.nodes[0].scale&&!json.nodes[0].matrix)
-    const roofs=json.meshes[0].primitives.filter(p=>/^(Far_Roof|Roof_|SatelliteRoof_)/.test(json.materials[p.material].name))
+    const roofs=json.meshes[0].primitives.filter(p=>/^(Far_Roof|Roof_|SatelliteRoof_)/.test(json.materials[p.material].name)
+        && !(preserveTexturedRoofs&&json.materials[p.material].pbrMetallicRoughness?.baseColorTexture))
     if(!roofs.length){
         const ids=[...new Set(json.meshes.flatMap(m=>m.primitives.map(p=>p.material)))],map=new Map(ids.map((v,i)=>[v,i]))
         json.materials=ids.map(i=>json.materials[i]);for(const mesh of json.meshes)for(const p of mesh.primitives)p.material=map.get(p.material)
@@ -46,6 +47,8 @@ export function textureCoarseRoofs(bytes,{maxDimension=256,zoom=19,metresPerPixe
     for(const {p,g} of geometry){const values=Buffer.alloc(g.positions.length/3*8);uvBytes+=values.length
         for(let i=0;i<g.positions.length/3;i++){values.writeFloatLE((g.positions[i*3]+t[0]-low[0])/span[0],i*8);values.writeFloatLE((high[1]-(-g.positions[i*3+2]-t[2]))/span[1],i*8+4)}
         const accessor=json.accessors.length;json.accessors.push({bufferView:append(values),componentType:5126,type:'VEC2',count:g.positions.length/3});p.attributes.TEXCOORD_0=accessor;p.material=material
+        // The new UV is external to Draco; a stale mapping would override it on import.
+        if(p.extensions?.KHR_draco_mesh_compression)delete p.extensions.KHR_draco_mesh_compression.attributes.TEXCOORD_0
     }
     // Prune unused roof-tone materials without changing any primitive geometry.
     const ids=[...new Set(json.meshes.flatMap(m=>m.primitives.map(p=>p.material)))],map=new Map(ids.map((v,i)=>[v,i]));json.materials=ids.map(i=>json.materials[i]);for(const mesh of json.meshes)for(const p of mesh.primitives)p.material=map.get(p.material)

+ 25 - 3
tools/city-massing/verify-export-triangle-reference.py

@@ -2,15 +2,37 @@
 import ast, math
 from pathlib import Path
 source = Path('tools/city-massing/assemble-cad-bim.py').read_text(encoding='utf8')
-functions = {'prism_triangles', 'facade_window_count', 'massing_triangles'}
+functions = {'facade_profile_flags', 'prism_triangles', 'facade_window_count', 'facade_entrance_edge', 'facade_panel_halfwidth', 'facade_window_positions', 'massing_triangles'}
 module = ast.Module(body=[n for n in ast.parse(source).body if isinstance(n, ast.FunctionDef) and n.name in functions], type_ignores=[])
-scope = {'math': math, 'ARCHITECTURE': True}
+scope = {'math': math, 'ARCHITECTURE': True, 'FULL_REVEALS': True, 'FACADE_PROFILE': 'off', 'BANDS': False}
 exec(compile(module, 'triangle-reference-seam', 'exec'), scope)
+assert scope['facade_profile_flags']('off', False, False) == {
+    'profile': 'off', 'architecture': False, 'fullReveals': False, 'bands': False}
+assert scope['facade_profile_flags']('compact', False, False) == {
+    'profile': 'compact', 'architecture': True, 'fullReveals': False, 'bands': False}
+assert scope['facade_profile_flags']('full', True, False)['fullReveals'] is True
+try:
+    scope['facade_profile_flags']('full', False, False)
+except RuntimeError:
+    pass
+else:
+    raise AssertionError('full profile must require near architecture')
 # A notch-sized edge must remain in the solid but cannot fit the 0.35m facade panel.
 ring = [(0,0),(.2,0),(.2,5),(0,5)]
 # 4*n-4 shell + 12 door + 2 long-wall windows * 2 triangles * 4 floors.
 assert scope['massing_triangles'](ring,4,'lod1') == 12+12+16
 scope['ARCHITECTURE'] = False
+scope['FACADE_PROFILE'] = 'off'
 # Original large rectangular case retains one window on every edge.
 assert scope['massing_triangles']([(0,0),(12,0),(12,9),(0,9)],3,'lod1') == 12+12+24
-print({'passed':True,'scope':'short surveyed edges counted only as shell; facade reference matches panel size guard','blenderStarted':False})
+scope['ARCHITECTURE'] = True
+scope['FULL_REVEALS'] = False
+scope['FACADE_PROFILE'] = 'compact'
+scope['BANDS'] = False
+# 12x9m: 30 windows across 3 floors, except one centered ground window
+# reserved for the entrance: 12 shell + 12 door + 58 glass + 16 bands = 98.
+assert scope['massing_triangles']([(0,0),(12,0),(12,9),(0,9)],3,'lod0') == 98
+scope['FULL_REVEALS'] = True
+# Optional reveals add 58*4 triangles, roof edges add 8; the cap remains unchanged.
+assert scope['massing_triangles']([(0,0),(12,0),(12,9),(0,9)],3,'lod0') == 338
+print({'passed':True,'scope':'short surveyed edges plus compact massing facade profile reference','blenderStarted':False})

+ 2 - 2
tools/city-massing/verify-roof-geometry.py

@@ -14,10 +14,10 @@ from mathutils.geometry import tessellate_polygon
 source = Path(__file__).with_name('assemble-cad-bim.py')
 module = ast.parse(source.read_text(encoding='utf-8'))
 # Load the real function definitions; city intake and export are deliberately not executed.
-functions = {'ring_area', 'as_ccw', 'prism_triangles', 'facade_window_count', 'massing_triangles',
+functions = {'ring_area', 'as_ccw', 'prism_triangles', 'facade_window_count', 'facade_entrance_edge', 'facade_panel_halfwidth', 'facade_window_positions', 'massing_triangles',
              'poly_prism', 'prism_volume_sign'}
 namespace = {'bpy': bpy, 'bmesh': bmesh, 'math': math, 'Vector': Vector,
-             'tessellate_polygon': tessellate_polygon, 'ARCHITECTURE': '--legacy' not in sys.argv}
+             'tessellate_polygon': tessellate_polygon, 'ARCHITECTURE': '--legacy' not in sys.argv, 'FULL_REVEALS': '--legacy' not in sys.argv, 'FACADE_PROFILE': 'off', 'BANDS': False}
 exec(compile(ast.Module(body=[node for node in module.body
                              if isinstance(node, ast.FunctionDef) and node.name in functions],
                         type_ignores=[]), str(source), 'exec'), namespace)

+ 5 - 2
tools/city-massing/verify-streaming-tiles.mjs

@@ -10,6 +10,9 @@ const argv = process.argv.slice(2)
 // Flat-face agreement is opt-in: curved/smooth library assets need not match each face normal.
 const checkFlatNormals = argv.includes('--check-flat-normals')
 if (checkFlatNormals) argv.splice(argv.indexOf('--check-flat-normals'), 1)
+// Reject collapsed faces independently of the optional flat-shading criterion.
+const rejectDegenerate = argv.includes('--reject-degenerate')
+if (rejectDegenerate) argv.splice(argv.indexOf('--reject-degenerate'), 1)
 const reportIndex = argv.indexOf('--report')
 const reportPath = reportIndex < 0 ? null : argv.splice(reportIndex, 2)[1]
 const dirs = argv.length ? argv : [process.argv[2]]
@@ -92,7 +95,7 @@ function decodePrimitive(path, json, bin, primitive, translation, bounds) {
                 const area2 = Math.hypot(...cross)
                 if (area2 < 1e-10) {
                     degenerateTriangles++
-                    assert.ok(!checkFlatNormals, `${path}: triangle ${face} collapsed after decoding`)
+                    assert.ok(!(rejectDegenerate || checkFlatNormals), `${path}: triangle ${face} collapsed after decoding`)
                     continue
                 }
                 for (const id of ids) {
@@ -209,7 +212,7 @@ for (const directory of dirs) {
             `${directory}: ${level} source triangle count changed`)
     }
     const result = { directory, buildings: manifest.count, files, triangles, vertices, bytes, maxVertices, maxTriangles,
-        budget: manifest.budget, normals: { flatFaceAgreementRequired: checkFlatNormals, checkedNormalTriangles,
+        budget: manifest.budget, normals: { flatFaceAgreementRequired: checkFlatNormals, degenerateRejectionRequired: rejectDegenerate || checkFlatNormals, checkedNormalTriangles,
             degenerateTriangles, minNormalDot }, maxDecodedTextureBytes: manifest.budget.maxDecodedTextureBytes === undefined ? null : maxDecodedTextureBytes,
         decodedTextureBudgetValidated: manifest.budget.maxDecodedTextureBytes !== undefined }
     all.push(result)

+ 43 - 0
tools/city-massing/window_texture.py

@@ -0,0 +1,43 @@
+"""Blender-free contract for an estimated shared compact window-frame image."""
+import hashlib
+import json
+import math
+from pathlib import Path
+import struct
+
+
+def load_window_texture(manifest_path):
+    manifest_path = Path(manifest_path).resolve()
+    record = json.loads(manifest_path.read_text(encoding='utf-8'))
+    if record.get('kind') != 'estimated-window-frame' or record.get('version') not in (1, 2):
+        raise ValueError('unsupported compact window texture manifest')
+    path = (manifest_path.parent / record['image']).resolve()
+    data = path.read_bytes()
+    if len(data) > 8192 or len(data) < 33 or data[:8] != b'\x89PNG\r\n\x1a\n':
+        raise ValueError('window texture must be a bounded PNG')
+    width, height = struct.unpack('>II', data[16:24])
+    size_valid = (width, height) == (64, 64) if record['version'] == 1 else (
+        width == height and width in (32, 64) and record.get('dimensions') == [width, height])
+    if not size_valid or data[24:26] != bytes((8, 2)):
+        raise ValueError('window texture must be bounded opaque RGB8; v1=64px, v2=declared 32/64px')
+    if hashlib.sha256(data).hexdigest() != record['sha256']:
+        raise ValueError('window texture source hash mismatch')
+    mip_bytes = sum(max(1, width >> level) * max(1, height >> level) * 4
+                    for level in range(width.bit_length()))
+    return {**record, 'path': str(path), 'rasterBytes': len(data),
+            'width': width, 'height': height, 'rgba8FullMipEstimate': mip_bytes}
+
+
+def window_quad_uv(points):
+    """Map a vertical window quad to one tile; never reuse geographic roof UVs."""
+    if len(points) != 4:
+        raise ValueError('window face is not a quad')
+    pairs = [(b[0]-a[0], b[1]-a[1]) for a in points for b in points]
+    dx, dy = max(pairs, key=lambda p: math.hypot(*p))
+    length = math.hypot(dx, dy)
+    low_z, high_z = min(p[2] for p in points), max(p[2] for p in points)
+    if length < 1e-6 or high_z-low_z < 1e-6:
+        raise ValueError('window quad has no width or height')
+    along = [(p[0]-points[0][0])*dx/length + (p[1]-points[0][1])*dy/length for p in points]
+    low, high = min(along), max(along)
+    return [((u-low)/(high-low), (p[2]-low_z)/(high_z-low_z)) for u,p in zip(along,points)]

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor