feat: 完善 STEP 编辑器最小系统和稳定性

This commit is contained in:
2026-07-28 14:05:14 +08:00
parent 26216064e0
commit 7c263d1e96
19 changed files with 3173 additions and 931 deletions
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
import argparse
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OUTPUT = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def write_cube_step(path: Path, size: float = 10.0) -> None:
entities: list[str] = []
def add(text: str) -> int:
index = len(entities) + 1
entities.append(f"#{index}={text};")
return index
def ref(index: int) -> str:
return f"#{index}"
def refs(indices) -> str:
return ",".join(ref(index) for index in indices)
app_ctx = add("APPLICATION_CONTEXT('automotive_design')")
add(f"APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,{ref(app_ctx)})")
prod_ctx = add(f"PRODUCT_CONTEXT('',{ref(app_ctx)},'mechanical')")
product = add(
"PRODUCT('CUBE_10MM','CUBE_10MM','Simple 10 mm cube generated for edge editing tests',"
f"({ref(prod_ctx)}))"
)
formation = add(
f"PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('1','generated',{ref(product)},.NOT_KNOWN.)"
)
pd_ctx = add(f"PRODUCT_DEFINITION_CONTEXT('part definition',{ref(app_ctx)},'design')")
product_def = add(f"PRODUCT_DEFINITION('design','',{ref(formation)},{ref(pd_ctx)})")
product_shape = add(f"PRODUCT_DEFINITION_SHAPE('','',{ref(product_def)})")
length_unit = add("(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.))")
angle_unit = add("(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))")
solid_angle_unit = add("(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT())")
uncertainty = add(
f"UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),{ref(length_unit)},"
"'distance_accuracy_value','')"
)
geom_context = add(
"(GEOMETRIC_REPRESENTATION_CONTEXT(3) "
f"GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({ref(uncertainty)})) "
f"GLOBAL_UNIT_ASSIGNED_CONTEXT(({refs([length_unit, angle_unit, solid_angle_unit])})) "
"REPRESENTATION_CONTEXT('3D Context',''))"
)
point_ids: dict[str, int] = {}
for name, xyz in {
"P1": (0.0, 0.0, 0.0),
"P2": (size, 0.0, 0.0),
"P3": (size, size, 0.0),
"P4": (0.0, size, 0.0),
"P5": (0.0, 0.0, size),
"P6": (size, 0.0, size),
"P7": (size, size, size),
"P8": (0.0, size, size),
}.items():
point_ids[name] = add("CARTESIAN_POINT('',({:.6f},{:.6f},{:.6f}))".format(*xyz))
directions = {
"+X": add("DIRECTION('',(1.000000,0.000000,0.000000))"),
"-X": add("DIRECTION('',(-1.000000,0.000000,0.000000))"),
"+Y": add("DIRECTION('',(0.000000,1.000000,0.000000))"),
"-Y": add("DIRECTION('',(0.000000,-1.000000,0.000000))"),
"+Z": add("DIRECTION('',(0.000000,0.000000,1.000000))"),
"-Z": add("DIRECTION('',(0.000000,0.000000,-1.000000))"),
}
world_axis = add(
f"AXIS2_PLACEMENT_3D('',{ref(point_ids['P1'])},{ref(directions['+Z'])},{ref(directions['+X'])})"
)
vertex_ids = {
name.replace("P", "V"): add(f"VERTEX_POINT('',{ref(point_id)})")
for name, point_id in point_ids.items()
}
edge_specs = {
"E1": ("V1", "V2", "P1", "+X"),
"E2": ("V2", "V3", "P2", "+Y"),
"E3": ("V3", "V4", "P3", "-X"),
"E4": ("V4", "V1", "P4", "-Y"),
"E5": ("V5", "V6", "P5", "+X"),
"E6": ("V6", "V7", "P6", "+Y"),
"E7": ("V7", "V8", "P7", "-X"),
"E8": ("V8", "V5", "P8", "-Y"),
"E9": ("V1", "V5", "P1", "+Z"),
"E10": ("V2", "V6", "P2", "+Z"),
"E11": ("V3", "V7", "P3", "+Z"),
"E12": ("V4", "V8", "P4", "+Z"),
}
edge_ids: dict[str, int] = {}
for name, (start, end, start_point, direction) in edge_specs.items():
vector = add(f"VECTOR('',{ref(directions[direction])},1.000000)")
line = add(f"LINE('',{ref(point_ids[start_point])},{ref(vector)})")
edge_ids[name] = add(
f"EDGE_CURVE('{name}',{ref(vertex_ids[start])},{ref(vertex_ids[end])},{ref(line)},.T.)"
)
def oriented(edge_name: str, same: bool) -> int:
orientation = ".T." if same else ".F."
return add(f"ORIENTED_EDGE('',*,*,{ref(edge_ids[edge_name])},{orientation})")
def make_plane(origin_point: str, normal: str, ref_dir: str) -> int:
axis = add(
f"AXIS2_PLACEMENT_3D('',{ref(point_ids[origin_point])},"
f"{ref(directions[normal])},{ref(directions[ref_dir])})"
)
return add(f"PLANE('',{ref(axis)})")
faces: list[int] = []
face_specs = [
("BOTTOM_Z0", [("E4", False), ("E3", False), ("E2", False), ("E1", False)], "P1", "-Z", "+X"),
("TOP_Z10", [("E5", True), ("E6", True), ("E7", True), ("E8", True)], "P5", "+Z", "+X"),
("FRONT_Y0", [("E1", True), ("E10", True), ("E5", False), ("E9", False)], "P1", "-Y", "+X"),
("BACK_Y10", [("E12", True), ("E7", False), ("E11", False), ("E3", True)], "P4", "+Y", "+X"),
("LEFT_X0", [("E9", True), ("E8", False), ("E12", False), ("E4", True)], "P1", "-X", "+Y"),
("RIGHT_X10", [("E2", True), ("E11", True), ("E6", False), ("E10", False)], "P2", "+X", "+Y"),
]
for name, loop_edges, plane_origin, normal, ref_dir in face_specs:
loop = add(f"EDGE_LOOP('',({refs(oriented(edge, same) for edge, same in loop_edges)}))")
bound = add(f"FACE_OUTER_BOUND('',{ref(loop)},.T.)")
plane = make_plane(plane_origin, normal, ref_dir)
faces.append(add(f"ADVANCED_FACE('{name}',({ref(bound)}),{ref(plane)},.T.)"))
closed_shell = add(f"CLOSED_SHELL('',({refs(faces)}))")
solid = add(f"MANIFOLD_SOLID_BREP('CUBE_10MM',{ref(closed_shell)})")
shape_rep = add(
f"ADVANCED_BREP_SHAPE_REPRESENTATION('',({refs([world_axis, solid])}),{ref(geom_context)})"
)
add(f"SHAPE_DEFINITION_REPRESENTATION({ref(product_shape)},{ref(shape_rep)})")
add(f"PRODUCT_RELATED_PRODUCT_CATEGORY('part','',({ref(product)}))")
header = "\n".join(
[
"ISO-10303-21;",
"HEADER;",
"FILE_DESCRIPTION(('Simple 10 mm cube generated by Python for edge edit tests'),'2;1');",
(
f"FILE_NAME('{path.name}','{datetime.now().isoformat(timespec='seconds')}',"
"('Codex'),('OpenAI'),'Python ASCII STEP generator','python-occt','');"
),
"FILE_SCHEMA(('AUTOMOTIVE_DESIGN_CC2'));",
"ENDSEC;",
"DATA;",
]
)
path.write_text(header + "\n" + "\n".join(entities) + "\nENDSEC;\nEND-ISO-10303-21;\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Generate a simple STEP cube for editor tests.")
parser.add_argument("output", nargs="?", default=str(DEFAULT_OUTPUT), help="Output STEP file path.")
parser.add_argument("--size", type=float, default=10.0, help="Cube edge length in millimeters.")
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
write_cube_step(output, args.size)
print(f"wrote {output.resolve()} ({output.stat().st_size} bytes)")
print(f"cube bounds: 0,0,0 to {args.size:g},{args.size:g},{args.size:g} mm")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import argparse
from collections import Counter
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _edge_length(model: StepModel, edge_id: int) -> float:
return float(model.edge_info(edge_id).get("length", 0.0))
def _line_edge_ids_near_length(model: StepModel, length: float, tolerance: float) -> list[int]:
edge_ids: list[int] = []
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "line":
continue
if abs(float(info.get("length", 0.0)) - length) <= tolerance:
edge_ids.append(edge_id)
return edge_ids
def _length_distribution(model: StepModel) -> dict[float, int]:
counts = Counter(round(_edge_length(model, edge_id), 6) for edge_id in range(len(model.edges)))
return dict(sorted(counts.items()))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify cube edge-length local deformation.")
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
parser.add_argument("--source-length", type=float, default=10.0, help="Current line edge length to search for.")
parser.add_argument("--target-length", type=float, default=15.0, help="Target edge length to apply.")
parser.add_argument("--anchor", default="keep-start", choices=["auto", "center", "keep-start", "keep-end"])
parser.add_argument("--tolerance", type=float, default=1e-5, help="Allowed target length error.")
args = parser.parse_args()
model = StepModel.load(Path(args.model))
edge_ids = _line_edge_ids_near_length(model, args.source_length, args.tolerance)
if not edge_ids:
raise SystemExit(f"no line edge near source length {args.source_length:g}")
edge_id = edge_ids[0]
before = model.stats()
plan = model.general_edge_length_plan(edge_id, args.target_length, anchor_mode=args.anchor)
strategy = str(plan.get("resize_strategy", ""))
if strategy != "local-edge-only-deform":
raise SystemExit(f"expected local-edge-only-deform, got {strategy or '<none>'}")
result = model.resize_general_edge_length(edge_id, args.target_length, anchor_mode=args.anchor)
after = model.stats()
lengths = [_edge_length(model, item) for item in range(len(model.edges))]
nearest = min(lengths, key=lambda value: abs(value - args.target_length))
error = abs(nearest - args.target_length)
if error > args.tolerance:
raise SystemExit(f"target length check failed: nearest={nearest:g}, error={error:g}")
print(f"model={Path(args.model)}")
print(f"edge_id={edge_id}")
print(f"strategy={strategy}")
print(f"anchor_mode={args.anchor}")
print(f"start_move={plan.get('local_edge_deform_start_move')}")
print(f"end_move={plan.get('local_edge_deform_end_move')}")
print(f"before_faces={before.faces} before_edges={before.edges}")
print(f"after_faces={after.faces} after_edges={after.edges}")
print(f"nearest_length={nearest:.6f} target_error={error:.6g}")
print(f"length_distribution={_length_distribution(model)}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
if __name__ == "__main__":
raise SystemExit(main())