Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """End-to-end integration tests for the Three.js Object Sculptor pipeline. | |
| Pure stdlib. Runs each CLI script as a subprocess and asserts the gate behavior | |
| described in SKILL.md / references. Also generates a tiny real PNG (struct+zlib) | |
| to exercise the image-consuming scripts without any third-party deps. | |
| Run: python3 forge/tests/test_pipeline.py (from skill root) | |
| or: python3 -m unittest discover -s forge/tests | |
| """ | |
| import json | |
| import struct | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import unittest | |
| import zlib | |
| from pathlib import Path | |
| SKILL = Path(__file__).resolve().parents[2] | |
| SCRIPTS = SKILL / "forge" | |
| def run(script, *args): | |
| return subprocess.run( | |
| [sys.executable, str(SCRIPTS / script), *map(str, args)], | |
| capture_output=True, text=True, | |
| ) | |
| def write_png(path, w=64, h=64): | |
| """Write a minimal valid RGB PNG with a simple gradient (no PIL).""" | |
| raw = bytearray() | |
| for y in range(h): | |
| raw.append(0) # filter type 0 per scanline | |
| for x in range(w): | |
| raw += bytes(((x * 4) % 256, (y * 4) % 256, ((x + y) * 2) % 256)) | |
| def chunk(tag, data): | |
| c = struct.pack(">I", len(data)) + tag + data | |
| return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) | |
| ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) | |
| png = (b"\x89PNG\r\n\x1a\n" | |
| + chunk(b"IHDR", ihdr) | |
| + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) | |
| + chunk(b"IEND", b"")) | |
| Path(path).write_bytes(png) | |
| class PipelineTest(unittest.TestCase): | |
| def setUp(self): | |
| self.dir = Path(tempfile.mkdtemp()) | |
| self.assessment = self.dir / "assessment.json" | |
| self.spec = self.dir / "object-sculpt-spec.json" | |
| self.ref = self.dir / "ref.png" | |
| self.render = self.dir / "render.png" | |
| write_png(self.ref) | |
| write_png(self.render) | |
| def test_probe_image(self): | |
| r = run("stage1_intake/probe_image.py", self.ref) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| self.assertIn("64", r.stdout) # reports dimensions | |
| def test_assessment_and_spec(self): | |
| r = run("stage2_spec/new_pre_spec_assessment.py", "Oak", "--complexity", "complex", | |
| "--out", self.assessment) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| self.assertTrue(self.assessment.exists()) | |
| self.assertIn("qualityContract", json.loads(self.assessment.read_text())) | |
| r = run("stage2_spec/new_sculpt_spec.py", "Oak", "--assessment", self.assessment, | |
| "--out", self.spec) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| spec = json.loads(self.spec.read_text()) | |
| self.assertEqual(spec["schemaVersion"], "2.1") | |
| self.assertEqual(spec["targetName"], "Oak") | |
| def test_normal_validate_passes_strict_fails_on_shallow(self): | |
| run("stage2_spec/new_pre_spec_assessment.py", "Oak", "--complexity", "complex", | |
| "--out", self.assessment) | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--assessment", self.assessment, | |
| "--out", self.spec) | |
| # normal validation of a structurally-sound starter succeeds | |
| self.assertEqual(run("stage2_spec/validate_sculpt_spec.py", self.spec).returncode, 0) | |
| # strict quality gate must BLOCK a shallow starter spec | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotEqual(strict.returncode, 0) | |
| self.assertIn("strict quality failure", strict.stdout + strict.stderr) | |
| def test_topology_rejects_missing_classification(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| del spec["componentTree"][0]["topologyClass"] | |
| del spec["componentTree"][0]["topologyRationale"] | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotEqual(strict.returncode, 0) | |
| self.assertIn("missing or invalid topologyClass", strict.stdout + strict.stderr) | |
| def test_topology_rejects_restated_rationale(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["topologyClass"] = "assembled-solid" | |
| spec["componentTree"][0]["topologyRationale"] = "Assembled Solid" | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotEqual(strict.returncode, 0) | |
| self.assertIn("restates the enum value", strict.stdout + strict.stderr) | |
| def test_topology_rejects_disallowed_continuous_sculpt_pairing(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["primitive"] = "box" | |
| spec["componentTree"][0]["topologyClass"] = "continuous-sculpt" | |
| spec["componentTree"][0]["topologyRationale"] = "Smooth organic bulge with no seams." | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotEqual(strict.returncode, 0) | |
| self.assertIn("disallowed primitive", strict.stdout + strict.stderr) | |
| def test_topology_rejects_disallowed_fiber_strand_pairing(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["primitive"] = "plane-card" | |
| spec["componentTree"][0]["topologyClass"] = "fiber-strand" | |
| spec["componentTree"][0]["topologyRationale"] = "Thin repeated strand form." | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotEqual(strict.returncode, 0) | |
| self.assertIn("disallowed primitive", strict.stdout + strict.stderr) | |
| def test_topology_accepts_all_six_classes_with_allowed_primitives(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| cases = [ | |
| ("continuous-sculpt", "lathe"), | |
| ("assembled-solid", "box"), | |
| ("conforming-shell", "plane-card"), | |
| ("surface-relief", "box"), | |
| ("fiber-strand", "tube"), | |
| ("material-only", "plane-card"), | |
| ] | |
| for topology_class, primitive in cases: | |
| with self.subTest(topology_class=topology_class): | |
| spec["componentTree"][0]["primitive"] = primitive | |
| spec["componentTree"][0]["topologyClass"] = topology_class | |
| spec["componentTree"][0]["topologyRationale"] = ( | |
| f"Test fixture citing observed evidence for {topology_class}." | |
| ) | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotIn( | |
| "topologyClass", strict.stdout + strict.stderr, | |
| f"unexpected topology failure for {topology_class}/{primitive}: {strict.stdout}{strict.stderr}", | |
| ) | |
| self.assertNotIn( | |
| "disallowed primitive", strict.stdout + strict.stderr, | |
| f"unexpected disallowed-pairing failure for {topology_class}/{primitive}", | |
| ) | |
| def test_color_material_recipe_accepts_full_opacity_alpha_format(self): | |
| # Regression: lab_to_rgba() in extract_part_color_recipe.py renders full opacity | |
| # as "1.0" (round(1.0, 3) -> the float 1.0, not the bare int 1). RGBA_PATTERN must | |
| # accept that exact format, or every real extracted recipe fails its own validator. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["colorMaterialRecipe"] = { | |
| "dominantAlbedo": "rgba(21, 87, 78, 1.0)", | |
| "secondaryAlbedo": "rgba(34, 67, 67, 1.0)", | |
| "materialClass": "glass", | |
| "materialClassConfidence": 0.6, | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotIn( | |
| "must be an 'rgba(r, g, b, a)' string", strict.stdout + strict.stderr, | |
| f"1.0-alpha rgba string was wrongly rejected: {strict.stdout}{strict.stderr}", | |
| ) | |
| def test_diagnose_render_tier1_help_and_identical_images(self): | |
| r = run("stage4_review/diagnose_render.py", "--help") | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| # Identical reference/render images should trivially pass every Tier-1 check. | |
| r2 = run("stage4_review/diagnose_render.py", "--reference", self.ref, "--render", self.ref, "--json") | |
| self.assertEqual(r2.returncode, 0, r2.stderr) | |
| result = json.loads(r2.stdout) | |
| self.assertTrue(result["passed"]) | |
| self.assertEqual(result["checks"]["silhouetteIoU"], 1.0) | |
| def test_orchestrator_refuses_current_pass_without_passing_tier1(self): | |
| # Plan 1.3 Workstream D: the current (unlocked) VISUAL pass must be blocked | |
| # until a passing tier1Result is recorded for it. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| blocked = run("stage3_build/orchestrate_passes.py", "check", self.spec, "--pass-id", "blockout") | |
| self.assertNotEqual(blocked.returncode, 0) | |
| self.assertIn("Tier 1 diagnostics have not passed", blocked.stdout + blocked.stderr) | |
| # Recording a PASSING tier1 result (identical ref/render) unblocks it. | |
| run("stage4_review/diagnose_render.py", "--reference", self.ref, "--render", self.ref, | |
| "--pass-id", "blockout", "--spec", self.spec, "--in-place") | |
| unblocked = run("stage3_build/orchestrate_passes.py", "check", self.spec, "--pass-id", "blockout") | |
| self.assertEqual(unblocked.returncode, 0, unblocked.stderr) | |
| def test_orchestrator_starts_at_blockout(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| r = run("stage3_build/orchestrate_passes.py", "status", self.spec) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| self.assertIn("blockout", r.stdout) | |
| # a future pass must be locked | |
| locked = run("stage3_build/orchestrate_passes.py", "check", self.spec, | |
| "--pass-id", "material-pass") | |
| self.assertNotEqual(locked.returncode, 0) | |
| def test_generate_factory_emits_typescript(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| out = self.dir / "createObjectModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("import * as THREE from 'three'", ts) | |
| self.assertIn("sculptRuntime", ts) | |
| # generating a locked future pass must fail | |
| locked = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out, | |
| "--pass-id", "lighting-pass") | |
| self.assertNotEqual(locked.returncode, 0) | |
| def test_generate_factory_emits_f3_f4_material_and_environment(self): | |
| # Plan 1.3 F.3/F.4: previously-missing MeshPhysicalMaterial properties and the | |
| # environment map must both appear in the generated code (codegen-output test, | |
| # not a rendering test — fully automatable per the plan's acceptance criteria). | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| out = self.dir / "createObjectModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| for prop in ("sheen:", "sheenColor:", "iridescence:", "iridescenceIOR:", | |
| "ior:", "attenuationDistance:", "anisotropy:", "anisotropyRotation:", | |
| "specularIntensity:", "specularColor:", "emissive:", "emissiveIntensity:"): | |
| self.assertIn(prop, ts, f"missing material property in generated code: {prop}") | |
| self.assertIn("import { RoomEnvironment }", ts) | |
| self.assertIn("PMREMGenerator", ts) | |
| self.assertIn("Environment(renderer: THREE.WebGLRenderer)", ts) | |
| def test_generate_factory_emits_auto_framing(self): | |
| # Plan 1.3 §3.2: auto-framing by bounding box is a prerequisite for the Divine | |
| # Eye — an object framed unlike the reference makes every silhouette comparison | |
| # meaningless. The generated code must expose a frame<Type>Camera helper that | |
| # positions the camera from the object's Box3 and updates the projection matrix. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| out = self.dir / "createObjectModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("Camera(", ts) | |
| self.assertIn("new THREE.Box3().setFromObject", ts) | |
| self.assertIn("camera.updateProjectionMatrix()", ts) | |
| def test_generate_factory_emits_presentation_composer_only(self): | |
| # Plan 1.3 §3.2c / R-POSTFX: DOF+bloom live in a SEPARATE presentation composer | |
| # (opt-in via options), never wired into the model factory itself — the Eye's | |
| # evaluation render must stay post-fx-free. Codegen-output test. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| out = self.dir / "createObjectModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("PresentationComposer", ts) | |
| self.assertIn("EffectComposer", ts) | |
| self.assertIn("BokehPass", ts) | |
| self.assertIn("UnrealBloomPass", ts) | |
| self.assertIn("R-POSTFX", ts) | |
| # the composer must be gated on options.dof / options.bloom (opt-in, not forced) | |
| self.assertIn("if (options.dof)", ts) | |
| self.assertIn("if (options.bloom)", ts) | |
| def test_generate_factory_builds_real_extrude_lathe_tube_geometry(self): | |
| # Plan 1.3 F.5: the original bug — primitive: "extrude" (e.g. a knife blade | |
| # tapering to a sharp point) used to validate fine but silently render as a | |
| # generic box. Confirms all three previously-unimplemented primitives now | |
| # produce real geometry-builder calls instead of raising or falling back. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["primitive"] = "extrude" | |
| spec["componentTree"][0]["topologyClass"] = "continuous-sculpt" | |
| spec["componentTree"][0]["topologyRationale"] = "Blade tapering to a single sharp point." | |
| spec["componentTree"][0]["geometryDescriptor"]["profile2D"] = { | |
| "points": [[-0.05, 0.0], [0.05, 0.0], [0.0, 0.6]], # converges to a real point | |
| "depth": 0.02, | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createOakModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("buildExtrudeGeometry", ts) | |
| self.assertIn("bevelEnabled: false", ts) | |
| self.assertIn("shape.lineTo", ts) | |
| self.assertNotIn("BoxGeometry(1, 1, 1, 8, 8, 8)", ts) # the old silent fallback | |
| for primitive, builder in (("lathe", "buildLatheGeometry"), ("tube", "buildTubeGeometry")): | |
| with self.subTest(primitive=primitive): | |
| spec["componentTree"][0]["primitive"] = primitive | |
| self.spec.write_text(json.dumps(spec)) | |
| r2 = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out, "--force") | |
| self.assertEqual(r2.returncode, 0, r2.stderr) | |
| self.assertIn(builder, out.read_text()) | |
| def test_generate_factory_omits_unused_geometry_helpers(self): | |
| # Regression: a real showcase integration build failed TypeScript's | |
| # noUnusedLocals because buildLatheGeometry/buildTubeGeometry were emitted | |
| # unconditionally even when no component in the pass used lathe/tube. Only | |
| # the primitives actually present should get a helper function. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["primitive"] = "extrude" | |
| spec["componentTree"][0]["topologyClass"] = "continuous-sculpt" | |
| spec["componentTree"][0]["topologyRationale"] = "Blade tapering to a single sharp point." | |
| spec["componentTree"][0]["geometryDescriptor"]["profile2D"] = { | |
| "points": [[-0.05, 0.0], [0.05, 0.0], [0.0, 0.6]], | |
| "depth": 0.02, | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createOakModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("buildExtrudeGeometry", ts) | |
| self.assertNotIn("function buildLatheGeometry", ts) | |
| self.assertNotIn("function buildTubeGeometry", ts) | |
| def test_instanced_cluster_component_now_implemented(self): | |
| # instanced-cluster used to fall through geometry_for() and fail loudly; it now | |
| # resolves to its base geometry (default box) so a component may declare it without | |
| # crashing. The instancing itself is applied by repetition systems (test below). | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["primitive"] = "instanced-cluster" | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createOakModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| self.assertIn("BoxGeometry", out.read_text()) | |
| def test_repetition_system_emits_instanced_mesh(self): | |
| # Repeated parts (teeth/fasteners/spokes) must render as ONE THREE.InstancedMesh | |
| # (single draw call), not a per-instance Mesh clone loop (real-time perf principle). | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| first_mat = (spec.get("materials") or [{}])[0].get("id", "base") | |
| spec["repetitionSystems"] = [{ | |
| "id": "teeth", "level": "macro", "parent": "root", "count": 8, | |
| "primitive": "box", "material": first_mat, | |
| "instanceScale": [0.05, 0.05, 0.05], | |
| "placement": {"mode": "radial", "axis": [0, 0, 1], "radius": 0.5, "startAngleDeg": 0}, | |
| }] | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createOakModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("new THREE.InstancedMesh(geo, mat, 8)", ts) | |
| self.assertIn("setMatrixAt", ts) | |
| self.assertNotIn("new THREE.Mesh(geo, mat)", ts) # no leftover clone loop | |
| def test_geometry_for_raises_for_unimplemented_primitive(self): | |
| # The GeometryNotImplementedError guard still protects any FUTURE primitive added to | |
| # VALID_PRIMITIVES without a geometry_for() branch (never a silent box fallback). | |
| import importlib.util | |
| gen_path = SCRIPTS / "stage3_build" / "generate_threejs_factory.py" | |
| sys.path.insert(0, str(SCRIPTS / "stage3_build")) | |
| mod_spec = importlib.util.spec_from_file_location("gen_factory_test", gen_path) | |
| gen = importlib.util.module_from_spec(mod_spec) | |
| mod_spec.loader.exec_module(gen) | |
| with self.assertRaises(gen.GeometryNotImplementedError): | |
| gen.geometry_for("some-future-unimplemented-primitive") | |
| def test_generate_factory_builds_curve_sweep(self): | |
| # F.6: curve-sweep sweeps a thin cross-section along a 3D spine so a curved form | |
| # reads correctly from every angle (the karambit-blade fix). Must emit a real | |
| # extrudePath + CatmullRomCurve3, only when a component uses curve-sweep. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["componentTree"][0]["primitive"] = "curve-sweep" | |
| spec["componentTree"][0]["topologyClass"] = "continuous-sculpt" | |
| spec["componentTree"][0]["topologyRationale"] = "Hooked blade curving through space." | |
| spec["componentTree"][0]["geometryDescriptor"]["curveSweep"] = { | |
| "spine": [[-0.5, -0.4, 0.0], [0.0, 0.1, 0.0], [0.5, -0.2, 0.0]], | |
| "crossSection": {"points": [[-0.04, -0.02], [0.04, -0.02], [0.04, 0.02], [-0.04, 0.02]]}, | |
| "closed": False, | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createOakModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("buildCurveSweepGeometry", ts) | |
| self.assertIn("extrudePath", ts) | |
| self.assertIn("CatmullRomCurve3", ts) | |
| self.assertIn("bevelEnabled: false", ts) | |
| # conditional emission: a box-only spec must NOT carry the curve-sweep helper | |
| spec["componentTree"][0]["primitive"] = "box" | |
| self.spec.write_text(json.dumps(spec)) | |
| r2 = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out, "--force") | |
| self.assertEqual(r2.returncode, 0, r2.stderr) | |
| self.assertNotIn("function buildCurveSweepGeometry", out.read_text()) | |
| def test_ground_blade_uv_uses_actual_y_bounds(self): | |
| # ground-blade UVs must map v across the blade's ACTUAL Y bounds, not a hardcoded | |
| # ±0.12. An off-origin blade (y~0.4) with the old formula clamped v→1 so every face sampled | |
| # the bright spine-rim row → white/washed tip facets. Corrected UV = (y - yMin) / yH. | |
| run("stage2_spec/new_sculpt_spec.py", "Blade", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| c = spec["componentTree"][0] | |
| c["primitive"] = "ground-blade" | |
| c["topologyClass"] = "continuous-sculpt" | |
| c["topologyRationale"] = "Blade lofted along beveled stations to a sharp point." | |
| c["geometryDescriptor"]["bladeSpec"] = { | |
| "stations": [[0, 0.55, 0.30], [0.5, 0.56, 0.31], [1.0, 0.50, 0.40]], | |
| "thickness": 0.05, | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createBladeModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("yMin = Math.min(yMin, s[2])", ts) # bounds from actual stations | |
| self.assertIn("(pos[t + 1] - yMin) / yH", ts) # v spans real height | |
| self.assertNotIn("+ 0.12) / 0.24", ts) # old hardcoded formula gone | |
| def test_extrude_supports_oval_hole_via_shape_holes(self): | |
| # a cutout (wire-cutter oval hole) is done via THREE.Shape.holes — dep-free, no | |
| # three-bvh-csg. The generator must emit hole-handling + oval-loop support on extrude. | |
| run("stage2_spec/new_sculpt_spec.py", "Blade", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| c = spec["componentTree"][0] | |
| c["primitive"] = "extrude" | |
| c["topologyClass"] = "continuous-sculpt" | |
| c["topologyRationale"] = "Flat blade plate with an oval wire-cutter cutout." | |
| c["geometryDescriptor"]["profile2D"] = { | |
| "points": [[-0.05, 0.0], [0.05, 0.0], [0.0, 0.6]], | |
| "depth": 0.02, | |
| "ovalHoles": [{"cx": 0.0, "cy": 0.3, "rx": 0.02, "ry": 0.035}], | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createBladeModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("shape.holes.push(path)", ts) # cutout via Shape.holes | |
| self.assertIn("ovalLoop", ts) # oval-hole authoring helper | |
| self.assertNotIn("three-bvh-csg", ts) # no CSG dependency | |
| def test_flatness_pre_check_flags_thin_continuous_sculpt_extrude(self): | |
| # G.1: a continuous-sculpt form (asserted to be a volumetric 3D shape) built as a | |
| # THIN straight extrude (flat slab) must be flagged before render; the same form | |
| # with real thickness must not. | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| c = spec["componentTree"][0] | |
| c["primitive"] = "extrude" | |
| c["topologyClass"] = "continuous-sculpt" | |
| c["topologyRationale"] = "Hooked blade silhouette, single continuous form." | |
| # thin slab (depth ≪ diagonal) under a continuous-sculpt claim → HIGH flatness risk | |
| c["geometryDescriptor"]["profile2D"] = { | |
| "points": [[0.0, 0.0], [0.5, 0.6], [1.0, 0.0], [0.5, -0.05]], | |
| "depth": 0.02, | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotEqual(strict.returncode, 0) | |
| self.assertIn("flatness risk", strict.stdout + strict.stderr) | |
| # same profile with real depth (not a thin slab) must NOT trip the flatness gate | |
| c["geometryDescriptor"]["profile2D"]["depth"] = 0.5 | |
| self.spec.write_text(json.dumps(spec)) | |
| strict2 = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotIn("flatness risk", strict2.stdout + strict2.stderr) | |
| def test_generate_factory_emits_color_gradient_codegen(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| spec["materials"][0]["colorGradient"] = { | |
| "type": "linear", | |
| "axis": [1.0, 0.0], | |
| "stops": [ | |
| {"offset": 0.0, "color": "rgba(60, 45, 30, 1.0)"}, | |
| {"offset": 1.0, "color": "rgba(200, 155, 120, 1.0)"}, | |
| ], | |
| } | |
| self.spec.write_text(json.dumps(spec)) | |
| out = self.dir / "createObjectModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("sampleColorGradient", ts) | |
| self.assertIn("colorGradient", ts) | |
| def test_comparison_sheet_packages_without_scoring(self): | |
| cmp = self.dir / "cmp.png" | |
| r = run("stage4_review/make_comparison_sheet.py", "--reference", self.ref, | |
| "--render", self.render, "--out", cmp, "--json") | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| self.assertTrue(cmp.exists() and cmp.stat().st_size > 0) | |
| def test_append_review_gate_and_record(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| # GATE: continue on a visual pass WITHOUT screenshot evidence must be refused. | |
| no_evidence = run("stage4_review/append_review.py", self.spec, "--pass-id", "blockout", | |
| "--fidelity", "0.8", "--action", "continue", | |
| "--summary", "no evidence", "--ai-vision-score", "0.8", | |
| "--in-place") | |
| self.assertNotEqual(no_evidence.returncode, 0) | |
| self.assertIn("render-screenshot", no_evidence.stdout + no_evidence.stderr) | |
| # WITH evidence: the review is recorded. | |
| cmp = self.dir / "cmp.png" | |
| run("stage4_review/make_comparison_sheet.py", "--reference", self.ref, | |
| "--render", self.render, "--out", cmp) | |
| layers = json.dumps({ | |
| "silhouetteProportion": 0.82, "componentStructure": 0.78, | |
| "formDetail": 0.75, "materialSurface": 0.7, "lightingCamera": 0.8, | |
| }) | |
| # every critical feature target of this pass needs an AI-vision review entry | |
| spec = json.loads(self.spec.read_text()) | |
| targets = spec.get("selfCorrectLoop", {}).get("featureReviewTargets", []) | |
| reviews = [ | |
| {"id": t.get("id"), "score": 0.8, "visible": True, "notes": "acceptable"} | |
| for t in targets if t.get("tier") == "critical" | |
| ] or [{"id": "overall-silhouette", "score": 0.8, "visible": True, "notes": "ok"}] | |
| freviews = self.dir / "features.json" | |
| freviews.write_text(json.dumps(reviews)) | |
| r = run("stage4_review/append_review.py", self.spec, "--pass-id", "blockout", | |
| "--fidelity", "0.8", "--action", "continue", | |
| "--summary", "Blockout silhouette acceptable.", | |
| "--render-screenshot", self.render, "--comparison-image", cmp, | |
| "--ai-vision-score", "0.8", "--layer-scores-json", layers, | |
| "--feature-reviews-json", freviews, | |
| "--camera-view", "front", "--in-place") | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| spec = json.loads(self.spec.read_text()) | |
| self.assertTrue(len(spec.get("reviewHistory", [])) >= 1) | |
| def test_pbr_extraction_runs(self): | |
| # low-detail synthetic image: either passes or refuses (non-zero) — both are valid, | |
| # but it must not crash and must respect the confidence gate. | |
| r = run("stage1_intake/extract_pbr_evidence.py", self.ref, "--out-dir", self.dir / "pbr", | |
| "--material-id", "bark", "--target-threshold", "0.7", | |
| "--report", self.dir / "pbr-report.json") | |
| self.assertIn(r.returncode, (0, 1), r.stderr) | |
| self.assertTrue((self.dir / "pbr-report.json").exists() or r.returncode == 1) | |
| # ---- Track A / Track B upgrade coverage ---- | |
| def _fresh_spec(self, complexity="moderate"): | |
| run("stage2_spec/new_pre_spec_assessment.py", "Widget", "--complexity", complexity, | |
| "--out", self.assessment) | |
| run("stage2_spec/new_sculpt_spec.py", "Widget", "--assessment", self.assessment, | |
| "--out", self.spec) | |
| return json.loads(self.spec.read_text()) | |
| def test_new_schema_fields_present(self): | |
| spec = self._fresh_spec("complex") | |
| pre = spec["preSpecAssessment"] | |
| self.assertIn("detailInventory", pre) | |
| self.assertIn("anatomy", pre) | |
| self.assertIn("primaryDomain", pre["objectClass"]) | |
| self.assertIn("referenceCamera", spec) | |
| # targetMinDetails scales with complexity | |
| self.assertEqual(pre["detailInventory"]["targetMinDetails"], 10) | |
| def test_detail_inventory_gate_fires_on_empty(self): | |
| self._fresh_spec("moderate") | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotEqual(strict.returncode, 0) | |
| self.assertIn("detailInventory has 0 details", strict.stdout + strict.stderr) | |
| def test_detail_inventory_backward_compatible(self): | |
| # A spec with NO detailInventory (pre-upgrade shape) must not trigger the detail gate. | |
| spec = self._fresh_spec("moderate") | |
| spec["preSpecAssessment"].pop("detailInventory", None) | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotIn("detailInventory", strict.stdout + strict.stderr) | |
| def test_character_gate_requires_anatomy(self): | |
| spec = self._fresh_spec("moderate") | |
| spec["preSpecAssessment"]["objectClass"]["primaryDomain"] = "character" | |
| self.spec.write_text(json.dumps(spec)) | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertIn("anatomy.applies is not true", strict.stdout + strict.stderr) | |
| def test_character_track_skipped_for_objects(self): | |
| # primaryDomain unassessed/object must not trigger character warnings. | |
| self._fresh_spec("moderate") | |
| strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality") | |
| self.assertNotIn("anatomy.applies", strict.stdout + strict.stderr) | |
| def test_new_upgrade_scripts_help(self): | |
| for script in ("stage1_intake/build_detail_inventory.py", "stage1_intake/extract_landmarks.py", | |
| "stage1_intake/solve_camera_pose.py", "stage1_intake/delight_albedo.py", | |
| "stage3_build/bake_projected_texture.py", "stage1_intake/extract_part_color_recipe.py"): | |
| r = run(script, "--help") | |
| self.assertEqual(r.returncode, 0, f"{script}: {r.stderr}") | |
| def test_extract_part_color_recipe_cache_hit_and_invalidation(self): | |
| # Plan 1.3 Workstream E: same crop + unchanged script -> cache hit on 2nd run. | |
| r1 = run("stage1_intake/extract_part_color_recipe.py", self.ref, | |
| "--component-id", "root") | |
| self.assertEqual(r1.returncode, 0, r1.stderr) | |
| self.assertFalse(json.loads(r1.stdout)["cacheHit"]) | |
| r2 = run("stage1_intake/extract_part_color_recipe.py", self.ref, | |
| "--component-id", "root") | |
| self.assertEqual(r2.returncode, 0, r2.stderr) | |
| self.assertTrue(json.loads(r2.stdout)["cacheHit"]) | |
| cache_file = self.ref.parent / ".cache" / "color_recipe_cache.json" | |
| self.assertTrue(cache_file.exists()) | |
| # --no-cache bypasses the cache entirely, even with an existing hit available. | |
| r3 = run("stage1_intake/extract_part_color_recipe.py", self.ref, | |
| "--component-id", "root", "--no-cache") | |
| self.assertEqual(r3.returncode, 0, r3.stderr) | |
| self.assertFalse(json.loads(r3.stdout)["cacheHit"]) | |
| def test_extract_part_color_recipe_patches_spec(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec) | |
| r = run("stage1_intake/extract_part_color_recipe.py", self.ref, | |
| "--component-id", "root", "--spec", self.spec, "--in-place", | |
| "--allow-low-confidence") | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| recipe = json.loads(r.stdout) | |
| self.assertIn("dominantAlbedo", recipe) | |
| self.assertTrue(recipe["dominantAlbedo"].startswith("rgba(")) | |
| self.assertIn("materialClass", recipe) | |
| spec = json.loads(self.spec.read_text()) | |
| self.assertIn("colorMaterialRecipe", spec["componentTree"][0]) | |
| self.assertEqual(spec["componentTree"][0]["colorMaterialRecipe"]["dominantAlbedo"], recipe["dominantAlbedo"]) | |
| def test_build_detail_inventory_slices_zones(self): | |
| out = self.dir / "di.json" | |
| zones = self.dir / "zones" | |
| r = run("stage1_intake/build_detail_inventory.py", self.ref, "--mode", "grid-3x3", | |
| "--out-dir", zones, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| self.assertTrue(out.exists()) | |
| crops = list(zones.glob("*.png")) if zones.exists() else [] | |
| self.assertGreaterEqual(len(crops), 1) | |
| def test_delight_reference_writes_png(self): | |
| out = self.dir / "albedo.png" | |
| r = run("stage1_intake/delight_albedo.py", self.ref, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| self.assertTrue(out.exists() and out.stat().st_size > 0) | |
| # ---- v1.2 character generator ---- | |
| def test_character_flag_builds_humanoid_tree(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Person", "--character", "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| ids = {c["id"] for c in spec["componentTree"]} | |
| for part in ("root", "head", "torso", "neck", "hair", "glasses-frame-l", "arm-l"): | |
| self.assertIn(part, ids) | |
| # all parts flattened to root (no cascading non-uniform parent scale) | |
| for c in spec["componentTree"]: | |
| if c["id"] != "root": | |
| self.assertEqual(c["parent"], "root") | |
| # distinct per-part colors (skin vs hair vs shirt), not a single fallback | |
| colors = {m["id"]: m.get("color") for m in spec["materials"] if m["id"] in ("skin", "hair", "shirt")} | |
| self.assertEqual(len({colors["skin"], colors["hair"], colors["shirt"]}), 3) | |
| # palette has >= 2 entries so the generator does not fall back to beige | |
| for m in spec["materials"]: | |
| if m["id"] in ("skin", "hair", "shirt"): | |
| self.assertGreaterEqual(len(m.get("colorVariation", {}).get("palette", [])), 2) | |
| def test_character_autodetect_from_domain(self): | |
| run("stage2_spec/new_pre_spec_assessment.py", "Person", "--complexity", "complex", "--out", self.assessment) | |
| a = json.loads(self.assessment.read_text()) | |
| a["preSpecAssessment"]["objectClass"]["primaryDomain"] = "character" | |
| self.assessment.write_text(json.dumps(a)) | |
| run("stage2_spec/new_sculpt_spec.py", "Person", "--assessment", self.assessment, "--out", self.spec) | |
| spec = json.loads(self.spec.read_text()) | |
| self.assertIn("head", {c["id"] for c in spec["componentTree"]}) | |
| def test_character_factory_generates(self): | |
| run("stage2_spec/new_sculpt_spec.py", "Person", "--character", "--out", self.spec) | |
| out = self.dir / "createCharacterModel.ts" | |
| r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out) | |
| self.assertEqual(r.returncode, 0, r.stderr) | |
| ts = out.read_text() | |
| self.assertIn("createPersonModel", ts) | |
| self.assertIn('meshes["head"]', ts) | |
| if __name__ == "__main__": | |
| unittest.main(verbosity=2) | |