optigami / engine /paper.py
sissississi's picture
Add physics engine (Layer 4) and instruction planner (Layer 1)
efeed27
raw
history blame
19.6 kB
"""
Paper β€” the core geometric data structure for origami simulation.
Stores vertices, edges, faces, fold assignments, fold angles, layer ordering,
and material. Supports FOLD-format serialization and the face-splitting
operation needed by the fold engine.
"""
from __future__ import annotations
import copy
import json
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from .materials import Material, get_material
# ────────────────────────────────────────────────────────────────────
# Helper: 2-D line-segment intersection
# ────────────────────────────────────────────────────────────────────
def _seg_seg_intersect_2d(
p1: np.ndarray, p2: np.ndarray,
p3: np.ndarray, p4: np.ndarray,
eps: float = 1e-10,
) -> np.ndarray | None:
"""Return the intersection point of segments (p1-p2) and (p3-p4) in 2-D,
or None if they do not intersect. Points that lie on the segment
endpoints are considered intersections (within tolerance *eps*).
All inputs are shape (2,).
"""
d1 = p2 - p1
d2 = p4 - p3
denom = d1[0] * d2[1] - d1[1] * d2[0]
if abs(denom) < eps:
return None # parallel / collinear
dp = p3 - p1
t = (dp[0] * d2[1] - dp[1] * d2[0]) / denom
u = (dp[0] * d1[1] - dp[1] * d1[0]) / denom
if -eps <= t <= 1.0 + eps and -eps <= u <= 1.0 + eps:
return p1 + np.clip(t, 0.0, 1.0) * d1
return None
# ────────────────────────────────────────────────────────────────────
# Paper dataclass
# ────────────────────────────────────────────────────────────────────
@dataclass
class Paper:
"""Origami sheet state.
Attributes
----------
vertices : np.ndarray, shape (N, 3)
Vertex positions in 3-D.
edges : np.ndarray, shape (E, 2), dtype int
Each row is (v_start, v_end).
faces : list[list[int]]
Each face is an ordered list of vertex indices (CCW winding).
assignments : list[str]
Per-edge assignment: 'M' (mountain), 'V' (valley), 'B' (boundary),
'F' (flat / unfolded), 'U' (unassigned).
fold_angles : np.ndarray, shape (E,)
Current fold angle (degrees) per edge.
face_orders : list[tuple[int, int, int]]
Layer ordering triples (f_i, f_j, +1/-1) meaning f_i is above/below f_j.
material : Material
The sheet material.
rest_lengths : np.ndarray, shape (E,)
Original (unfolded) edge lengths β€” used for strain computation.
original_area : float
Area of the sheet before any folds.
"""
vertices: np.ndarray
edges: np.ndarray
faces: list[list[int]]
assignments: list[str]
fold_angles: np.ndarray
face_orders: list[tuple[int, int, int]] = field(default_factory=list)
material: Material = field(default_factory=lambda: get_material("paper"))
rest_lengths: np.ndarray = field(default_factory=lambda: np.empty(0))
original_area: float = 0.0
# ── constructors ────────────────────────────────────────────────
@staticmethod
def create_flat_sheet(
width: float = 1.0,
height: float = 1.0,
material: Material | None = None,
) -> "Paper":
"""Create a flat rectangular sheet with 4 vertices, 5 edges
(including one diagonal), and 2 triangular faces."""
mat = material if material is not None else get_material("paper")
verts = np.array([
[0.0, 0.0, 0.0],
[width, 0.0, 0.0],
[width, height, 0.0],
[0.0, height, 0.0],
], dtype=np.float64)
edges = np.array([
[0, 1], # bottom
[1, 2], # right
[2, 3], # top
[3, 0], # left
[0, 2], # diagonal
], dtype=np.int64)
faces: list[list[int]] = [[0, 1, 2], [0, 2, 3]]
assignments = ["B", "B", "B", "B", "F"]
fold_angles = np.zeros(len(edges), dtype=np.float64)
rest_lengths = np.array(
[np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
dtype=np.float64,
)
return Paper(
vertices=verts,
edges=edges,
faces=faces,
assignments=assignments,
fold_angles=fold_angles,
material=mat,
rest_lengths=rest_lengths,
original_area=width * height,
)
# ── dict / prompt serialization (matches mock_env.PaperState.to_dict) ──
def to_dict(self) -> dict:
"""Return a simplified dict suitable for LLM prompts.
The format matches ``mock_env.PaperState.to_dict()`` so that the
trainer reward functions work with either engine.
"""
bb = self.bounding_box
return {
"width": float(bb[0]),
"height": float(bb[1]),
"material": {
"name": self.material.name,
"thickness_mm": self.material.thickness_mm,
"youngs_modulus_gpa": self.material.youngs_modulus_gpa,
},
"vertices": self.vertices.tolist(),
"edges": self.edges.tolist(),
"assignments": list(self.assignments),
"fold_angles": self.fold_angles.tolist(),
"num_layers_at_center": self.num_layers,
"bounding_box": {
"x": float(bb[0]),
"y": float(bb[1]),
"z": float(bb[2]),
},
}
# ── FOLD format serialization ───────────────────────────────────
def to_fold_json(self) -> str:
"""Serialize to FOLD JSON format (v1.1 subset)."""
fold = {
"file_spec": 1.1,
"file_creator": "optigami",
"file_classes": ["singleModel"],
"frame_classes": ["foldedForm"],
"vertices_coords": self.vertices.tolist(),
"edges_vertices": self.edges.tolist(),
"edges_assignment": self.assignments,
"edges_foldAngle": self.fold_angles.tolist(),
"faces_vertices": self.faces,
"faceOrders": [list(fo) for fo in self.face_orders],
}
return json.dumps(fold, indent=2)
@staticmethod
def from_fold_json(data: str | dict, material: Material | None = None) -> "Paper":
"""Deserialize from FOLD JSON format."""
if isinstance(data, str):
data = json.loads(data)
verts = np.array(data["vertices_coords"], dtype=np.float64)
edges = np.array(data["edges_vertices"], dtype=np.int64)
faces = data.get("faces_vertices", [])
assignments = data.get("edges_assignment", ["U"] * len(edges))
fold_angles = np.array(
data.get("edges_foldAngle", [0.0] * len(edges)),
dtype=np.float64,
)
face_orders = [tuple(fo) for fo in data.get("faceOrders", [])]
rest_lengths = np.array(
[np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
dtype=np.float64,
)
mat = material if material is not None else get_material("paper")
# Approximate original area from convex hull of initial XY footprint
try:
from scipy.spatial import ConvexHull
hull = ConvexHull(verts[:, :2])
area = hull.volume # 2-D ConvexHull.volume is area
except Exception:
# Fallback: bounding-box area from XY coordinates
if len(verts) >= 2:
ptp = np.ptp(verts[:, :2], axis=0)
area = float(ptp[0] * ptp[1])
else:
area = 0.0
return Paper(
vertices=verts,
edges=edges,
faces=faces,
assignments=assignments,
fold_angles=fold_angles,
face_orders=face_orders,
material=mat,
rest_lengths=rest_lengths,
original_area=area,
)
# ── computed properties ─────────────────────────────────────────
@property
def bounding_box(self) -> np.ndarray:
"""Axis-aligned bounding-box dimensions (dx, dy, dz)."""
if len(self.vertices) == 0:
return np.zeros(3)
ptp = np.ptp(self.vertices, axis=0)
ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
# Ensure minimum z height from material thickness * layers
t = self.material.thickness_mm / 1000.0
ptp[2] = max(ptp[2], t * self.num_layers)
return ptp
@property
def num_layers(self) -> int:
"""Estimate layer count from face-order triples.
Falls back to 1 + number of M/V edges as a simple heuristic when
face_orders is empty.
"""
if self.face_orders:
face_ids = set()
for fo in self.face_orders:
face_ids.add(fo[0])
face_ids.add(fo[1])
return max(len(face_ids), 1)
# Heuristic: each fold adds one layer
mv_count = sum(1 for a in self.assignments if a in ("M", "V"))
return 1 + mv_count
# ── topology helpers ────────────────────────────────────────────
def _find_or_add_vertex(self, point_3d: np.ndarray, tol: float = 1e-8) -> int:
"""Return index of an existing vertex close to *point_3d*, or add a
new vertex and return its index."""
for i, v in enumerate(self.vertices):
if np.linalg.norm(v - point_3d) < tol:
return i
idx = len(self.vertices)
self.vertices = np.vstack([self.vertices, point_3d.reshape(1, 3)])
return idx
def _find_or_add_edge(self, v1: int, v2: int) -> int:
"""Return index of edge (v1,v2) or (v2,v1), or add a new edge and
return its index. New edges get assignment 'F' and fold-angle 0."""
for i, e in enumerate(self.edges):
if (e[0] == v1 and e[1] == v2) or (e[0] == v2 and e[1] == v1):
return i
idx = len(self.edges)
self.edges = np.vstack([self.edges, np.array([[v1, v2]], dtype=np.int64)])
self.assignments.append("F")
self.fold_angles = np.append(self.fold_angles, 0.0)
# Rest length for the new edge
rl = np.linalg.norm(self.vertices[v1] - self.vertices[v2])
self.rest_lengths = np.append(self.rest_lengths, rl)
return idx
# ── face splitting ──────────────────────────────────────────────
def split_faces_along_line(
self,
start_2d: np.ndarray | list,
end_2d: np.ndarray | list,
) -> list[int]:
"""Split every face that the 2-D line (start_2d -> end_2d) crosses.
The line is infinite for intersection purposes (we test each face
edge-segment against the full fold-line extent clipped to the paper).
Returns a list of edge indices that lie *on* the fold line (i.e. the
newly created edges along the fold path).
This mutates ``self`` in-place (vertices, edges, faces, assignments,
fold_angles, rest_lengths are updated).
"""
start_2d = np.asarray(start_2d, dtype=np.float64)
end_2d = np.asarray(end_2d, dtype=np.float64)
fold_edge_indices: list[int] = []
new_faces: list[list[int]] = []
faces_to_process = list(range(len(self.faces)))
for fi in faces_to_process:
face = self.faces[fi]
n = len(face)
# Gather intersection points along the face boundary
hits: list[tuple[int, np.ndarray]] = [] # (local_edge_index, point_2d)
for k in range(n):
v_a = face[k]
v_b = face[(k + 1) % n]
pa = self.vertices[v_a][:2]
pb = self.vertices[v_b][:2]
pt = _seg_seg_intersect_2d(start_2d, end_2d, pa, pb)
if pt is not None:
hits.append((k, pt))
# Deduplicate hits that are at the same location (e.g. hitting a vertex)
if len(hits) >= 2:
unique_hits: list[tuple[int, np.ndarray]] = [hits[0]]
for h in hits[1:]:
is_dup = False
for uh in unique_hits:
if np.linalg.norm(h[1] - uh[1]) < 1e-8:
is_dup = True
break
if not is_dup:
unique_hits.append(h)
hits = unique_hits
if len(hits) < 2:
# Line does not fully cross this face β€” keep face as-is
new_faces.append(face)
continue
# We only handle the first two intersection points (one chord across face)
hit_a_edge_idx, hit_a_pt = hits[0]
hit_b_edge_idx, hit_b_pt = hits[1]
# Create / find 3-D vertices at intersection points (z=0 for flat, interpolated otherwise)
def _interp_z(pt2d: np.ndarray, edge_local: int) -> np.ndarray:
"""Interpolate z from the edge endpoints."""
v_a = face[edge_local]
v_b = face[(edge_local + 1) % n]
pa = self.vertices[v_a]
pb = self.vertices[v_b]
seg = pb[:2] - pa[:2]
seg_len = np.linalg.norm(seg)
if seg_len < 1e-12:
return np.array([pt2d[0], pt2d[1], pa[2]])
t = np.linalg.norm(pt2d - pa[:2]) / seg_len
t = np.clip(t, 0.0, 1.0)
z = pa[2] + t * (pb[2] - pa[2])
return np.array([pt2d[0], pt2d[1], z])
pt_a_3d = _interp_z(hit_a_pt, hit_a_edge_idx)
pt_b_3d = _interp_z(hit_b_pt, hit_b_edge_idx)
idx_a = self._find_or_add_vertex(pt_a_3d)
idx_b = self._find_or_add_vertex(pt_b_3d)
if idx_a == idx_b:
new_faces.append(face)
continue
# Add the fold-line edge between the two intersection points
fold_eidx = self._find_or_add_edge(idx_a, idx_b)
fold_edge_indices.append(fold_eidx)
# ── Split the face into two sub-faces ──
# Walk around the face vertices, inserting idx_a and idx_b at the
# appropriate positions, then split into two loops.
ordered_verts = list(face)
# Insert intersection vertices into the vertex ring if not already present
def _insert_after(ring: list[int], after_local: int, vid: int) -> list[int]:
"""Insert *vid* after position *after_local* if it is not already
adjacent in the ring at that position."""
pos = after_local + 1
if ring[after_local % len(ring)] == vid:
return ring
if ring[pos % len(ring)] == vid:
return ring
return ring[:pos] + [vid] + ring[pos:]
# Determine insertion order β€” always insert the one with the
# larger local-edge index first so that the earlier index stays valid.
if hit_a_edge_idx <= hit_b_edge_idx:
ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
# Recompute hit_a_edge_idx offset if idx_b was inserted before it
# (it shouldn't be, since hit_b >= hit_a, but guard anyway)
a_pos = hit_a_edge_idx
ordered_verts = _insert_after(ordered_verts, a_pos, idx_a)
else:
ordered_verts = _insert_after(ordered_verts, hit_a_edge_idx, idx_a)
ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
# Now split the ring at idx_a and idx_b
try:
pos_a = ordered_verts.index(idx_a)
pos_b = ordered_verts.index(idx_b)
except ValueError:
new_faces.append(face)
continue
if pos_a > pos_b:
pos_a, pos_b = pos_b, pos_a
loop1 = ordered_verts[pos_a: pos_b + 1]
loop2 = ordered_verts[pos_b:] + ordered_verts[: pos_a + 1]
# Only keep faces with >= 3 unique vertices
for loop in (loop1, loop2):
unique = list(dict.fromkeys(loop)) # preserve order, dedupe
if len(unique) >= 3:
new_faces.append(unique)
# Ensure all edges of this new face exist
for k in range(len(unique)):
self._find_or_add_edge(unique[k], unique[(k + 1) % len(unique)])
self.faces = new_faces
return fold_edge_indices
# ── vertex side test ────────────────────────────────────────────
def get_vertices_on_side(
self,
line_start: np.ndarray | list,
line_end: np.ndarray | list,
side: str = "positive",
) -> list[int]:
"""Return indices of vertices on one side of a 2-D line.
*side* can be ``"positive"`` or ``"negative"``. The positive side is
defined by the left-hand normal of (line_end - line_start).
"""
ls = np.asarray(line_start, dtype=np.float64)[:2]
le = np.asarray(line_end, dtype=np.float64)[:2]
d = le - ls
normal = np.array([-d[1], d[0]])
indices: list[int] = []
for i, v in enumerate(self.vertices):
dot = np.dot(v[:2] - ls, normal)
if side == "positive" and dot > 1e-9:
indices.append(i)
elif side == "negative" and dot < -1e-9:
indices.append(i)
return indices
# ── deep copy ───────────────────────────────────────────────────
def copy(self) -> "Paper":
"""Return an independent deep copy."""
return Paper(
vertices=self.vertices.copy(),
edges=self.edges.copy(),
faces=copy.deepcopy(self.faces),
assignments=list(self.assignments),
fold_angles=self.fold_angles.copy(),
face_orders=list(self.face_orders),
material=Material(
name=self.material.name,
thickness_mm=self.material.thickness_mm,
youngs_modulus_gpa=self.material.youngs_modulus_gpa,
max_strain=self.material.max_strain,
poissons_ratio=self.material.poissons_ratio,
),
rest_lengths=self.rest_lengths.copy(),
original_area=self.original_area,
)