Skip to content

Commit ad94d4c

Browse files
MarinVillalobosplu
andauthored
feat(3D-Texture): add 3D Texture scripts (restored from e543fd0) (#184)
* feat(3D-Texture): restore 3D Texture scripts from e543fd0; keep rest at main * update pre-commit config * Apply pre-commit fixes to selected 3D Texture scripts * pre-commit correction --------- Co-authored-by: plu <plu@ansys.com>
1 parent ba730a8 commit ad94d4c

File tree

8 files changed

+953
-29
lines changed

8 files changed

+953
-29
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
2-
- repo: https://github.com/humitos/mirrors-autoflake
3-
rev: v1.1
2+
- repo: https://github.com/PyCQA/autoflake
3+
rev: v2.2.1
44
hooks:
55
- id: autoflake
66
args: ['-i', '--remove-all-unused-imports']
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
"""
5+
Fit a 2D polynomial surface z = f(x, y) from a Speos .OPT3DMapping file,
6+
generate an STL mesh, and export both the STL and the polynomial model JSON.
7+
8+
This script works standalone or can be imported by another script.
9+
"""
10+
11+
import json
12+
import os
13+
14+
import numpy as np
15+
import trimesh
16+
17+
18+
def build_design_matrix(x: np.ndarray, y: np.ndarray, order: int = 5) -> np.ndarray:
19+
"""
20+
Build the design matrix for 2D polynomial least-squares fitting.
21+
22+
Parameters
23+
----------
24+
x, y : ndarray
25+
Normalized coordinates.
26+
order : int, optional
27+
Polynomial total order (default: 5).
28+
29+
Returns
30+
-------
31+
ndarray
32+
Vandermonde-like matrix of shape (N, (order+1)(order+2)/2).
33+
"""
34+
terms = []
35+
for i in range(order + 1):
36+
for j in range(order + 1 - i):
37+
terms.append((x**i) * (y**j))
38+
return np.vstack(terms).T
39+
40+
41+
def evaluate_fitted_surface(coeffs: np.ndarray, x: np.ndarray, y: np.ndarray, order: int = 5) -> np.ndarray:
42+
"""
43+
Evaluate the fitted polynomial surface z = f(x, y).
44+
45+
Parameters
46+
----------
47+
coeffs : ndarray
48+
Polynomial coefficients.
49+
x, y : ndarray
50+
Normalized coordinates.
51+
order : int
52+
Polynomial order.
53+
54+
Returns
55+
-------
56+
ndarray
57+
Evaluated z values.
58+
"""
59+
z = np.zeros_like(x)
60+
idx = 0
61+
for i in range(order + 1):
62+
for j in range(order + 1 - i):
63+
z += coeffs[idx] * (x**i) * (y**j)
64+
idx += 1
65+
return z
66+
67+
68+
def create_polynomial_surface(input_file: str, output_stl: str, output_json: str, order: int = 5) -> None:
69+
"""
70+
Fit the polynomial surface and export STL + JSON model.
71+
72+
Parameters
73+
----------
74+
input_file : str
75+
Path to the .OPT3DMapping file.
76+
output_stl : str
77+
Output STL file path.
78+
output_json : str
79+
Output polynomial model JSON path.
80+
order : int, optional
81+
Polynomial order (default: 5).
82+
"""
83+
with open(input_file, "r") as f:
84+
lines = f.readlines()[1:]
85+
86+
points = [list(map(float, line.strip().split()[:3])) for line in lines if len(line.strip().split()) >= 3]
87+
points = np.array(points)
88+
x_raw, y_raw, z = points[:, 0], points[:, 1], points[:, 2]
89+
90+
x_mean, x_std = x_raw.mean(), x_raw.std()
91+
y_mean, y_std = y_raw.mean(), y_raw.std()
92+
x = (x_raw - x_mean) / x_std
93+
y = (y_raw - y_mean) / y_std
94+
95+
X_design = build_design_matrix(x, y, order)
96+
coeffs, _, _, _ = np.linalg.lstsq(X_design, z, rcond=None)
97+
98+
x_grid_raw = np.linspace(x_raw.min(), x_raw.max(), 100)
99+
y_grid_raw = np.linspace(y_raw.min(), y_raw.max(), 100)
100+
xg_raw, yg_raw = np.meshgrid(x_grid_raw, y_grid_raw)
101+
xg = (xg_raw - x_mean) / x_std
102+
yg = (yg_raw - y_mean) / y_std
103+
zg = evaluate_fitted_surface(coeffs, xg, yg, order)
104+
105+
vertices = np.stack([xg_raw.flatten(), yg_raw.flatten(), zg.flatten()], axis=1)
106+
faces = []
107+
res_x, res_y = xg.shape
108+
for i in range(res_x - 1):
109+
for j in range(res_y - 1):
110+
idx = i * res_y + j
111+
faces.append([idx, idx + 1, idx + res_y])
112+
faces.append([idx + 1, idx + res_y + 1, idx + res_y])
113+
mesh = trimesh.Trimesh(vertices=vertices, faces=np.array(faces))
114+
mesh.export(output_stl)
115+
print(f"[OK] STL exported to: {output_stl}")
116+
117+
model = {
118+
"order": order,
119+
"coeffs": coeffs.tolist(),
120+
"x_mean": float(x_mean),
121+
"x_std": float(x_std),
122+
"y_mean": float(y_mean),
123+
"y_std": float(y_std),
124+
"x_min": float(x_raw.min()),
125+
"x_max": float(x_raw.max()),
126+
"y_min": float(y_raw.min()),
127+
"y_max": float(y_raw.max()),
128+
}
129+
130+
os.makedirs(os.path.dirname(output_json), exist_ok=True)
131+
with open(output_json, "w", encoding="utf-8") as f:
132+
json.dump(model, f, ensure_ascii=False, indent=2)
133+
print(f"[OK] Model JSON exported to: {output_json}")
134+
135+
136+
def main():
137+
"""Standalone entry point."""
138+
139+
base_path = (
140+
r"C:\Users\amarin\OneDrive - ANSYS, Inc\Articules and Trainings ACE"
141+
r"\3D Texture - Light Guide\#2. Variable pitch"
142+
)
143+
144+
input_file = base_path + r"\TL L.3D Texture.2.OPT3DMapping"
145+
146+
output_stl = base_path + r"\FittedSurface_Global_HighQuality.stl"
147+
148+
output_json = base_path + r"\FittedSurface_Model.json"
149+
150+
create_polynomial_surface(
151+
input_file,
152+
output_stl,
153+
output_json,
154+
)
155+
156+
157+
if __name__ == "__main__":
158+
main()
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
"""
5+
Generate a variable-pitch .OPT3DMapping file from a polynomial surface model.
6+
7+
If the JSON model does not exist, this script will try to import and run
8+
`create_polynomial_surface()` from `1_1_create_stl_polynomial_surface.py`.
9+
10+
Everything can also be executed independently (standalone).
11+
"""
12+
13+
import json
14+
import os
15+
from typing import List
16+
from typing import Tuple
17+
18+
import numpy as np
19+
20+
# Try importing the fitting script
21+
try:
22+
from A_1_Create_stl_polynomial_surface import create_polynomial_surface
23+
24+
print("[INFO] Successfully imported create_polynomial_surface from 1_1_create_stl_polynomial_surface.py")
25+
except ModuleNotFoundError as e:
26+
print(f"[ERROR] Could not import Script 1: {e}")
27+
print("Make sure both scripts are in the same folder or that this folder is in sys.path.")
28+
raise
29+
30+
31+
# ============================== CONFIG ============================== #
32+
33+
MODEL_JSON = (
34+
r"C:\Users\amarin\OneDrive - ANSYS, Inc\Articules and Trainings ACE\3D Texture - Light Guide"
35+
r"\#2. Variable pitch\FittedSurface_Model.json"
36+
)
37+
OUTPUT_MAPPING = (
38+
r"C:\Users\amarin\OneDrive - ANSYS, Inc\Articules and Trainings ACE\3D Texture - Light Guide"
39+
r"\#2. Variable pitch\VariablePitch.OPT3DMapping"
40+
)
41+
INPUT_MAPPING = (
42+
r"C:\Users\amarin\OneDrive - ANSYS, Inc\Articules and Trainings ACE\3D Texture - Light Guide"
43+
r"\#2. Variable pitch\TL L.3D Texture.2.OPT3DMapping"
44+
)
45+
OUTPUT_STL = (
46+
r"C:\Users\amarin\OneDrive - ANSYS, Inc\Articules and Trainings ACE\3D Texture - Light Guide"
47+
r"\#2. Variable pitch\FittedSurface_Global_HighQuality.stl"
48+
)
49+
50+
PITCH_X_START = 2.0
51+
PITCH_X_END = 5.0
52+
PITCH_Y = 1.0
53+
INCLUDE_EDGES = True
54+
55+
X_MIN = None
56+
X_MAX = None
57+
Y_MIN = None
58+
Y_MAX = None
59+
60+
EXTRA_CONSTANTS = ["1", "0", "0", "0", "1", "0", "0.5", "0.5", "1"]
61+
FLOAT_FMT = ".6f"
62+
63+
# ==================================================================== #
64+
65+
66+
def linear_pitch_x(x: float, x_min: float, x_max: float, p_start: float, p_end: float) -> float:
67+
"""Linear interpolation of pitch along X."""
68+
if x_max == x_min:
69+
return p_start
70+
t = np.clip((x - x_min) / (x_max - x_min), 0.0, 1.0)
71+
return p_start * (1.0 - t) + p_end * t
72+
73+
74+
def generate_points(
75+
domain: Tuple[float, float, float, float], p_start: float, p_end: float, p_y: float, include_edges: bool = True
76+
) -> Tuple[np.ndarray, np.ndarray]:
77+
"""
78+
Generate (X, Y) grid with variable X pitch and constant Y pitch.
79+
80+
Parameters
81+
----------
82+
domain : tuple
83+
(x_min, x_max, y_min, y_max)
84+
p_start, p_end, p_y : float
85+
Pitch configuration.
86+
include_edges : bool
87+
Whether to force the last row/column at the domain edge.
88+
89+
Returns
90+
-------
91+
X_pts, Y_pts : ndarray
92+
Flattened arrays of all coordinates.
93+
"""
94+
x_min, x_max, y_min, y_max = domain
95+
eps = 1e-12
96+
if p_start <= 0 or p_end <= 0 or p_y <= 0:
97+
raise ValueError("All pitch values must be positive.")
98+
99+
ys = []
100+
y = y_min
101+
while y <= y_max + eps:
102+
ys.append(min(y, y_max))
103+
y += p_y
104+
if include_edges and abs(ys[-1] - y_max) > 1e-9:
105+
ys.append(y_max)
106+
107+
X_list, Y_list = [], []
108+
for yy in ys:
109+
x = x_min
110+
row = []
111+
while x <= x_max + eps:
112+
row.append(min(x, x_max))
113+
x += linear_pitch_x(x, x_min, x_max, p_start, p_end)
114+
if include_edges and abs(row[-1] - x_max) > 1e-9:
115+
row.append(x_max)
116+
X_list.extend(row)
117+
Y_list.extend([yy] * len(row))
118+
return np.asarray(X_list), np.asarray(Y_list)
119+
120+
121+
def eval_poly2d(coeffs: np.ndarray, x_norm: np.ndarray, y_norm: np.ndarray, order: int) -> np.ndarray:
122+
"""Evaluate z = f(x, y) using polynomial coefficients."""
123+
z = np.zeros_like(x_norm)
124+
idx = 0
125+
for i in range(order + 1):
126+
for j in range(order + 1 - i):
127+
z += coeffs[idx] * (x_norm**i) * (y_norm**j)
128+
idx += 1
129+
return z
130+
131+
132+
def write_opt3d_mapping(
133+
path: str,
134+
X: np.ndarray,
135+
Y: np.ndarray,
136+
Z: np.ndarray,
137+
extra_constants: List[str],
138+
float_fmt: str = ".6f",
139+
) -> None:
140+
"""Write the .OPT3DMapping file."""
141+
if len(extra_constants) != 9:
142+
raise ValueError("Exactly 9 extra constants are required.")
143+
n = len(X)
144+
os.makedirs(os.path.dirname(path), exist_ok=True)
145+
ffmt = f"{{:{float_fmt}}}" # noqa: E231
146+
with open(path, "w", encoding="utf-8") as f:
147+
f.write(f"{n}\n")
148+
for x, y, z in zip(X, Y, Z):
149+
formatted_values = " ".join([ffmt.format(x), ffmt.format(y), ffmt.format(z)] + extra_constants)
150+
f.write(formatted_values + "\n")
151+
152+
153+
def ensure_model_json():
154+
"""Ensure the polynomial model JSON exists; generate it if missing."""
155+
if os.path.isfile(MODEL_JSON):
156+
return
157+
print("[INFO] JSON not found. Running Script 1 via direct import...")
158+
create_polynomial_surface(INPUT_MAPPING, OUTPUT_STL, MODEL_JSON)
159+
if not os.path.isfile(MODEL_JSON):
160+
raise FileNotFoundError(f"Model JSON not generated: {MODEL_JSON}")
161+
162+
163+
def main():
164+
"""Main entry point."""
165+
ensure_model_json()
166+
167+
with open(MODEL_JSON, "r", encoding="utf-8") as f:
168+
model = json.load(f)
169+
170+
order = int(model["order"])
171+
coeffs = np.asarray(model["coeffs"], float)
172+
x_mean, x_std = float(model["x_mean"]), float(model["x_std"])
173+
y_mean, y_std = float(model["y_mean"]), float(model["y_std"])
174+
175+
x_min = X_MIN or float(model["x_min"])
176+
x_max = X_MAX or float(model["x_max"])
177+
y_min = Y_MIN or float(model["y_min"])
178+
y_max = Y_MAX or float(model["y_max"])
179+
180+
X_pts, Y_pts = generate_points((x_min, x_max, y_min, y_max), PITCH_X_START, PITCH_X_END, PITCH_Y, INCLUDE_EDGES)
181+
x_norm = (X_pts - x_mean) / x_std
182+
y_norm = (Y_pts - y_mean) / y_std
183+
Z_pts = eval_poly2d(coeffs, x_norm, y_norm, order)
184+
write_opt3d_mapping(OUTPUT_MAPPING, X_pts, Y_pts, Z_pts, EXTRA_CONSTANTS, FLOAT_FMT)
185+
186+
print(f"[OK] Mapping created: {OUTPUT_MAPPING}")
187+
print(f"[INFO] Total points: {len(X_pts)}")
188+
print(f"[INFO] Domain X[{x_min}, {x_max}] Y[{y_min}, {y_max}]")
189+
print(f"[INFO] Pitch X: {PITCH_X_START}{PITCH_X_END} | Pitch Y: {PITCH_Y}")
190+
191+
192+
if __name__ == "__main__":
193+
main()

0 commit comments

Comments
 (0)