diff --git a/Standalone/Core/src/ShapeWorldInternal.h b/Standalone/Core/src/ShapeWorldInternal.h index 60a4ba3..a2b706d 100644 --- a/Standalone/Core/src/ShapeWorldInternal.h +++ b/Standalone/Core/src/ShapeWorldInternal.h @@ -5973,6 +5973,10 @@ private: } const float centerX=bezier(t,0),centerY=bezier(t,1); const float floorZ=bezier(t,2); + const float floorHorizontalSpeed=std::hypot(derivative(t,0),derivative(t,1)); + const float floorGrade=floorHorizontalSpeed>1.0e-5f + ?derivative(t,2)/floorHorizontalSpeed:0.0f; + const float floorDistanceScale=1.0f/std::sqrt(1.0f+floorGrade*floorGrade); const float radial=std::hypot(X-centerX,Y-centerY); const float radius=feature.boreRadiusVoxels; const float vertical=(Z-floorZ)/feature.boreHeightVoxels; @@ -6000,11 +6004,12 @@ private: return; } composed=SmoothMaxDistance(composed,-distance,feature.smoothWidthVoxels); - // The hard profile cap is deliberately applied after the smooth union and - // relief. Below it, native rock remains untouched; the narrow support above - // fills only baseline air. + // Apply a signed distance to the sloped floor plane. The old vertical + // height field made Marching Cubes quantize the shallow ramp into terraces. + // Below it, native rock remains untouched; the narrow support above fills + // only baseline air. if(radial<=radius) - composed=std::min(composed,Z-floorZ); + composed=std::min(composed,(Z-floorZ)*floorDistanceScale); }; if(scoped) for(const auto index:tileQueries->cavernFeatureCandidates())apply(index); @@ -6097,12 +6102,17 @@ private: t=std::clamp(t-(dx*vx+dy*vy)/denominator,0.0f,1.0f); } const float floorZ=bezier(t,2); + const float floorHorizontalSpeed=std::hypot(derivative(t,0),derivative(t,1)); + const float floorGrade=floorHorizontalSpeed>1.0e-5f + ?derivative(t,2)/floorHorizontalSpeed:0.0f; + const float floorDistanceScale=1.0f/std::sqrt(1.0f+floorGrade*floorGrade); const float radial=std::hypot(X-bezier(t,0),Y-bezier(t,1)); - if(Z0.0f&&feature.floorSupportDepthVoxels>0.0f &&Z>=floorZ-feature.floorSupportDepthVoxels &&radial0.0f) + { + const float fade=SmoothUnit((Z-floorZ)/0.8f); + tunnelDistance+=StampValueNoise( + X-feature.controlPointsVoxels[0][0], + Y-feature.controlPointsVoxels[0][1],Z-floorZ, + feature.irregularityWavelengthVoxels,feature.irregularitySeed) + *feature.irregularityAmplitudeVoxels*fade; + } + // Reapply the tunnel SDF in the floor-adjacent strip. Keep a narrow + // correction along the ramp, with a wider fade only at the stamped mouth. + const float heightAboveFloor=Z-floorZ; + const float mouthFade=1.0f-SmoothUnit(t/0.10f); + const float recarveWeight=heightAboveFloor<=0.25f?1.0f:mouthFade; + if(recarveWeight>0.0f) + { + const float recarved=SmoothMaxDistance(capped,-tunnelDistance, + feature.smoothWidthVoxels); + capped=std::lerp(capped,recarved,recarveWeight); + } + } if(radial<=feature.boreRadiusVoxels) - capped=std::min(capped,Z-floorZ); + capped=std::min(capped,floorDistance); }; if(scoped) for(const auto index:tileQueries->cavernFeatureCandidates())apply(index); diff --git a/Standalone/Core/src/VoxelForgeCore.cpp b/Standalone/Core/src/VoxelForgeCore.cpp index 43a6af5..a01386e 100644 --- a/Standalone/Core/src/VoxelForgeCore.cpp +++ b/Standalone/Core/src/VoxelForgeCore.cpp @@ -2294,6 +2294,30 @@ namespace VoxelForgeCore RelevantMinimum=std::min(RelevantMinimum,MinimumZ); RelevantMaximum=std::max(RelevantMaximum,MaximumZ); } + const auto includeCavernBounds=[&](const auto& Feature) + { + const double MinimumX=Feature.boundsMinimumMicrometres[0] + *MicronsToVoxels; + const double MaximumX=Feature.boundsMaximumMicrometres[0] + *MicronsToVoxels; + const double MinimumY=Feature.boundsMinimumMicrometres[1] + *MicronsToVoxels; + const double MaximumY=Feature.boundsMaximumMicrometres[1] + *MicronsToVoxels; + if(MaximumXTileMaximumX + ||MaximumYTileMaximumY)return; + const double MinimumZ=Feature.boundsMinimumMicrometres[2] + *MicronsToVoxels; + const double MaximumZ=Feature.boundsMaximumMicrometres[2] + *MicronsToVoxels; + if(MaximumZTileMaximumZ)return; + RelevantMinimum=std::min(RelevantMinimum,MinimumZ); + RelevantMaximum=std::max(RelevantMaximum,MaximumZ); + }; + for(const auto& Room:impl_->resolvedLayout->cavernRooms) + includeCavernBounds(Room); + for(const auto& Edge:impl_->resolvedLayout->cavernEdges) + includeCavernBounds(Edge); } if(std::isfinite(RelevantMinimum)&&std::isfinite(RelevantMaximum)) { diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/CaptureLandmarkL47.py b/Standalone/Research/WorldAssemblerM3/Linux47/CaptureLandmarkL47.py new file mode 100644 index 0000000..524e6d8 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux47/CaptureLandmarkL47.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""Capture L47 cavern entrance views and audit the exported floor profile.""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import time + + +ROOT = Path(__file__).resolve().parents[4] +VERIFY = ROOT / "Standalone/Tools/verify" +LINUX37 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux37" +sys.path.insert(0, str(VERIFY)) +from runner import bounded_run, current_stamp, first_stamp + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +PROFILE = load_module("l45_ring_profile", ROOT / + "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py") +SHAPE = load_module("l45_shape_audit", LINUX37 / "AuditMouthShapeL37.py") + +PARSER = argparse.ArgumentParser(description=__doc__) +PARSER.add_argument("--layout", type=Path, required=True) +PARSER.add_argument("--artifact-dir", type=Path, required=True) +PARSER.add_argument("--capture-dir", type=Path, required=True) +PARSER.add_argument("--data", type=Path, default=ROOT / "Standalone/data/owner_world.json") +OPTIONS = PARSER.parse_args() + +ARTIFACT_DIR = OPTIONS.artifact_dir.resolve() +CAPTURE_DIR = OPTIONS.capture_dir.resolve() +LAYOUT_PATH = OPTIONS.layout.resolve() +ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) +CAPTURE_DIR.mkdir(parents=True, exist_ok=True) +PNG_DIR = ARTIFACT_DIR / "capture-png" +PNG_DIR.mkdir(parents=True, exist_ok=True) +HEADLESS = Path(os.environ["VOXELFORGE_L37_HEADLESS"]) +RENDERER = Path(os.environ["VOXELFORGE_L37_RENDERER"]) +EXPECTED_STAMP = current_stamp() + + +def run_logged(name: str, command: list[str], *, capture: bool = False) -> str: + log = ARTIFACT_DIR / f"{name}.log" + started = time.monotonic() + with log.open("w", encoding="utf-8") as stream: + result = bounded_run(command, timeout=1800, stdout=stream) + elapsed = time.monotonic() - started + output = log.read_text(encoding="utf-8", errors="replace") + if result is None: + raise RuntimeError(f"{name} timed out; see {log}") + print(f"[L47CaptureRun] step={name} elapsed={elapsed:.1f}s " + f"exit={result.returncode} log={log}", flush=True) + if result.returncode != 0: + raise RuntimeError(f"{name} failed; see {log}") + if capture and (first_stamp(output) != EXPECTED_STAMP + or first_stamp(bounded_run( + [str(command[0]), "--print-source-stamp"], timeout=15).stdout or "") + != EXPECTED_STAMP): + raise RuntimeError(f"{name} executable source stamp mismatch") + return output + + +def stamp_by_id(layout: dict, stable_id: str) -> dict: + for stamp in layout.get("stamps", []): + if stamp.get("stable_id") == stable_id: + return stamp + raise RuntimeError(f"missing stamp {stable_id}") + + +def metres(stamp: dict, key: str = "position_um") -> tuple[float, float, float]: + return tuple(value / 1_000_000.0 for value in stamp[key]) + + +def camera_angles(camera, target): + dx, dy, dz = (target[index] - camera[index] for index in range(3)) + return (math.degrees(math.atan2(dx, dy)), + math.degrees(math.atan2(dz, math.hypot(dx, dy)))) + + +def fmt(values) -> str: + return ",".join(f"{value:.6f}" for value in values) + + +def camera_spec(camera, target): + yaw, pitch = camera_angles(camera, target) + return (*camera, yaw, pitch) + + +def edge_point(edge: dict, t: float) -> tuple[float, float, float]: + points = edge["control_points_um"] + one = 1.0 - t + weights = (one ** 3, 3 * one ** 2 * t, 3 * one * t ** 2, t ** 3) + return tuple(sum(weights[index] * points[index][axis] + for index in range(4)) / 1_000_000.0 + for axis in range(3)) + + +def cavern_features(layout: dict): + if (layout.get("cavern_graph_vocabulary_version") != 1 + or len(layout.get("cavern_rooms", [])) != 1 + or len(layout.get("cavern_edges", [])) != 1): + raise RuntimeError("L47 capture needs one resolved cavern room and edge") + room = layout["cavern_rooms"][0] + edge = layout["cavern_edges"][0] + return room, edge + + +layout = json.loads(LAYOUT_PATH.read_text(encoding="utf-8")) +room, edge = cavern_features(layout) +portal_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-mouth-approach-v1") +floor_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-floor") +dais_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-dais") +entrance = edge_point(edge, 0.0) +next_ramp = edge_point(edge, 0.08) +portal = metres(portal_stamp) +chamber = metres(room, "center_um") +floor = metres(floor_stamp) +floor_top = floor[2] + floor_stamp["half_extents_um"][2] / 1_000_000.0 +dais = metres(dais_stamp) +dais_top = dais[2] + dais_stamp["half_extents_um"][2] / 1_000_000.0 +ground_z = entrance[2] +direction_length = math.hypot(next_ramp[0] - entrance[0], next_ramp[1] - entrance[1]) +direction = ((next_ramp[0] - entrance[0]) / direction_length, + (next_ramp[1] - entrance[1]) / direction_length) +outward_length = math.hypot(entrance[0] - chamber[0], entrance[1] - chamber[1]) +outward = ((entrance[0] - chamber[0]) / outward_length, + (entrance[1] - chamber[1]) / outward_length) +mouth_normal = (-direction[1], direction[0]) + +mesh = ARTIFACT_DIR / "landmark-l47.vfmesh" +export_anchor = edge_point(edge, 0.25) +export_camera = (export_anchor[0], export_anchor[1], export_anchor[2] + 1.4) +export_target_floor = edge_point(edge, 0.42) +export_target = (export_target_floor[0], export_target_floor[1], + export_target_floor[2] + 1.2) +run_logged("landmark-export", [str(HEADLESS), str(OPTIONS.data), "--workers=12", + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--force-generate", "--clip-radius=4", "--max-clip-level=0", "--max-level=0", + "--render-distance-chunks=0", "--recipe=surface-world", "--landmark-chamber-demo", + f"--camera={fmt(camera_spec(export_camera, export_target))}", + f"--mesh-export={mesh}", f"--resolved-layout-output={LAYOUT_PATH}"], capture=True) +layout = json.loads(LAYOUT_PATH.read_text(encoding="utf-8")) +room, edge = cavern_features(layout) +portal_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-mouth-approach-v1") +floor_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-floor") +dais_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-dais") +entrance = edge_point(edge, 0.0) +next_ramp = edge_point(edge, 0.08) +portal = metres(portal_stamp) +chamber = metres(room, "center_um") +floor = metres(floor_stamp) +floor_top = floor[2] + floor_stamp["half_extents_um"][2] / 1_000_000.0 +dais = metres(dais_stamp) +dais_top = dais[2] + dais_stamp["half_extents_um"][2] / 1_000_000.0 +ground_z = entrance[2] +direction_length = math.hypot(next_ramp[0] - entrance[0], next_ramp[1] - entrance[1]) +direction = ((next_ramp[0] - entrance[0]) / direction_length, + (next_ramp[1] - entrance[1]) / direction_length) +outward_length = math.hypot(entrance[0] - chamber[0], entrance[1] - chamber[1]) +outward = ((entrance[0] - chamber[0]) / outward_length, + (entrance[1] - chamber[1]) / outward_length) +if not mesh.is_file() or mesh.stat().st_size == 0: + raise RuntimeError(f"missing mesh export {mesh}") + +entrance_audit = ROOT / "Standalone/Research/WorldAssemblerM3/Linux47/MeasureEntranceL47.py" +run_logged("entrance-floor-control-l46", [sys.executable, str(entrance_audit), + "--mesh", str(ROOT / "Saved/Linux/l46/landmark-l46.vfmesh"), + "--layout", str(ROOT / "Saved/Linux/l46/layouts/landmark-reachability-clang.json"), + "--out", str(ARTIFACT_DIR / "entrance-profile-control-l46.json"), "--expect=fail"]) +run_logged("entrance-floor-audit", [sys.executable, str(entrance_audit), + "--mesh", str(mesh), "--layout", str(LAYOUT_PATH), + "--out", str(ARTIFACT_DIR / "entrance-profile-l47.json"), "--expect=pass"]) + +voxel_um, triangles, tile_count = PROFILE.read_scene(str(mesh), 0, + floor_top - 8.0, entrance[2] + 9.0, + (min(entrance[0], chamber[0]) - 20.0, max(entrance[0], chamber[0]) + 20.0, + min(entrance[1], chamber[1]) - 20.0, max(entrance[1], chamber[1]) + 20.0)) +bounds = (min(entrance[0], chamber[0]) - 20.0, max(entrance[0], chamber[0]) + 20.0, + min(entrance[1], chamber[1]) - 20.0, max(entrance[1], chamber[1]) + 20.0) +buckets = PROFILE.build_buckets(triangles, True, bounds, cell_size=1.0) +ground_candidates = [] +for ix in range(-20, 21): + for iy in range(-20, 21): + x, y = portal[0] + ix * 0.5, portal[1] + iy * 0.5 + dx, dy = x - portal[0], y - portal[1] + distance = math.hypot(dx, dy) + if not 6.0 <= distance <= 10.0: + continue + hit = PROFILE.sample_surface(buckets, x, y, True, 1.0, + preferred_height=ground_z) + if hit is None or abs(hit[0] - ground_z) > 3.0 or hit[1][2] < 0.85: + continue + alignment = max(-1.0, min(1.0, + (dx * mouth_normal[0] + dy * mouth_normal[1]) / distance)) + angle = math.acos(alignment) + score = abs(distance - 7.0) + angle * 4.0 + ground_candidates.append((score, x, y, hit[0])) +if not ground_candidates: + raise RuntimeError("no walkable mesh ground lies 6 to 10 m from the cave mouth") +_, ground_x, ground_y, outside_ground = min(ground_candidates) +inside_floor = PROFILE.sample_surface(buckets, chamber[0], chamber[1], True, 1.0, + preferred_height=floor_top) +if inside_floor is None or abs(inside_floor[0] - floor_top) > 1.5: + raise RuntimeError(f"chamber floor mesh hit {inside_floor} does not match designed floor {floor_top:.3f} m") +floor_top = inside_floor[0] + +ground_camera = (ground_x, ground_y, outside_ground + 1.6) +entry_ground = PROFILE.sample_surface(buckets, entrance[0], entrance[1], True, 1.0, + preferred_height=ground_z) +if entry_ground is None: + raise RuntimeError("cave mouth has no walkable ground surface in the exported mesh") +entry_camera = (entrance[0] + mouth_normal[0] * 3.8, + entrance[1] + mouth_normal[1] * 3.8, ground_z + 1.6) +entry_target_floor = edge_point(edge, 0.30) +entry_target = (entry_target_floor[0], entry_target_floor[1], entry_target_floor[2] + 1.4) +low_camera = (entrance[0] + outward[0] * 1.0, + entrance[1] + outward[1] * 1.0, ground_z + 0.65) +low_target_floor = edge_point(edge, 0.24) +low_target = (low_target_floor[0], low_target_floor[1], low_target_floor[2] + 0.15) +to_dais = (dais[0] - chamber[0], dais[1] - chamber[1]) +to_dais_length = math.hypot(*to_dais) +opposite_dais = (-to_dais[0] / to_dais_length, -to_dais[1] / to_dais_length) +inside_x = chamber[0] +inside_y = chamber[1] +inside_floor = PROFILE.sample_surface(buckets, inside_x, inside_y, True, 1.0, + preferred_height=floor_top) +inside_eye_z = inside_floor[0] + 1.6 +chamber_camera = (chamber[0] + opposite_dais[0] * 5.5, + chamber[1] + opposite_dais[1] * 5.5, inside_eye_z) +chamber_target = (dais[0], dais[1], dais_top - 0.25) +overview_camera = (entrance[0] + outward[0] * 18.0, + entrance[1] + outward[1] * 18.0, outside_ground + 15.0) +overview_target = ((entrance[0] + chamber[0]) * 0.5, + (entrance[1] + chamber[1]) * 0.5, + (ground_z + floor_top) * 0.5) +inward = (-outward[0], -outward[1]) +clip_point = (entrance[0] + outward[0] * 0.5, + entrance[1] + outward[1] * 0.5) +overview_clip = (inward[0], inward[1], 0.0, + -(inward[0] * clip_point[0] + inward[1] * clip_point[1])) + +views = ( + ("ground-entrance", ground_camera, (portal[0], portal[1], portal[2]), + (portal[0], portal[1], ground_z, 20.0), -509.0), + ("entrance-looking-in", entry_camera, entry_target, (entrance[0], entrance[1], entrance[2], 16.0), -507.0), + ("entrance-floor-low", low_camera, low_target, + (entrance[0], entrance[1], entrance[2], 12.0), ground_z), + ("inside-to-dais", chamber_camera, chamber_target, + (chamber[0], chamber[1], chamber[2], 15.0), chamber[2]), + ("overview-entrance-and-chamber", overview_camera, overview_target, + ((entrance[0] + chamber[0]) * 0.5, + (entrance[1] + chamber[1]) * 0.5, + (ground_z + floor_top) * 0.5, 34.0), -509.0), +) + +for name, camera, target, focus, height_center in views: + png = PNG_DIR / f"{name}.png" + jpg = CAPTURE_DIR / f"{name}.jpg" + yaw, pitch = camera_angles(camera, target) + fov = (80.0 if name == "overview-entrance-and-chamber" else + 70.0 if name in ("inside-to-dais", "entrance-looking-in") else + 58.0 if name == "entrance-floor-low" else 65.0 + if name == "ground-entrance" else 60.0) + if name == "ground-entrance": + fov = 55.0 + print(f"[L47CaptureCamera] view={name} camera_m=({fmt(camera)}) " + f"target_m=({fmt(target)}) yaw_deg={yaw:.3f} pitch_deg={pitch:.3f} " + f"fov_deg={fov:.1f} entrance_distance_m={math.dist(camera, portal):.3f}", flush=True) + render = [str(RENDERER), str(mesh), str(png), "--size=1600x900", f"--fov-deg={fov:.1f}", + f"--camera-m={fmt(camera)}", f"--yaw-deg={yaw:.6f}", f"--pitch-deg={pitch:.6f}", + "--shade=readable", "--headlamp=on", "--height-tint=on", + "--light-direction=0.3,-0.4,0.85", + f"--height-center-m={(floor_top if name == 'inside-to-dais' else height_center):.6f}", + f"--height-range-m={4 if name == 'inside-to-dais' else 6 if name == 'entrance-floor-low' else 16}", + "--tile-skirts=off", "--tile-edges=off", + f"--focus-region={fmt(focus)}"] + if name == "overview-entrance-and-chamber": + render.append(f"--clip-plane={fmt(overview_clip)}") + run_logged(f"{name}-render", render, capture=True) + for quality in (4, 6, 8): + run_logged(f"{name}-jpeg-q{quality}", ["ffmpeg", "-nostdin", "-hide_banner", + "-loglevel", "error", "-y", "-i", str(png), "-frames:v", "1", + "-q:v", str(quality), str(jpg)]) + if jpg.is_file() and 0 < jpg.stat().st_size <= 300_000: + break + if not jpg.is_file() or not 0 < jpg.stat().st_size <= 300_000: + raise RuntimeError(f"{jpg.name} exceeds the 300 KB capture limit") + quality_log = run_logged(f"{name}-quality", [sys.executable, + str(VERIFY / "capture_quality.py"), str(jpg)]) + print(f"[L47CaptureJPEG] file={jpg} bytes={jpg.stat().st_size} " + f"quality_log={quality_log.strip().replace(chr(10), ' ')} status=PASS", flush=True) + + +def region_triangles(center, radius_xy, z_min, z_max): + selected = [] + for triangle in triangles: + points = tuple(vertex[0] for vertex in triangle[2]) + centroid = tuple(sum(point[axis] for point in points) / 3.0 for axis in range(3)) + if (math.hypot(centroid[0] - center[0], centroid[1] - center[1]) <= radius_xy + and z_min <= centroid[2] <= z_max): + selected.append(triangle) + return selected + + +def tunnel_wall_triangles(edge, t, height_above_floor): + center = edge_point(edge, t) + before = edge_point(edge, max(0.0, t - 0.01)) + after = edge_point(edge, min(1.0, t + 0.01)) + tx, ty = after[0] - before[0], after[1] - before[1] + tangent_length = math.hypot(tx, ty) + tx, ty = tx / tangent_length, ty / tangent_length + expected_radius = edge["bore_radius_um"] / 1_000_000.0 * math.sqrt( + 1.0 - (height_above_floor / (edge["bore_height_um"] / 1_000_000.0)) ** 2) + selected = [] + for triangle in triangles: + points = tuple(vertex[0] for vertex in triangle[2]) + centroid = tuple(sum(point[axis] for point in points) / 3.0 for axis in range(3)) + dx, dy = centroid[0] - center[0], centroid[1] - center[1] + longitudinal = dx * tx + dy * ty + lateral = abs(-dy * tx + dx * ty) + vertical = centroid[2] - center[2] + if (abs(longitudinal) <= 1.5 + and abs(lateral - expected_radius) <= 0.7 + and abs(vertical - height_above_floor) <= 0.75): + selected.append(triangle) + print(f"[L47TunnelWallWindow] t={t:.3f} center_m=({center[0]:.3f}," + f"{center[1]:.3f},{center[2]:.3f}) expected_radius_m={expected_radius:.3f} " + f"longitudinal_half_width_m=1.500 vertical_half_width_m=0.750 " + f"triangles={len(selected)}", flush=True) + return selected + + +def audit_shape(name, region, center): + metrics = SHAPE.analyze_triangles(region, center[:2], center[2], + crop_to_center=False, include_sides=True) + passed = (metrics["triangles"] > 0 + and metrics["planar_area"] < SHAPE.PLANE_LIMIT_M2 + and metrics["straight"] <= SHAPE.STRAIGHT_LIMIT_M + and metrics["corners"] == 0) + print(f"[LandmarkBuiltShape] region={name} triangles={metrics['triangles']} " + f"planar_patch_m2={metrics['planar_area']:.4f} " + f"straight_run_m={metrics['straight']:.4f} sharp_corners={metrics['corners']} " + f"corner_turn_max_deg={metrics['corner_turn_max']:.2f} " + f"thresholds=({SHAPE.PLANE_LIMIT_M2:.2f}m2,{SHAPE.STRAIGHT_LIMIT_M:.2f}m," + f"{SHAPE.CORNER_TURN_LIMIT_DEG:.0f}deg) status={'PASS' if passed else 'FAIL'}", + flush=True) + if not passed: + raise RuntimeError(f"L37 built-shape thresholds failed in {name}") + return metrics + + +entrance_metrics = audit_shape("cave-mouth", region_triangles( + portal, 5.0, ground_z + 0.8, ground_z + 4.5), + (portal[0], portal[1], ground_z)) +tunnel_mid = edge_point(edge, 0.64) +tunnel_metrics = audit_shape("tunnel-wall-edge", tunnel_wall_triangles( + edge, 0.64, 1.2), + (tunnel_mid[0], tunnel_mid[1], tunnel_mid[2] + 1.2)) +dais_surface = PROFILE.sample_surface(buckets, dais[0], dais[1], True, 1.0, + preferred_height=dais_top - 0.4) +if dais_surface is None: + raise RuntimeError("dais stamp has no upward-facing surface in the exported mesh") +dais_rise = dais_surface[0] - floor_top +print(f"[LandmarkDaisSurface] center_m=({dais[0]:.3f},{dais[1]:.3f}) " + f"floor_z_m={floor_top:.3f} top_z_m={dais_surface[0]:.3f} " + f"rise_m={dais_rise:.3f} minimum_m=1.000 " + f"status={'PASS' if dais_rise >= 1.0 else 'FAIL'}", flush=True) +if dais_rise < 1.0: + raise RuntimeError(f"dais rises only {dais_rise:.3f} m above the chamber floor") +dais_metrics = audit_shape("dais-platform", region_triangles( + dais, 1.7, floor_top + 0.6, dais_top + 0.4), + (dais[0], dais[1], dais_surface[0])) +metrics_path = ARTIFACT_DIR / "landmark-shape-metrics.json" +metrics_path.write_text(json.dumps({ + "graph_vocabulary_version": layout["cavern_graph_vocabulary_version"], + "entry_m": entrance, "portal_m": portal, "entry_ground_m": ground_z, + "chamber_center_m": chamber, "chamber_floor_m": floor_top, + "tunnel_midpoint_m": tunnel_mid, + "dais_center_m": dais, "dais_top_m": dais_top, + "dais_surface_m": dais_surface[0], "dais_rise_m": dais_rise, + "l37_thresholds": {"planar_patch_m2_lt": SHAPE.PLANE_LIMIT_M2, + "straight_run_m_lte": SHAPE.STRAIGHT_LIMIT_M, + "corner_turn_deg_lt": SHAPE.CORNER_TURN_LIMIT_DEG}, + "entrance_arch": entrance_metrics, "tunnel_wall": tunnel_metrics, + "dais_platform": dais_metrics, + "mesh_tiles": tile_count, "mesh_voxel_um": voxel_um, +}, indent=2) + "\n", encoding="utf-8") +print(f"[L47CaptureShapeMetrics] file={metrics_path} status=PASS", flush=True) diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/MeasureEntranceL47.py b/Standalone/Research/WorldAssemblerM3/Linux47/MeasureEntranceL47.py new file mode 100644 index 0000000..3620116 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux47/MeasureEntranceL47.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Measure the L46 entrance floor from open terrain through its first ten metres.""" +from __future__ import annotations + +import importlib.util +import argparse +import json +import math +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[4] +PROFILE_PATH = ROOT / "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py" +spec = importlib.util.spec_from_file_location("ring_profile", PROFILE_PATH) +if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {PROFILE_PATH}") +PROFILE = importlib.util.module_from_spec(spec) +spec.loader.exec_module(PROFILE) + +ARTIFACTS = ROOT / "Saved/Linux/l47" +PARSER = argparse.ArgumentParser(description=__doc__) +PARSER.add_argument("--layout", type=Path, + default=ROOT / "Saved/Linux/l46/layouts/landmark-reachability-clang.json") +PARSER.add_argument("--mesh", type=Path, + default=ROOT / "Saved/Linux/l46/landmark-l46.vfmesh") +PARSER.add_argument("--out", type=Path, default=ARTIFACTS / "entrance-profile-before.json") +PARSER.add_argument("--expect", choices=("pass", "fail", "measure"), default="measure") +PARSER.add_argument("--lod", default="0", help="LOD level, or 'all' to include every exported level") +OPTIONS = PARSER.parse_args() +LAYOUT = OPTIONS.layout.resolve() +MESH = OPTIONS.mesh.resolve() +OUT = OPTIONS.out.resolve() + +layout = json.loads(LAYOUT.read_text(encoding="utf-8")) +edge = layout["cavern_edges"][0] +controls = [[value / 1_000_000.0 for value in point] + for point in edge["control_points_um"]] + + +def bezier(t: float) -> tuple[float, float, float]: + u = 1.0 - t + weights = (u ** 3, 3.0 * u * u * t, 3.0 * u * t * t, t ** 3) + return tuple(sum(weights[i] * controls[i][axis] for i in range(4)) + for axis in range(3)) + + +route = [bezier(i / 4096.0) for i in range(4097)] +arc = [0.0] +for a, b in zip(route, route[1:]): + arc.append(arc[-1] + math.hypot(b[0] - a[0], b[1] - a[1])) + + +def route_at(distance: float) -> tuple[float, float, float]: + if distance < 0.0: + start = route[0] + dx = route[1][0] - start[0] + dy = route[1][1] - start[1] + dz = route[1][2] - start[2] + horizontal = math.hypot(dx, dy) + scale = distance / (arc[1] - arc[0]) + return start[0] + dx * scale, start[1] + dy * scale, start[2] + dz * scale + index = min(len(arc) - 2, max(0, next( + (i for i, value in enumerate(arc) if value >= distance), len(arc) - 1) - 1)) + span = arc[index + 1] - arc[index] + t = 0.0 if span <= 0.0 else (distance - arc[index]) / span + return tuple(route[index][axis] + (route[index + 1][axis] - route[index][axis]) * t + for axis in range(3)) + + +entry = route[0] +tangent = route[1] +tx, ty = tangent[0] - entry[0], tangent[1] - entry[1] +length = math.hypot(tx, ty) +tx, ty = tx / length, ty / length +nx, ny = -ty, tx +requested_lod = None if OPTIONS.lod == "all" else int(OPTIONS.lod) +voxel_um, triangles, tile_count = PROFILE.read_scene( + str(MESH), requested_lod, entry[2] - 7.0, entry[2] + 5.0, + (entry[0] - 14.0, entry[0] + 14.0, entry[1] - 14.0, entry[1] + 14.0)) +buckets = PROFILE.build_buckets( + triangles, True, + (entry[0] - 14.0, entry[0] + 14.0, entry[1] - 14.0, entry[1] + 14.0), + cell_size=1.0) +down_buckets = PROFILE.build_buckets( + triangles, False, + (entry[0] - 14.0, entry[0] + 14.0, entry[1] - 14.0, entry[1] + 14.0), + cell_size=1.0) +mesh_points = [vertex[0] for triangle in triangles for vertex in triangle[2]] +print(f"[EntranceProfileGeometry] entry_m=({entry[0]:.3f},{entry[1]:.3f},{entry[2]:.3f}) " + f"point10_m=({route_at(10.0)[0]:.3f},{route_at(10.0)[1]:.3f}," + f"{route_at(10.0)[2]:.3f}) mesh_xy=({min(p[0] for p in mesh_points):.3f}," + f"{max(p[0] for p in mesh_points):.3f},{min(p[1] for p in mesh_points):.3f}," + f"{max(p[1] for p in mesh_points):.3f}) mesh_z=({min(p[2] for p in mesh_points):.3f}," + f"{max(p[2] for p in mesh_points):.3f})") +for sample_distance in range(11): + sx, sy, expected = route_at(float(sample_distance)) + bucket = buckets.get((math.floor(sx), math.floor(sy)), ()) + hit = PROFILE.sample_surface(buckets, sx, sy, True, 1.0, + preferred_height=expected) + down_hit = PROFILE.sample_surface(down_buckets, sx, sy, False, 1.0, + preferred_height=expected) + print(f"[EntranceProfileBucket] distance_m={sample_distance:.1f} " + f"xy_m=({sx:.3f},{sy:.3f}) triangles={len(bucket)} " + f"hit_z_m={None if hit is None else round(hit[0], 4)} " + f"down_hit_z_m={None if down_hit is None else round(down_hit[0], 4)} " + f"expected_z_m={expected:.4f}") + +rows = [] +for rail, lateral in (("left", -1.5), ("center", 0.0), ("right", 1.5)): + distance = -5.0 + while distance <= 10.000001: + x, y, expected = route_at(distance) + x += nx * lateral + y += ny * lateral + hit = PROFILE.sample_surface(buckets, x, y, True, 1.0, + preferred_height=expected) + rows.append({ + "rail": rail, + "distance_m": round(distance, 3), + "x_m": x, + "y_m": y, + "expected_floor_m": expected, + "surface_z_m": None if hit is None else hit[0], + "normal_z": None if hit is None else hit[1][2], + "residual_m": None if hit is None else hit[0] - expected, + }) + distance += 0.125 + +results = {} +for rail in ("left", "center", "right"): + profile = [row for row in rows if row["rail"] == rail and row["surface_z_m"] is not None + and 0.0 <= row["distance_m"] <= 10.0] + jumps = [] + for previous, current in zip(profile, profile[1:]): + expected_delta = current["expected_floor_m"] - previous["expected_floor_m"] + residual_delta = current["residual_m"] - previous["residual_m"] + if abs(residual_delta) >= 0.025: + jumps.append({"from_m": previous["distance_m"], "to_m": current["distance_m"], + "delta_m": current["surface_z_m"] - previous["surface_z_m"], + "residual_delta_m": residual_delta, + "expected_delta_m": expected_delta}) + residuals = [row["residual_m"] for row in profile] + ripple_residuals = [] + for row in profile: + nearby = [other["residual_m"] for other in profile + if abs(other["distance_m"] - row["distance_m"]) <= 1.0] + ripple_residuals.append((row["distance_m"], row["residual_m"] + - sum(nearby) / max(1, len(nearby)))) + amplitude, period = 0.0, 0.0 + for candidate in (0.375 + index * 0.125 for index in range(30)): + cosine = sum(value * math.cos(2.0 * math.pi * distance / candidate) + for distance, value in ripple_residuals) + sine = sum(value * math.sin(2.0 * math.pi * distance / candidate) + for distance, value in ripple_residuals) + current_amplitude = 2.0 * math.hypot(cosine, sine) / max(1, len(ripple_residuals)) + if current_amplitude > amplitude: + amplitude, period = current_amplitude, candidate + step_error = max((abs(item["residual_delta_m"]) for item in jumps), default=0.0) + coverage = len(profile) / 81.0 + rail_passed = (coverage >= 0.85 and amplitude <= 0.015 and step_error <= 0.05) + results[rail] = { + "coverage": coverage, + "max_adjacent_step_m": max((abs(item["delta_m"]) for item in jumps), default=0.0), + "max_step_residual_m": step_error, + "ripple_amplitude_m": amplitude, + "ripple_period_m": period, + "passed": rail_passed, + "jumps_over_0_04m": jumps, + "residual_peak_to_peak_m": max(residuals) - min(residuals) if residuals else None, + } + +payload = { + "layout": str(LAYOUT.relative_to(ROOT)), + "mesh": str(MESH.relative_to(ROOT)), + "voxel_um": voxel_um, + "selected_tiles": tile_count, + "sample_spacing_m": 0.125, + "distance_range_m": [-5.0, 10.0], + "lateral_offsets_m": {"left": -1.5, "center": 0.0, "right": 1.5}, + "rails": results, + "samples": rows, +} +all_passed = all(result["passed"] for result in results.values()) +expected_ok = (OPTIONS.expect == "measure" + or (OPTIONS.expect == "pass" and all_passed) + or (OPTIONS.expect == "fail" and not all_passed)) +OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +print(f"[EntranceProfileBefore] voxel_um={voxel_um} tiles={tile_count} " + f"lod={OPTIONS.lod} spacing_m=0.125 distance_m=-5.000..10.000 status=MEASURED") +for rail, summary in results.items(): + print(f"[EntranceFloorRailAudit] rail={rail} coverage={summary['coverage']:.3f} " + f"ripple_amplitude_m={summary['ripple_amplitude_m']:.4f} " + f"ripple_period_m={summary['ripple_period_m']:.3f} " + f"max_step_residual_m={summary['max_step_residual_m']:.4f} " + f"status={'PASS' if summary['passed'] else 'FAIL'}") + for jump in summary["jumps_over_0_04m"]: + print(f"[EntranceProfileJump] rail={rail} from_m={jump['from_m']:.3f} " + f"to_m={jump['to_m']:.3f} delta_m={jump['delta_m']:+.4f} " + f"expected_delta_m={jump['expected_delta_m']:+.4f} " + f"residual_delta_m={jump['residual_delta_m']:+.4f}") +failed_rails = sum(not result["passed"] for result in results.values()) +print(f"[EntranceFloorMeshAudit] samples_per_rail=81 distance_m=0.000..10.000 " + f"rails=3 failed_rails={failed_rails} observed={'PASS' if all_passed else 'FAIL'} " + f"expected={OPTIONS.expect.upper()} status={'PASS' if expected_ok else 'FAIL'}") +print(f"[EntranceProfileJSON] file={OUT} status={'PASS' if expected_ok else 'FAIL'}") +if not expected_ok: + raise SystemExit(1) diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/REPORT.md b/Standalone/Research/WorldAssemblerM3/Linux47/REPORT.md new file mode 100644 index 0000000..22905ae --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux47/REPORT.md @@ -0,0 +1,38 @@ +# Linux47 report + +## Entrance floor + +The L46 mesh has a repeating staircase through the first 7 m: 39.9–51.4 mm ripple and 81.8–82.9 mm largest residual step. The cause was the final hard floor cap, applied after the mouth and terrain stamps, using vertical `Z-floorZ` on a sloped Bezier floor. That field is not a signed distance to the ramp, so Marching Cubes interpolated a distorted gradient; stamp fill beside the cap made the ridges visible. Voxel height quantisation was not the main cause. + +The cap now uses the grade-normalised signed distance. The tunnel SDF is reapplied in a floor-adjacent band, with the wider correction fading over the first 10% of the entrance; the narrow floor correction continues along the ramp. This removes the ridges without changing the tunnel wall profile. Mesh tile Z bounds now include resolved cavern bounds too; without that, the exported ramp lost triangles past about 6.6 m. + +The mesh audit samples 0–10 m at 0.125 m on three rails (−1.5, 0, +1.5 m), requiring ≥85% coverage, ≤15 mm ripple and ≤50 mm adjacent residual step. L46 fails all rails: coverage 0.63–0.68, ripple 39.9–51.4 mm, step 81.8–82.9 mm. L47 passes all rails: 100% coverage, ripple 0.4–1.2 mm, no measured residual step. The Core audit also passes 243 samples with 0.3 mm maximum step and 0.0 mm reported ripple. Buried L43 and segmented-ramp L45 controls still fail as expected. + +## Verifier, hashes and cost + +The cost baseline now exports only Core, Source, Compat, and the build/helper scripts to `Saved/Linux/l47/baseline-e016f6a20fcfac785c9870b3d1133fd5df47a6d4`; an exit handler removes the export. It does not copy the repository to `/tmp`. `/tmp` was **28K before** and **28K after** the full tier; the saved top-level-entry comparison is identical and the baseline export was removed. + +Source stamp / source manifest: `68328b0c2a154e261eba94a067eb7d879f37ef987a54758275b4d14e3e9746d3`. Demo-off owner hashes match L46 exactly for both recipes, with W1=W12. Whole-view hashes (density/mesh/payload/topology): floating-islands `598e9daaa1744ccb / 3114e0f4f6dbf734 / c49c4e48bff74089 / cadd4edd10d4d8ef`; surface-world `7dc75e2c09302258 / 0cb34c40ceec4ce3 / 9d23e8c46b515cf1 / 2962c9464991eedd`. Demo-on determinism passed 30/30 cells. + +Five-pair median cost: floating-islands 16.390→16.435 s (+0.27%); surface-world 1.722→1.717 s (−0.29%). The baseline build took 40.2 s. + +## Tiers, captures and failed runs + +Fast **PASS** (195.9 s), medium **PASS** (567.8 s), full **PASS** (1606.5 s); zero failed checks. All used source stamp above. Final captures: [ground entrance](captures/ground-entrance.jpg), [looking in](captures/entrance-looking-in.jpg), [overview](captures/overview-entrance-and-chamber.jpg), [inside to dais](captures/inside-to-dais.jpg), and [low entrance floor](captures/entrance-floor-low.jpg). + +Exploration failures: the first entrance gate caught a 171 mm step; stamp-only re-carving restored about 40 mm ripple and a 100 mm step; full-width re-carving failed the wall-shape check at t=0.58. The final mouth-local fade passed the wall check at t=0.64. The first fast invocation also stopped because the executable had not yet been built; the rebuilt fast tier passed. L46/L43/L45 negative controls are intentional failures and passed their expected-fail checks. Work stayed within the 15-trial budget. + +## Orchestrator review (Claude) + +Accepted, with one new defect logged. The stamp matches the final Clang and GCC builds (`68328b0c…`); demo-off hashes +are identical to L46; `/tmp` is unchanged after the full tier (1.8 MB used). The entrance floor is smooth now, and +the cause, a vertical cap distance on a sloped floor, was measured before the fix. + +**New defect:** a row of **jagged sawtooth teeth along the wall foot,** where the floor-adjacent band correction meets +the bore wall. It shows in `entrance-looking-in.jpg` and along the edge in the overview. The three floor rails (−1.5, +0, +1.5 m) don't reach the 2.6 m wall foot. **This is the third time** a fix has moved an artifact to where the +audit isn't looking (L46 inside-only, L47 rails only). L48 adds a whole-surface artifact audit. + +R14: no likely MSVC or UHT blocker. The Unreal adapter does not resolve stamps or cavern graphs, so the L43–L47 demos +are standalone-only until the adapter supplies those records. The legacy chunk callers agree with the micrometre +layout only at the default 8 m width. diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/captures/entrance-floor-low.jpg b/Standalone/Research/WorldAssemblerM3/Linux47/captures/entrance-floor-low.jpg new file mode 100644 index 0000000..b0467bb Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux47/captures/entrance-floor-low.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/captures/entrance-looking-in.jpg b/Standalone/Research/WorldAssemblerM3/Linux47/captures/entrance-looking-in.jpg new file mode 100644 index 0000000..25b8183 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux47/captures/entrance-looking-in.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/captures/ground-entrance.jpg b/Standalone/Research/WorldAssemblerM3/Linux47/captures/ground-entrance.jpg new file mode 100644 index 0000000..98be83b Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux47/captures/ground-entrance.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/captures/inside-to-dais.jpg b/Standalone/Research/WorldAssemblerM3/Linux47/captures/inside-to-dais.jpg new file mode 100644 index 0000000..3ea8eb3 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux47/captures/inside-to-dais.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux47/captures/overview-entrance-and-chamber.jpg b/Standalone/Research/WorldAssemblerM3/Linux47/captures/overview-entrance-and-chamber.jpg new file mode 100644 index 0000000..b2569fa Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux47/captures/overview-entrance-and-chamber.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Research14/REPORT.md b/Standalone/Research/WorldAssemblerM3/Research14/REPORT.md new file mode 100644 index 0000000..4b9f4a0 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Research14/REPORT.md @@ -0,0 +1,31 @@ +# VoxelForge R14 — Windows build review + +**Verdict:** I found no likely MSVC or UHT compile blocker in the requested diff. This is a source review, not a build result. Runtime behavior has one important condition: the legacy chunk callers align with the new physical layout only at the default 8 m width. + +## Compile and UHT review + +The diff adds no designated initializers, `__int128`, GNU builtins, `[[likely]]`, or `std::visit` use. The `std::variant` code has `` available; Core uses C++20, and the `std::llround` calls have `` available. The new `FStrateSlot` metadata is plain non-`UPROPERTY` data. + +| File:line | Issue | Surety | One-line fix | +|---|---|---|---| +| [CoreNamespacePreamble.h:60](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Core/src/CoreNamespacePreamble.h:60), [VoxelForgeCore.Build.cs:41](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForgeCore/VoxelForgeCore.Build.cs:41) | Toolchain check: the Core build uses C++20 and MSVC’s `fp_contract` pragma. Core pins C++20 and precise FP; confirm the owner’s UBT-selected MSVC version and flags. | check | Use the UE-supported VS2022 toolset and confirm `/std:c++20` and `/fp:precise` in the build log. | + +Epic documents C++20 as UE’s default and minimum, and UBT’s module settings come from `.Build.cs` files ([C++ standard](https://dev.epicgames.com/documentation/unreal-engine/epic-cplusplus-coding-standard-for-unreal-engine), [module properties](https://dev.epicgames.com/documentation/unreal-engine/module-properties-in-unreal-engine)). Microsoft documents that `/fp:precise` disables contraction by default starting with Visual Studio 2022; earlier versions could contract, while the Core preamble explicitly requests `fp_contract(off)` ([`/fp`](https://learn.microsoft.com/en-us/cpp/build/reference/fp-specify-floating-point-behavior?view=msvc-170), [`fp_contract`](https://learn.microsoft.com/en-us/cpp/preprocessor/fp-contract?view=msvc-160)). + +## Behaviour risks + +- **Chunk coordinates versus micrometre bounds:** [VoxelStrateManager.cpp:1201](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:1201) resolves physical layout width in micrometres, but [GetStrateForChunk/GetStrateChunkZBounds](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:6298) and [IsGapChunk](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:6398) still treat `ChunkCoord.Z` as a layout-chunk index. Normal world chunk coordinates use `CHUNK_SIZE` voxels ([VoxelTypes.h:285](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelTypes.h:285)). Those units match at the default 8 m width with 25 cm voxels; the setting also allows 0.25–64 m. At other widths, generator selection, gap detection, and transition distances can disagree with the physical slot bounds. Enforce the matching width or convert these chunk APIs using the actual streaming-chunk width. + +- **Stamp and cavern graph records:** The Unreal adapter builds strata and passages, then finalizes its layout JSON ([VoxelForgeCoreAdapter.cpp:1078](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp:1078), [1260](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp:1260)); it does not call either stamp or graph resolver. The Core world is active in Unreal, but the adapter currently supplies it with no L43–L46 stamp or graph records. The current headless demo calls those resolvers directly ([StreamBenchmarkHeadless.cpp:933](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Source/StreamBenchmarkHeadless.cpp:933)). To get that geometry in Unreal, the adapter needs to build and serialize those records. + +- **Bezier FP contraction:** The new Newton solve is in [ShapeWorldInternal.h:6090](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Core/src/ShapeWorldInternal.h:6090). Core’s compile preamble disables contraction for MSVC, and `World::compile` runs a contraction self-check before compiling the shape. The solve itself has no graph input from the current Unreal adapter, so it should not execute in the current plugin path. Confirm the owner’s MSVC toolset; Microsoft documents version-dependent `/fp:precise` defaults as above. + +## First-build checklist + +1. Confirm the UE minor version and UBT-selected VS2022/MSVC toolset. +2. Build the Win64 Development Editor target through UBT so it runs UHT and compiles both plugin modules; UBT takes module settings from `.Build.cs`. +3. Check the first UHT or MSVC diagnostic, then confirm the Core compile flags include C++20 and precise FP. +4. Launch the editor with the default 8 m layout and confirm Core world initialization succeeds, including its FP self-check. +5. If using a non-default layout width, resolve the chunk-coordinate mismatch before relying on streamed generator and gap behavior. Do not expect the stamp/graph demo geometry in Unreal until the adapter supplies those records. + +I left the tree untouched. `git status` showed `Standalone/Research/WorldAssemblerM3/Linux18/exports/` as pre-existing untracked content. \ No newline at end of file diff --git a/Standalone/Source/StreamBenchmarkPipeline.h b/Standalone/Source/StreamBenchmarkPipeline.h index 366eb9b..7e55c0c 100644 --- a/Standalone/Source/StreamBenchmarkPipeline.h +++ b/Standalone/Source/StreamBenchmarkPipeline.h @@ -504,8 +504,12 @@ namespace StandaloneStreamBenchmark struct FLandmarkTunnelRippleResult { - std::size_t floorSamples=0,wallSamples=0; + std::size_t floorSamples=0,entranceFloorSamples=0,wallSamples=0; double floorPeakPeriodMetres=0.0,floorPeakAmplitudeMetres=0.0; + double entranceFloorMaxStepMetres=0.0; + double entranceFloorWorstDistanceMetres=0.0; + double entranceFloorPriorResidualMetres=0.0,entranceFloorCurrentResidualMetres=0.0; + std::size_t entranceFloorWorstTrack=0; double wallPeakPeriodMetres=0.0,wallPeakAmplitudeMetres=0.0; bool valid=false,passed=false; }; @@ -601,7 +605,11 @@ namespace StandaloneStreamBenchmark return closest; }; std::vector> floorResiduals; - for(double distance=0.75;distance<=length-0.75;distance+=0.125) + std::array>,3> floorTrackResiduals; + std::array,3> previousFloorResiduals; + std::array previousFloorDistances{}; + constexpr std::array FloorTrackOffsets={-1.5,0.0,1.5}; + for(double distance=0.0;distance<=length-0.75;distance+=0.125) { const auto point=atDistance(distance); const auto prior=atDistance(std::max(0.0,distance-0.1)); @@ -611,16 +619,40 @@ namespace StandaloneStreamBenchmark if(tangentLength<1.0e-6)continue; const double normalX=-ty/tangentLength,normalY=tx/tangentLength; double offsetSum=0.0;std::size_t offsetCount=0; - for(const double lateral:{-0.35,0.0,0.35}) + for(std::size_t track=0;trackresult.entranceFloorMaxStepMetres) + { + result.entranceFloorMaxStepMetres=step; + result.entranceFloorWorstDistanceMetres=distance; + result.entranceFloorPriorResidualMetres=*previousFloorResiduals[track]; + result.entranceFloorCurrentResidualMetres=residual; + result.entranceFloorWorstTrack=track; + } + } + } + previousFloorResiduals[track]=residual; + previousFloorDistances[track]=distance; } if(offsetCount) {floorResiduals.push_back({distance,offsetSum/offsetCount});} } - result.floorSamples=floorResiduals.size(); + result.floorSamples=floorTrackResiduals[0].size() + +floorTrackResiduals[1].size()+floorTrackResiduals[2].size(); const auto peakPeriod=[&](const std::vector>& samples, double minimumPeriod,double maximumPeriod, double& outPeriod) @@ -642,6 +674,17 @@ namespace StandaloneStreamBenchmark }; result.floorPeakAmplitudeMetres=peakPeriod(floorResiduals,0.375, std::min(8.0,length/2.0),result.floorPeakPeriodMetres); + for(const auto& track:floorTrackResiduals) + { + double trackPeriod=0.0; + const double trackAmplitude=peakPeriod(track,0.375, + std::min(8.0,length/2.0),trackPeriod); + if(trackAmplitude>result.floorPeakAmplitudeMetres) + { + result.floorPeakAmplitudeMetres=trackAmplitude; + result.floorPeakPeriodMetres=trackPeriod; + } + } std::vector> wallResiduals; const double expectedWallRadius=edge!=layout.cavernEdges.end() @@ -702,8 +745,10 @@ namespace StandaloneStreamBenchmark } result.wallPeakAmplitudeMetres=peakPeriod(wallRippleResiduals,0.25, std::min(1.0,length/2.0),result.wallPeakPeriodMetres); - result.valid=result.floorSamples>=16&&result.wallSamples>=16; + result.valid=result.floorSamples>=16&&result.entranceFloorSamples>=150 + &&result.wallSamples>=16; result.passed=result.valid&&result.floorPeakAmplitudeMetres<=0.015 + &&result.entranceFloorMaxStepMetres<=0.05 &&result.wallPeakAmplitudeMetres<=0.035; return result; } @@ -1259,6 +1304,16 @@ namespace StandaloneStreamBenchmark {std::fprintf(stderr,"[LandmarkReachabilityAudit] missing cavern room node\n");return 2;} const auto ripples=AuditLandmarkTunnelRipples(Shape,*ResolvedLayout, World.CoreVoxelSizeMicrons); + const bool entranceFloorPassed=ripples.entranceFloorSamples>=150 + &&ripples.entranceFloorMaxStepMetres<=0.05 + &&ripples.floorPeakAmplitudeMetres<=0.015; + std::printf("[CavernEntranceFloorAudit] layout=L46 samples=%zu distance_m=0.000..10.000 rails=3 max_step_m=%.4f step_limit_m=0.050 ripple_peak_m=%.4f ripple_limit_m=0.015 status=%s\n", + ripples.entranceFloorSamples,ripples.entranceFloorMaxStepMetres, + ripples.floorPeakAmplitudeMetres,entranceFloorPassed?"PASS":"FAIL"); + std::printf("[CavernEntranceFloorStepDetail] rail=%zu distance_m=%.3f residual_m=%.4f->%.4f\n", + ripples.entranceFloorWorstTrack,ripples.entranceFloorWorstDistanceMetres, + ripples.entranceFloorPriorResidualMetres, + ripples.entranceFloorCurrentResidualMetres); std::printf("[CavernTunnelRipple] layout=L46 floor_samples=%zu floor_peak_period_m=%.3f floor_peak_amplitude_m=%.4f floor_limit_m=0.015 wall_samples=%zu wall_peak_period_m=%.3f wall_peak_amplitude_m=%.4f wall_period_band_m=0.250..1.000 wall_detrend_window_m=2.000 wall_limit_m=0.035 status=%s\n", ripples.floorSamples,ripples.floorPeakPeriodMetres, ripples.floorPeakAmplitudeMetres,ripples.wallSamples, @@ -1318,7 +1373,8 @@ namespace StandaloneStreamBenchmark static_cast(buried.unsupportedSamples), static_cast(buried.gradeFailures), buried.passed?"PASS":"FAIL",buried.passed?"FAIL":"PASS"); - const bool passed=ripples.passed&&candidate.passed&&!buried.passed; + const bool passed=ripples.passed&&entranceFloorPassed + &&candidate.passed&&!buried.passed; std::printf("[LandmarkReachabilityAudit] positive=PASS control=%s status=%s\n", buried.passed?"FAILED":"EXPECTED_FAIL",passed?"PASS":"FAIL"); return passed?0:13; diff --git a/Standalone/Tools/verify/verify.py b/Standalone/Tools/verify/verify.py index 2920cf0..8c6739e 100644 --- a/Standalone/Tools/verify/verify.py +++ b/Standalone/Tools/verify/verify.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import atexit import hashlib import json import math @@ -22,6 +23,7 @@ import zlib ROOT = Path(__file__).resolve().parents[3] VERIFY_DIR = ROOT / "Standalone/Tools/verify" LINUX37 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux37" +LINUX47 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux47" OWNER = ROOT / "Standalone/data/owner_world.json" OUT_DEFAULT = ROOT / "Saved/Linux/l42" @@ -221,6 +223,16 @@ def run_landmark_reachability(runner: VerificationRunner, runner.require(f"{name}:continuous-tunnel-ripple", all_match(output, r"^\[CavernTunnelRipple\] layout=L46 .*floor_limit_m=0\.015 .*wall_limit_m=0\.035 status=PASS$"), "floor and wall ripple remain below L36/L35 limits") + entrance_floor = re.search( + r"^\[CavernEntranceFloorAudit\] layout=L46 samples=(\d+) " + r"distance_m=0\.000\.\.10\.000 rails=3 max_step_m=([0-9.]+) " + r"step_limit_m=0\.050 ripple_peak_m=([0-9.]+) " + r"ripple_limit_m=0\.015 status=PASS$", output, re.M) + runner.require(f"{name}:entrance-floor-ripple-and-steps", + bool(entrance_floor and int(entrance_floor.group(1)) >= 150 + and float(entrance_floor.group(2)) <= 0.05 + and float(entrance_floor.group(3)) <= 0.015), + "three floor rails cover the first 10 m from the entrance; step<=0.05 m and ripple<=0.015 m") runner.require(f"{name}:buried-l43-control", all_match(output, r"^\[LandmarkReachabilityControl\] layout=L43-buried .*" r"result=FAIL expected=FAIL status=PASS$"), @@ -356,13 +368,13 @@ def run_hash_pairs(runner: VerificationRunner, runner.require(f"{name}:hash-count", len(values) == 12, f"hashes={len(values)}/12") same = hashes[1] == hashes[12] - baseline = ROOT / "Saved/Linux/l37" / f"determinism-l25-clang-{recipe}-w12.log" + baseline = ROOT / "Saved/Linux/l46" / f"owner-hash-{recipe}-style{style}-w12.log" old_hashes = tuple(HASH_RE.findall(baseline.read_text(encoding="utf-8", errors="replace"))) - same_l37 = hashes[12] == old_hashes and len(old_hashes) == 12 + same_l46 = hashes[12] == old_hashes and len(old_hashes) == 12 runner.require(f"owner-hash-{recipe}-w1-w12", same, "W1 and W12 each produced identical 12-hash signatures") - runner.require(f"owner-hash-{recipe}-matches-l37", same_l37, - f"current owner hashes match {baseline.name}; hashes={len(old_hashes)}") + runner.require(f"owner-hash-{recipe}-matches-l46", same_l46, + f"demo-off owner hashes match L46 {baseline.name}; hashes={len(old_hashes)}") class _SplitMix64: @@ -695,15 +707,16 @@ def run_captures_and_mesh_audits(runner: VerificationRunner) -> None: script_inputs=[overview_script, VERIFY_DIR / "capture_quality.py", OUT / "owner-fi-ground-mouth.vfmesh"]) - landmark_script = ROOT / "Standalone/Research/WorldAssemblerM3/Linux46/CaptureLandmarkL46.py" + landmark_script = LINUX47 / "CaptureLandmarkL47.py" landmark_layout = OUT / "layouts" / "landmark-reachability-clang.json" - runner.run_stage("capture-landmark-l46", landmark_script, + runner.run_stage("capture-landmark-l47", landmark_script, ["--layout", str(landmark_layout), "--artifact-dir", str(OUT), - "--capture-dir", str(ROOT / "Standalone/Research/WorldAssemblerM3/Linux46/captures"), + "--capture-dir", str(LINUX47 / "captures"), "--data", str(OWNER)], binary=CLANG, data=OWNER, recipe="surface-world", style=1, layout_path=landmark_layout, env=env_for(capture=True), - script_inputs=[landmark_script, VERIFY_DIR / "capture_quality.py", + script_inputs=[landmark_script, LINUX47 / "MeasureEntranceL47.py", + VERIFY_DIR / "capture_quality.py", LINUX37 / "AuditMouthShapeL37.py", ROOT / "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py"]) @@ -777,16 +790,18 @@ def prepare_cost_baseline(runner: VerificationRunner) -> tuple[Path, str]: baseline_commit = resolved.stdout.strip() if not re.fullmatch(r"[0-9a-f]{40,64}", baseline_commit): raise VerificationFailure(f"git returned an invalid baseline commit: {baseline_commit!r}") - temp = Path(f"/tmp/voxelforge-cost-baseline-{baseline_commit[:12]}") + temp = OUT / f"baseline-{baseline_commit}" if temp.exists(): shutil.rmtree(temp) temp.mkdir(parents=True) + atexit.register(shutil.rmtree, temp, ignore_errors=True) tarball = temp / "baseline.tar" archive = bounded_run(["git", "archive", "-o", str(tarball), - baseline_commit, "Standalone"], timeout=120) + baseline_commit, "Standalone/Core", "Standalone/Source", + "Standalone/Compat"], timeout=120) if archive is None or archive.returncode != 0 or not tarball.is_file(): raise VerificationFailure( - f"could not export Standalone from cost baseline commit {baseline_commit}") + f"could not export build inputs from cost baseline commit {baseline_commit}") extract = bounded_run(["tar", "-xf", str(tarball), "-C", str(temp)], timeout=120) if extract is None or extract.returncode != 0: raise VerificationFailure("cost baseline source extraction failed") @@ -794,6 +809,7 @@ def prepare_cost_baseline(runner: VerificationRunner) -> tuple[Path, str]: base = temp / "Standalone" (base / "Tools/verify").mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / "Standalone/Build.ps1", base / "Build.ps1") shutil.copy2(ROOT / "Standalone/BuildLinux.sh", base / "BuildLinux.sh") shutil.copy2(VERIFY_DIR / "source_stamp.py", base / "Tools/verify/source_stamp.py") shutil.copy2(ROOT / "Standalone/Source/SourceStamp.h", base / "Source/SourceStamp.h")