From d3c52c4b2498cca1383ef256328595feb3f25afc Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Mon, 6 Jul 2026 02:41:19 -0700 Subject: [PATCH] fix(mesh): select vs-in position inputs --- docs-astro/src/data/commands.json | 2 +- .../_skills/references/commands-quick-ref.md | 4 + src/rdc/commands/mesh.py | 28 +- src/rdc/handlers/buffer.py | 168 +++++++- tests/unit/test_buffer_decode.py | 370 +++++++++++++++++- tests/unit/test_mesh_commands.py | 59 +++ 6 files changed, 611 insertions(+), 20 deletions(-) diff --git a/docs-astro/src/data/commands.json b/docs-astro/src/data/commands.json index c615bb5..6eac9b0 100644 --- a/docs-astro/src/data/commands.json +++ b/docs-astro/src/data/commands.json @@ -223,7 +223,7 @@ "name": "mesh", "id": "mesh", "help": "Export post-transform mesh as OBJ.", - "usage": "rdc mesh [EID] [--stage CHOICE] [-o PATH] [--json] [--no-header]" + "usage": "rdc mesh [EID] [--stage CHOICE] [-o PATH] [--json] [--no-header] [--position-attribute TEXT] [--position-index INTEGER] [--position-slot INTEGER] [--position-offset INTEGER]" }, { "name": "snapshot", diff --git a/src/rdc/_skills/references/commands-quick-ref.md b/src/rdc/_skills/references/commands-quick-ref.md index 9298e85..89d46ed 100644 --- a/src/rdc/_skills/references/commands-quick-ref.md +++ b/src/rdc/_skills/references/commands-quick-ref.md @@ -642,6 +642,10 @@ Export post-transform mesh as OBJ. | `-o, --output` | Write to file | path | | | `--json` | JSON output | flag | | | `--no-header` | Suppress OBJ header comment | flag | | +| `--position-attribute` | VS-IN position input name/semantic | text | | +| `--position-index` | VS-IN position input list index | integer | | +| `--position-slot` | VS-IN position vertex buffer slot | integer | | +| `--position-offset` | VS-IN position byte offset | integer | | ## `rdc open` diff --git a/src/rdc/commands/mesh.py b/src/rdc/commands/mesh.py index 2e4f64f..a57b635 100644 --- a/src/rdc/commands/mesh.py +++ b/src/rdc/commands/mesh.py @@ -22,17 +22,33 @@ @click.option("-o", "--output", type=click.Path(), default=None, help="Write to file") @click.option("--json", "use_json", is_flag=True, help="JSON output") @click.option("--no-header", is_flag=True, help="Suppress OBJ header comment") +@click.option("--position-attribute", default=None, help="VS-IN position input name/semantic") +@click.option("--position-index", type=int, default=None, help="VS-IN position input list index") +@click.option("--position-slot", type=int, default=None, help="VS-IN position vertex buffer slot") +@click.option("--position-offset", type=int, default=None, help="VS-IN position byte offset") def mesh_cmd( eid: int | None, stage: str, output: str | None, use_json: bool, no_header: bool, + position_attribute: str | None, + position_index: int | None, + position_slot: int | None, + position_offset: int | None, ) -> None: """Export post-transform mesh as OBJ.""" params: dict[str, Any] = {"stage": stage} if eid is not None: params["eid"] = eid + if position_attribute is not None: + params["position_attribute"] = position_attribute + if position_index is not None: + params["position_index"] = position_index + if position_slot is not None: + params["position_slot"] = position_slot + if position_offset is not None: + params["position_offset"] = position_offset result = call("mesh_data", params) @@ -47,6 +63,8 @@ def mesh_cmd( positions = _extract_positions(result["vertices"]) faces = _generate_faces(result["vertex_count"], result["indices"], result["topology"]) _warn_if_no_faces(result["topology"], len(positions), faces) + if result.get("position_warning"): + click.echo(f"mesh: warning: {result['position_warning']}", err=True) obj_text = _format_obj( positions, faces, @@ -54,6 +72,7 @@ def mesh_cmd( stage=result["stage"], topology=result["topology"], no_header=no_header, + position_metadata=result, ) if output: @@ -116,15 +135,22 @@ def _format_obj( stage: str, topology: str, no_header: bool = False, + position_metadata: dict[str, Any] | None = None, ) -> str: """Format vertex positions and faces as OBJ text.""" lines: list[str] = [] if not no_header: - lines.append( + header = ( f"# rdc mesh export: eid={eid} stage={stage} " f"vertices={len(positions)} faces={len(faces)} " f"topology={topology}" ) + if position_metadata and position_metadata.get("position_attribute"): + header += ( + f" position={position_metadata['position_attribute']}" + f" position_source={position_metadata.get('position_source', 'unknown')}" + ) + lines.append(header) for x, y, z in positions: lines.append(f"v {x:.6f} {y:.6f} {z:.6f}") for face in faces: diff --git a/src/rdc/handlers/buffer.py b/src/rdc/handlers/buffer.py index 5044ce7..594b32c 100644 --- a/src/rdc/handlers/buffer.py +++ b/src/rdc/handlers/buffer.py @@ -478,34 +478,166 @@ def _decode_position_rows( return vertices -def _position_input(inputs: list[Any]) -> Any | None: - for vi in inputs: - name = str(getattr(vi, "name", "") or getattr(vi, "semanticName", "")).lower() +def _input_display_name(vi: Any) -> str: + return str(getattr(vi, "name", "") or getattr(vi, "semanticName", "") or "") + + +def _input_names(vi: Any) -> list[str]: + return [ + str(name) + for name in (getattr(vi, "name", ""), getattr(vi, "semanticName", "")) + if str(name) + ] + + +def _is_position_semantic(vi: Any) -> bool: + for raw_name in _input_names(vi): + name = raw_name.lower() if "position" in name or name.startswith("pos"): - return vi - return None + return True + return False + + +def _is_instance_input(vi: Any) -> bool: + return bool( + getattr(vi, "perInstance", False) + or int(getattr(vi, "instanceRate", 0) or 0) > 0 + or getattr(vi, "instanced", False) + or int(getattr(vi, "instStepRate", 0) or 0) > 0 + ) -def _mesh_data_from_ia(controller: Any, pipe_state: Any, action: Any | None) -> dict[str, Any]: +def _format_name(fmt: Any) -> str: + if fmt is None: + return "" + name_fn = getattr(fmt, "Name", None) + if callable(name_fn): + return str(name_fn()) + return str(getattr(fmt, "name", "")) + + +def _is_float_vector_input(vi: Any) -> bool: + fmt = getattr(vi, "format", None) + if fmt is None: + return False + comp_width = int(getattr(fmt, "compByteWidth", 0) or 0) + comp_count = int(getattr(fmt, "compCount", 0) or 0) + fmt_name = _format_name(fmt).lower() + return comp_width in (2, 4) and 2 <= comp_count <= 4 and "float" in fmt_name + + +def _validate_position_input(vi: Any) -> None: + if not _is_float_vector_input(vi): + name = _input_display_name(vi) + fmt_name = _format_name(getattr(vi, "format", None)) or "" + raise ValueError(f"vertex input {name!r} format {fmt_name!r} cannot be decoded as position") + + +def _select_position_input( + inputs: list[Any], params: dict[str, Any] +) -> tuple[Any, str, int, str | None]: + attr_override = params.get("position_attribute") + index_override = params.get("position_index") + slot_override = params.get("position_slot") + offset_override = params.get("position_offset") + has_slot_override = slot_override is not None or offset_override is not None + if has_slot_override and (slot_override is None or offset_override is None): + raise ValueError("position slot override requires both position_slot and position_offset") + + override_count = sum( + 1 + for active in (attr_override is not None, index_override is not None, has_slot_override) + if active + ) + if override_count > 1: + raise ValueError("use only one position override selector") + + if attr_override is not None: + wanted = str(attr_override).lower() + for index, vi in enumerate(inputs): + if any(name.lower() == wanted for name in _input_names(vi)): + _validate_position_input(vi) + return vi, "user", index, None + raise ValueError(f"no vertex input matching position attribute {attr_override!r}") + + if index_override is not None: + index = int(index_override) + if 0 <= index < len(inputs): + vi = inputs[index] + _validate_position_input(vi) + return vi, "user", index, None + raise ValueError(f"no vertex input at position index {index}") + + if has_slot_override: + assert slot_override is not None + assert offset_override is not None + slot = int(slot_override) + offset = int(offset_override) + for index, vi in enumerate(inputs): + if ( + int(getattr(vi, "vertexBuffer", 0) or 0) == slot + and int(getattr(vi, "byteOffset", 0) or 0) == offset + ): + _validate_position_input(vi) + return vi, "user", index, None + raise ValueError(f"no vertex input at position slot {slot} offset {offset}") + + for index, vi in enumerate(inputs): + if _is_position_semantic(vi) and not _is_instance_input(vi): + _validate_position_input(vi) + return vi, "semantic", index, None + + candidates = [ + (index, vi) + for index, vi in enumerate(inputs) + if not _is_instance_input(vi) and _is_float_vector_input(vi) + ] + if not candidates: + raise ValueError("no vertex-rate float/vector position candidate at this event") + + def sort_key(item: tuple[int, Any]) -> tuple[int, int, int, int, int]: + index, vi = item + fmt = getattr(vi, "format", None) + comp_count = int(getattr(fmt, "compCount", 0) or 0) + comp_rank = {3: 0, 4: 1, 2: 2}.get(comp_count, 3) + slot = int(getattr(vi, "vertexBuffer", 0) or 0) + offset = int(getattr(vi, "byteOffset", 0) or 0) + return (comp_rank, 0 if slot == 0 else 1, slot, offset, index) + + index, vi = min(candidates, key=sort_key) + warning = None + if len(candidates) > 1: + warning = ( + f"heuristic position selection chose {_input_display_name(vi)!r} " + f"from {len(candidates)} candidates; use a position override if this is wrong" + ) + return vi, "heuristic", index, warning + + +def _mesh_data_from_ia( + controller: Any, pipe_state: Any, action: Any | None, params: dict[str, Any] +) -> dict[str, Any]: if action is None: raise ValueError("no draw action found for eid") inputs = pipe_state.GetVertexInputs() - pos_input = _position_input(inputs) - if pos_input is None: - raise ValueError("no vertex input POSITION at this event") + pos_input, position_source, position_index, position_warning = _select_position_input( + inputs, params + ) vbuffers = pipe_state.GetVBuffers() slot = getattr(pos_input, "vertexBuffer", 0) + input_name = _input_display_name(pos_input) if slot >= len(vbuffers): - raise ValueError(f"vertex buffer slot {slot} is not bound") + raise ValueError(f"vertex buffer for {input_name!r} (slot {slot}) is not bound") vb = vbuffers[slot] vrid = getattr(vb, "resourceId", None) stride = getattr(vb, "byteStride", 0) if vrid is None or int(vrid) == 0 or stride == 0: - raise ValueError("POSITION vertex buffer is not bound") + raise ValueError(f"vertex buffer for {input_name!r} (slot {slot}) is not bound") fmt = getattr(pos_input, "format", None) comp_width = getattr(fmt, "compByteWidth", 4) if fmt else 4 comp_count = getattr(fmt, "compCount", 3) if fmt else 3 + fmt_name = _format_name(fmt) vb_offset = getattr(vb, "byteOffset", 0) action_count = int(getattr(action, "numIndices", 0) or 0) @@ -552,7 +684,7 @@ def _mesh_data_from_ia(controller: Any, pipe_state: Any, action: Any | None) -> ) vertices = _decode_position_rows(raw, num_verts, stride, pos_offset, comp_width, comp_count) - return { + result = { "topology": _enum_name(pipe_state.GetPrimitiveTopology()), "vertex_count": len(vertices), "comp_count": comp_count, @@ -560,7 +692,17 @@ def _mesh_data_from_ia(controller: Any, pipe_state: Any, action: Any | None) -> "vertices": vertices, "index_count": len(local_indices), "indices": local_indices, + "position_attribute": _input_display_name(pos_input), + "position_source": position_source, + "position_index": position_index, + "position_slot": int(slot), + "position_byte_offset": int(pos_offset), + "position_format": fmt_name, + "position_comp_count": int(comp_count), } + if position_warning: + result["position_warning"] = position_warning + return result def _handle_mesh_data( @@ -589,7 +731,7 @@ def _handle_mesh_data( if use_ia: pipe_state = state.adapter.get_pipeline_state() action = _find_action(state.adapter.get_root_actions(), eid) - decoded = _mesh_data_from_ia(controller, pipe_state, action) + decoded = _mesh_data_from_ia(controller, pipe_state, action, params) else: decoded = _decode_mesh_postvs(controller, mesh) else: diff --git a/tests/unit/test_buffer_decode.py b/tests/unit/test_buffer_decode.py index fb57c0f..28c31bd 100644 --- a/tests/unit/test_buffer_decode.py +++ b/tests/unit/test_buffer_decode.py @@ -675,6 +675,11 @@ def test_empty_postvs_uses_ia_position(self, state: DaemonState) -> None: assert r["stage"] == "vs-in" assert r["vertex_count"] == 3 assert r["comp_count"] == 3 + assert r["position_attribute"] == "POSITION" + assert r["position_source"] == "semantic" + assert r["position_slot"] == 0 + assert r["position_byte_offset"] == 0 + assert r["position_format"] == "R32G32B32_FLOAT" assert r["vertices"][0] == pytest.approx([-1.0, -1.0, 0.0]) assert r["vertices"][1] == pytest.approx([1.0, -1.0, 0.0]) assert r["vertices"][2] == pytest.approx([0.0, 1.0, 0.0]) @@ -741,7 +746,9 @@ def test_fallback_uses_adapter_root_action_compat(self, state: DaemonState) -> N ) assert resp["result"]["vertices"][0] == pytest.approx([-1.0, -1.0, 0.0]) - def test_fallback_requires_position_semantic(self, state: DaemonState) -> None: + def test_fallback_rejects_when_no_plausible_position_candidate( + self, state: DaemonState + ) -> None: pipe = state.adapter.controller.GetPipelineState() pipe._vertex_inputs = [ VertexInputAttribute( @@ -749,7 +756,7 @@ def test_fallback_requires_position_semantic(self, state: DaemonState) -> None: vertexBuffer=0, byteOffset=12, format=ResourceFormat( - name="R32G32_FLOAT", + name="R32G32_UINT", compByteWidth=4, compCount=2, ), @@ -762,7 +769,357 @@ def test_fallback_requires_position_semantic(self, state: DaemonState) -> None: state, ) assert resp["error"]["code"] == -32001 - assert resp["error"]["message"] == "no vertex input POSITION at this event" + assert ( + resp["error"]["message"] + == "no vertex-rate float/vector position candidate at this event" + ) + + def test_fallback_selects_d3d12_attribute0_over_instance_uint(self, state: DaemonState) -> None: + pipe = state.adapter.controller.GetPipelineState() + pipe._vertex_inputs = [ + VertexInputAttribute( + name="ATTRIBUTE0", + vertexBuffer=0, + byteOffset=0, + format=ResourceFormat( + name="R32G32B32_FLOAT", + compByteWidth=4, + compCount=3, + ), + ), + VertexInputAttribute( + name="ATTRIBUTE1", + vertexBuffer=1, + byteOffset=0, + perInstance=True, + instanceRate=1, + format=ResourceFormat( + name="R32G32B32_FLOAT", + compByteWidth=4, + compCount=3, + ), + ), + VertexInputAttribute( + name="ATTRIBUTE13", + vertexBuffer=2, + byteOffset=0, + perInstance=True, + instanceRate=1, + format=ResourceFormat( + name="R32_UINT", + compByteWidth=4, + compCount=1, + ), + ), + ] + pipe._vbuffers = [ + BoundVBuffer( + resourceId=ResourceId(42), + byteOffset=0, + byteSize=36, + byteStride=12, + ), + BoundVBuffer( + resourceId=ResourceId(44), + byteOffset=0, + byteSize=12, + byteStride=12, + ), + BoundVBuffer( + resourceId=ResourceId(45), + byteOffset=0, + byteSize=4, + byteStride=4, + ), + ] + pos_data = struct.pack("<9f", -1.0, -1.0, 0.0, 1.0, -1.0, 0.0, 0.0, 1.0, 0.0) + + def _get(rid: Any, offset: int, length: int) -> bytes: + assert int(rid) == 42 + return pos_data[offset : offset + length] + + state.adapter.controller.GetBufferData = _get + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), + state, + ) + r = resp["result"] + assert r["position_attribute"] == "ATTRIBUTE0" + assert r["position_source"] == "heuristic" + assert r["position_index"] == 0 + assert r["vertices"][0] == pytest.approx([-1.0, -1.0, 0.0]) + assert r["vertices"][2] == pytest.approx([0.0, 1.0, 0.0]) + + def test_fallback_skips_instance_position_semantic(self, state: DaemonState) -> None: + pipe = state.adapter.controller.GetPipelineState() + pipe._vertex_inputs = [ + VertexInputAttribute( + name="POSITION", + vertexBuffer=1, + byteOffset=0, + perInstance=True, + instanceRate=1, + format=ResourceFormat( + name="R32G32B32_FLOAT", + compByteWidth=4, + compCount=3, + ), + ), + VertexInputAttribute( + name="ATTRIBUTE0", + vertexBuffer=0, + byteOffset=0, + format=ResourceFormat( + name="R32G32B32_FLOAT", + compByteWidth=4, + compCount=3, + ), + ), + ] + pipe._vbuffers = [ + BoundVBuffer( + resourceId=ResourceId(42), + byteOffset=0, + byteSize=36, + byteStride=12, + ), + BoundVBuffer( + resourceId=ResourceId(44), + byteOffset=0, + byteSize=12, + byteStride=12, + ), + ] + pos_data = struct.pack("<9f", -1.0, -1.0, 0.0, 1.0, -1.0, 0.0, 0.0, 1.0, 0.0) + + def _get(rid: Any, offset: int, length: int) -> bytes: + assert int(rid) == 42 + return pos_data[offset : offset + length] + + state.adapter.controller.GetBufferData = _get + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), + state, + ) + r = resp["result"] + assert r["position_attribute"] == "ATTRIBUTE0" + assert r["position_source"] == "heuristic" + assert r["vertices"][1] == pytest.approx([1.0, -1.0, 0.0]) + + def test_fallback_warns_when_multiple_heuristic_candidates(self, state: DaemonState) -> None: + pipe = state.adapter.controller.GetPipelineState() + pipe._vertex_inputs = [ + VertexInputAttribute( + name="ATTRIBUTE0", + vertexBuffer=0, + byteOffset=0, + format=ResourceFormat( + name="R32G32B32_FLOAT", + compByteWidth=4, + compCount=3, + ), + ), + VertexInputAttribute( + name="ATTRIBUTE1", + vertexBuffer=0, + byteOffset=12, + format=ResourceFormat( + name="R32G32B32A32_FLOAT", + compByteWidth=4, + compCount=4, + ), + ), + ] + verts = [ + (-1.0, -1.0, 0.0, 9.0, 9.0, 9.0, 1.0), + (1.0, -1.0, 0.0, 8.0, 8.0, 8.0, 1.0), + (0.0, 1.0, 0.0, 7.0, 7.0, 7.0, 1.0), + ] + vdata = b"".join(struct.pack("<7f", *v) for v in verts) + pipe._vbuffers = [ + BoundVBuffer( + resourceId=ResourceId(42), + byteOffset=0, + byteSize=len(vdata), + byteStride=28, + ) + ] + + def _get(rid: Any, offset: int, length: int) -> bytes: + assert int(rid) == 42 + return vdata[offset : offset + length] + + state.adapter.controller.GetBufferData = _get + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), + state, + ) + r = resp["result"] + assert r["position_attribute"] == "ATTRIBUTE0" + assert r["position_source"] == "heuristic" + assert r["position_warning"] == ( + "heuristic position selection chose 'ATTRIBUTE0' from 2 candidates; " + "use a position override if this is wrong" + ) + assert r["vertices"][0] == pytest.approx([-1.0, -1.0, 0.0]) + + def test_position_attribute_override_selects_named_input(self, state: DaemonState) -> None: + resp, _ = _handle_request( + rpc_request( + "mesh_data", + {"eid": 10, "stage": "vs-in", "position_attribute": "TEXCOORD"}, + token="abcdef1234567890", + ), + state, + ) + r = resp["result"] + assert r["position_attribute"] == "TEXCOORD" + assert r["position_source"] == "user" + assert r["position_index"] == 1 + assert r["position_byte_offset"] == 12 + assert r["comp_count"] == 2 + assert r["vertices"][0] == pytest.approx([0.0, 0.0]) + assert r["vertices"][1] == pytest.approx([1.0, 0.0]) + + def test_position_index_override_selects_input_by_index(self, state: DaemonState) -> None: + resp, _ = _handle_request( + rpc_request( + "mesh_data", + {"eid": 10, "stage": "vs-in", "position_index": 1}, + token="abcdef1234567890", + ), + state, + ) + r = resp["result"] + assert r["position_attribute"] == "TEXCOORD" + assert r["position_source"] == "user" + assert r["vertices"][2] == pytest.approx([0.5, 1.0]) + + def test_position_slot_offset_override_selects_input(self, state: DaemonState) -> None: + resp, _ = _handle_request( + rpc_request( + "mesh_data", + {"eid": 10, "stage": "vs-in", "position_slot": 0, "position_offset": 12}, + token="abcdef1234567890", + ), + state, + ) + r = resp["result"] + assert r["position_attribute"] == "TEXCOORD" + assert r["position_source"] == "user" + assert r["vertices"][0] == pytest.approx([0.0, 0.0]) + + def test_position_override_rejects_non_float_format(self, state: DaemonState) -> None: + pipe = state.adapter.controller.GetPipelineState() + pipe._vertex_inputs.append( + VertexInputAttribute( + name="ATTRIBUTE13", + vertexBuffer=0, + byteOffset=0, + format=ResourceFormat(name="R32_UINT", compByteWidth=4, compCount=1), + ) + ) + state.adapter.controller.GetBufferData = pytest.fail + + resp, _ = _handle_request( + rpc_request( + "mesh_data", + {"eid": 10, "stage": "vs-in", "position_attribute": "ATTRIBUTE13"}, + token="abcdef1234567890", + ), + state, + ) + assert resp["error"]["code"] == -32001 + assert ( + resp["error"]["message"] + == "vertex input 'ATTRIBUTE13' format 'R32_UINT' cannot be decoded as position" + ) + + def test_position_override_reports_selected_input_for_invalid_vbuffer( + self, state: DaemonState + ) -> None: + pipe = state.adapter.controller.GetPipelineState() + pipe._vertex_inputs = [ + VertexInputAttribute( + name="POSITION", + vertexBuffer=0, + byteOffset=0, + format=ResourceFormat( + name="R32G32B32_FLOAT", + compByteWidth=4, + compCount=3, + ), + ), + VertexInputAttribute( + name="TEXCOORD", + vertexBuffer=1, + byteOffset=0, + format=ResourceFormat( + name="R32G32_FLOAT", + compByteWidth=4, + compCount=2, + ), + ), + ] + pipe._vbuffers = [ + BoundVBuffer( + resourceId=ResourceId(42), + byteOffset=0, + byteSize=36, + byteStride=12, + ), + BoundVBuffer( + resourceId=ResourceId(0), + byteOffset=0, + byteSize=0, + byteStride=0, + ), + ] + state.adapter.controller.GetBufferData = pytest.fail + + resp, _ = _handle_request( + rpc_request( + "mesh_data", + {"eid": 10, "stage": "vs-in", "position_attribute": "TEXCOORD"}, + token="abcdef1234567890", + ), + state, + ) + assert resp["error"]["code"] == -32001 + assert resp["error"]["message"] == "vertex buffer for 'TEXCOORD' (slot 1) is not bound" + + @pytest.mark.parametrize( + ("params", "expected"), + [ + ( + {"position_slot": 0}, + "position slot override requires both position_slot and position_offset", + ), + ( + {"position_attribute": "POSITION", "position_index": 0}, + "use only one position override selector", + ), + ({"position_index": 99}, "no vertex input at position index 99"), + ( + {"position_slot": 9, "position_offset": 4}, + "no vertex input at position slot 9 offset 4", + ), + ], + ) + def test_position_override_validation_errors( + self, state: DaemonState, params: dict[str, Any], expected: str + ) -> None: + state.adapter.controller.GetBufferData = pytest.fail + resp, _ = _handle_request( + rpc_request( + "mesh_data", + {"eid": 10, "stage": "vs-in", **params}, + token="abcdef1234567890", + ), + state, + ) + assert resp["error"]["code"] == -32001 + assert resp["error"]["message"] == expected def test_fallback_requires_draw_action(self, state: DaemonState) -> None: state.adapter.controller.GetRootActions().clear() @@ -1193,7 +1550,7 @@ def test_vs_in_decodes_geometry(self, tmp_path: Path) -> None: assert r["vertices"][2] == pytest.approx([7.0, 8.0, 9.0]) def test_vs_in_without_postvs_or_ia_returns_error(self, state: DaemonState) -> None: - """stage=vs-in fails only when neither PostVS nor IA POSITION can provide data.""" + """stage=vs-in fails only when neither PostVS nor IA has a position candidate.""" empty = SimpleNamespace(vertexResourceId=ResourceId(0), vertexByteStride=0) orig_postvs = state.adapter.controller.GetPostVSData state.adapter.controller.GetPostVSData = lambda inst, view, stage: empty @@ -1203,7 +1560,10 @@ def test_vs_in_without_postvs_or_ia_returns_error(self, state: DaemonState) -> N state, ) assert resp["error"]["code"] == -32001 - assert resp["error"]["message"] == "no vertex input POSITION at this event" + assert ( + resp["error"]["message"] + == "no vertex-rate float/vector position candidate at this event" + ) state.adapter.controller.GetPostVSData = orig_postvs def test_invalid_stage_lists_vs_in(self, state: DaemonState) -> None: diff --git a/tests/unit/test_mesh_commands.py b/tests/unit/test_mesh_commands.py index 9cc6dba..93b6b2d 100644 --- a/tests/unit/test_mesh_commands.py +++ b/tests/unit/test_mesh_commands.py @@ -68,6 +68,21 @@ def test_mesh_no_header(self, monkeypatch: Any) -> None: assert not any(ln.startswith("#") for ln in lines) assert lines[0].startswith("v ") + def test_mesh_obj_warns_on_position_warning(self, monkeypatch: Any) -> None: + warning = ( + "heuristic position selection chose 'ATTRIBUTE0' from 2 candidates; " + "use a position override if this is wrong" + ) + monkeypatch.setattr( + "rdc.commands.mesh.call", + lambda m, p: {**_MESH_RESPONSE, "position_warning": warning}, + ) + runner = CliRunner() + result = runner.invoke(mesh_cmd, []) + assert result.exit_code == 0 + assert f"mesh: warning: {warning}" in result.output + assert "v 0.000000 0.500000 0.000000" in result.output + def test_mesh_stage_forwarded(self, monkeypatch: Any) -> None: calls: list[tuple[str, dict[str, Any]]] = [] @@ -94,6 +109,49 @@ def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: assert result.exit_code == 0 assert calls[0][1]["stage"] == "vs-in" + def test_mesh_position_attribute_forwarded(self, monkeypatch: Any) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + calls.append((method, params)) + return _MESH_RESPONSE + + monkeypatch.setattr("rdc.commands.mesh.call", mock_call) + runner = CliRunner() + result = runner.invoke(mesh_cmd, ["--stage", "vs-in", "--position-attribute", "TEXCOORD"]) + assert result.exit_code == 0 + assert calls[0][1]["position_attribute"] == "TEXCOORD" + + def test_mesh_position_index_forwarded(self, monkeypatch: Any) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + calls.append((method, params)) + return _MESH_RESPONSE + + monkeypatch.setattr("rdc.commands.mesh.call", mock_call) + runner = CliRunner() + result = runner.invoke(mesh_cmd, ["--stage", "vs-in", "--position-index", "1"]) + assert result.exit_code == 0 + assert calls[0][1]["position_index"] == 1 + + def test_mesh_position_slot_offset_forwarded(self, monkeypatch: Any) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + calls.append((method, params)) + return _MESH_RESPONSE + + monkeypatch.setattr("rdc.commands.mesh.call", mock_call) + runner = CliRunner() + result = runner.invoke( + mesh_cmd, + ["--stage", "vs-in", "--position-slot", "0", "--position-offset", "12"], + ) + assert result.exit_code == 0 + assert calls[0][1]["position_slot"] == 0 + assert calls[0][1]["position_offset"] == 12 + def test_mesh_unknown_stage_rejected(self, monkeypatch: Any) -> None: called: list[Any] = [] monkeypatch.setattr("rdc.commands.mesh.call", lambda m, p: called.append((m, p))) @@ -108,6 +166,7 @@ def test_mesh_help(self) -> None: assert result.exit_code == 0 assert "EID" in result.output assert "--stage" in result.output + assert "--position-attribute" in result.output assert "-o" in result.output def test_mesh_in_main_help(self) -> None: