CyGuy8 commited on
Commit
f6a0b87
·
1 Parent(s): 5a79264

User friendly scaling factors

Browse files
Files changed (3) hide show
  1. README.md +1 -1
  2. app.py +240 -83
  3. tests/test_app_scaling.py +17 -4
README.md CHANGED
@@ -41,7 +41,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
41
  - Loads bundled sample STL files
42
  - Shows interactive 3D viewers for rotating each model
43
  - Shows model extents, face count, vertex count, and watertight status
44
- - Optionally scales loaded STLs per shape, either by fitting target X/Y/Z dimensions or by applying one uniform scale factor to all axes
45
  - Lets you choose layer height and XY pixel size
46
  - Produces one `.tif` image per slice
47
  - Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
 
41
  - Loads bundled sample STL files
42
  - Shows interactive 3D viewers for rotating each model
43
  - Shows model extents, face count, vertex count, and watertight status
44
+ - Optionally scales loaded STLs per shape, either with independent target X/Y/Z dimensions or by keeping proportions while target dimensions update together
45
  - Lets you choose layer height and XY pixel size
46
  - Produces one `.tif` image per slice
47
  - Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
app.py CHANGED
@@ -41,9 +41,9 @@ ViewerState = dict[str, Any]
41
  SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
42
  SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
43
  DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
44
- DEFAULT_UNIFORM_SCALE = 1.0
45
- SCALE_MODE_TARGET_DIMENSIONS = "Fit X/Y/Z Targets"
46
- SCALE_MODE_UNIFORM_FACTOR = "Uniform Scale Factor"
47
  FRONT_CAMERA = (90, 80, None)
48
  APP_CSS = """
49
  .gradio-container {
@@ -979,21 +979,40 @@ def _resolve_target_extents(
979
  return (target[0], target[1], target[2])
980
 
981
 
982
- def _resolve_uniform_scale(
 
 
 
 
 
 
 
 
983
  scale_to_target: bool | None,
984
- uniform_scale: float | None,
 
 
 
985
  ) -> float | None:
986
  if not scale_to_target:
987
  return None
988
 
989
- if uniform_scale is None:
990
- raise ValueError("Uniform scale factor is required when uniform STL scaling is enabled.")
 
 
 
 
 
 
 
991
 
992
- scale = float(uniform_scale)
993
- if not math.isfinite(scale) or scale <= 0:
994
- raise ValueError("Uniform scale factor must be greater than zero.")
 
995
 
996
- return scale
997
 
998
 
999
  def _normalize_scale_mode(scale_mode: str | None) -> str:
@@ -1020,14 +1039,6 @@ def _shape_target_values(
1020
  )
1021
 
1022
 
1023
- def _shape_uniform_values(
1024
- uniform1: float | None,
1025
- uniform2: float | None,
1026
- uniform3: float | None,
1027
- ) -> tuple[float | None, float | None, float | None]:
1028
- return (uniform1, uniform2, uniform3)
1029
-
1030
-
1031
  def _resolve_mesh_scale_factors(
1032
  mesh: trimesh.Trimesh,
1033
  scale_to_target: bool | None,
@@ -1035,13 +1046,19 @@ def _resolve_mesh_scale_factors(
1035
  target_x: float | None,
1036
  target_y: float | None,
1037
  target_z: float | None,
1038
- uniform_scale: float | None,
1039
  ) -> tuple[float, float, float] | None:
1040
  if not scale_to_target:
1041
  return None
1042
 
1043
  if _normalize_scale_mode(scale_mode) == SCALE_MODE_UNIFORM_FACTOR:
1044
- scale = _resolve_uniform_scale(True, uniform_scale)
 
 
 
 
 
 
 
1045
  return (scale, scale, scale)
1046
 
1047
  target_extents = _resolve_target_extents(True, target_x, target_y, target_z)
@@ -1050,6 +1067,39 @@ def _resolve_mesh_scale_factors(
1050
  return scale_factors_for_target_extents(mesh, target_extents)
1051
 
1052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1053
  def _load_model_mesh(
1054
  stl_file: str | Path,
1055
  scale_to_target: bool | None = False,
@@ -1057,7 +1107,6 @@ def _load_model_mesh(
1057
  target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1058
  target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1059
  target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1060
- uniform_scale: float | None = DEFAULT_UNIFORM_SCALE,
1061
  ) -> tuple[trimesh.Trimesh, tuple[float, float, float]]:
1062
  mesh = load_mesh(stl_file)
1063
  scale_factors = _resolve_mesh_scale_factors(
@@ -1067,7 +1116,6 @@ def _load_model_mesh(
1067
  target_x,
1068
  target_y,
1069
  target_z,
1070
- uniform_scale,
1071
  )
1072
  if scale_factors is None:
1073
  return mesh, (1.0, 1.0, 1.0)
@@ -1078,6 +1126,55 @@ def _viewer_update(model_path: str | None) -> dict[str, Any]:
1078
  return gr.update(value=model_path, camera_position=FRONT_CAMERA)
1079
 
1080
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1081
  def _build_annotated_scene(mesh: trimesh.Trimesh, opacity: float = 1.0) -> str:
1082
  """Export a GLB containing the mesh, origin axes, and a Z=0 grid plane."""
1083
  scene = trimesh.Scene()
@@ -1203,7 +1300,6 @@ def load_single_model(
1203
  target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1204
  target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1205
  target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1206
- uniform_scale: float | None = DEFAULT_UNIFORM_SCALE,
1207
  ) -> tuple[str | None, str]:
1208
  if not stl_file:
1209
  return _viewer_update(None), "No model loaded."
@@ -1214,7 +1310,6 @@ def load_single_model(
1214
  target_x=target_x,
1215
  target_y=target_y,
1216
  target_z=target_z,
1217
- uniform_scale=uniform_scale,
1218
  )
1219
  glb_path = _build_annotated_scene(mesh, opacity=_resolve_model_opacity(opacity))
1220
  return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
@@ -1227,15 +1322,12 @@ def preload_sample_models(
1227
  target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1228
  target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1229
  target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1230
- uniform1: float | None = DEFAULT_UNIFORM_SCALE,
1231
  target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1232
  target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1233
  target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1234
- uniform2: float | None = DEFAULT_UNIFORM_SCALE,
1235
  target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1236
  target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1237
  target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1238
- uniform3: float | None = DEFAULT_UNIFORM_SCALE,
1239
  ) -> tuple:
1240
  outputs: list[Any] = []
1241
  resolved_opacity = _resolve_model_opacity(opacity)
@@ -1250,7 +1342,6 @@ def preload_sample_models(
1250
  target3_y,
1251
  target3_z,
1252
  )
1253
- uniform_values = _shape_uniform_values(uniform1, uniform2, uniform3)
1254
 
1255
  for index, filename in enumerate(SAMPLE_STL_FILENAMES):
1256
  stl_path = SAMPLE_STL_DIR / filename
@@ -1270,7 +1361,6 @@ def preload_sample_models(
1270
  target_x=target_values[index][0],
1271
  target_y=target_values[index][1],
1272
  target_z=target_values[index][2],
1273
- uniform_scale=uniform_values[index],
1274
  )
1275
  except Exception as exc:
1276
  outputs.extend([
@@ -1299,15 +1389,12 @@ def refresh_all_model_viewers(
1299
  target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1300
  target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1301
  target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1302
- uniform1: float | None = DEFAULT_UNIFORM_SCALE,
1303
  target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1304
  target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1305
  target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1306
- uniform2: float | None = DEFAULT_UNIFORM_SCALE,
1307
  target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1308
  target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1309
  target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1310
- uniform3: float | None = DEFAULT_UNIFORM_SCALE,
1311
  ) -> tuple:
1312
  outputs: list[Any] = []
1313
  resolved_opacity = _resolve_model_opacity(opacity)
@@ -1322,9 +1409,8 @@ def refresh_all_model_viewers(
1322
  target3_y,
1323
  target3_z,
1324
  )
1325
- uniform_values = _shape_uniform_values(uniform1, uniform2, uniform3)
1326
 
1327
- for stl_file, values, uniform_scale in zip((stl1, stl2, stl3), target_values, uniform_values):
1328
  if not stl_file:
1329
  outputs.extend([_viewer_update(None), "No model loaded."])
1330
  continue
@@ -1337,7 +1423,6 @@ def refresh_all_model_viewers(
1337
  values[0],
1338
  values[1],
1339
  values[2],
1340
- uniform_scale,
1341
  )
1342
  )
1343
  return tuple(outputs)
@@ -1354,15 +1439,12 @@ def generate_all_stacks(
1354
  target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1355
  target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1356
  target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1357
- uniform1: float | None = DEFAULT_UNIFORM_SCALE,
1358
  target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1359
  target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1360
  target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1361
- uniform2: float | None = DEFAULT_UNIFORM_SCALE,
1362
  target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1363
  target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1364
  target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
1365
- uniform3: float | None = DEFAULT_UNIFORM_SCALE,
1366
  progress: gr.Progress = gr.Progress(),
1367
  ):
1368
  files = [stl1, stl2, stl3]
@@ -1377,12 +1459,11 @@ def generate_all_stacks(
1377
  target3_y,
1378
  target3_z,
1379
  )
1380
- uniform_values = _shape_uniform_values(uniform1, uniform2, uniform3)
1381
  valid_count = max(1, sum(1 for f in files if f))
1382
  results: list = []
1383
  completed = 0
1384
 
1385
- for stl_file, values, uniform_scale in zip(files, target_values, uniform_values):
1386
  if not stl_file:
1387
  results.extend([
1388
  _empty_state(),
@@ -1411,7 +1492,6 @@ def generate_all_stacks(
1411
  values[0],
1412
  values[1],
1413
  values[2],
1414
- uniform_scale,
1415
  )
1416
 
1417
  stack = slice_stl_to_tiffs(
@@ -1930,7 +2010,7 @@ def build_demo() -> gr.Blocks:
1930
  gr.Markdown(
1931
  """
1932
  # STL to TIFF Slicer
1933
- Upload up to three STL files, choose per-shape STL dimensions, layer height, and XY pixel size, then generate TIFF stacks for all uploaded models.
1934
  """
1935
  )
1936
 
@@ -1950,24 +2030,27 @@ def build_demo() -> gr.Blocks:
1950
  )
1951
  with gr.Column(scale=0, min_width=260):
1952
  scale_to_target = gr.Checkbox(
1953
- label="Apply STL Scaling",
1954
  value=False,
1955
  )
1956
- with gr.Column(scale=0, min_width=260):
1957
- scale_mode = gr.Radio(
1958
- choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
1959
- value=SCALE_MODE_TARGET_DIMENSIONS,
1960
- label="Scaling Mode",
1961
- )
 
 
 
1962
 
1963
  # --- Upload + 3D viewer row ---
1964
  stl_files: list[gr.File] = []
1965
  model_viewers: list[gr.Model3D] = []
1966
  model_details_list: list[gr.Markdown] = []
 
1967
  target_xs: list[gr.Number] = []
1968
  target_ys: list[gr.Number] = []
1969
  target_zs: list[gr.Number] = []
1970
- uniform_scales: list[gr.Number] = []
1971
 
1972
  with gr.Row():
1973
  for i in range(3):
@@ -1985,38 +2068,33 @@ def build_demo() -> gr.Blocks:
1985
  height=270,
1986
  )
1987
  model_details = gr.Markdown(f"No model {i + 1} loaded.")
1988
- with gr.Row():
1989
- target_x = gr.Number(
1990
- label="Target X (mm)",
1991
- value=DEFAULT_TARGET_EXTENTS[0],
1992
- minimum=0.0001,
1993
- step=0.1,
1994
- )
1995
- target_y = gr.Number(
1996
- label="Target Y (mm)",
1997
- value=DEFAULT_TARGET_EXTENTS[1],
1998
- minimum=0.0001,
1999
- step=0.1,
2000
- )
2001
- target_z = gr.Number(
2002
- label="Target Z (mm)",
2003
- value=DEFAULT_TARGET_EXTENTS[2],
2004
- minimum=0.0001,
2005
- step=0.1,
2006
- )
2007
- uniform_scale = gr.Number(
2008
- label="Uniform Scale",
2009
- value=DEFAULT_UNIFORM_SCALE,
2010
- minimum=0.0001,
2011
- step=0.01,
2012
- )
2013
  stl_files.append(stl_file)
2014
  model_viewers.append(model_viewer)
2015
  model_details_list.append(model_details)
 
2016
  target_xs.append(target_x)
2017
  target_ys.append(target_y)
2018
  target_zs.append(target_z)
2019
- uniform_scales.append(uniform_scale)
2020
 
2021
  # --- Shared slicing controls ---
2022
  with gr.Row():
@@ -2140,15 +2218,12 @@ def build_demo() -> gr.Blocks:
2140
  target_xs[0],
2141
  target_ys[0],
2142
  target_zs[0],
2143
- uniform_scales[0],
2144
  target_xs[1],
2145
  target_ys[1],
2146
  target_zs[1],
2147
- uniform_scales[1],
2148
  target_xs[2],
2149
  target_ys[2],
2150
  target_zs[2],
2151
- uniform_scales[2],
2152
  ]
2153
 
2154
  for i in range(3):
@@ -2162,7 +2237,6 @@ def build_demo() -> gr.Blocks:
2162
  target_xs[i],
2163
  target_ys[i],
2164
  target_zs[i],
2165
- uniform_scales[i],
2166
  ],
2167
  outputs=[model_viewers[i], model_details_list[i]],
2168
  )
@@ -2186,7 +2260,7 @@ def build_demo() -> gr.Blocks:
2186
  model_details_list[i],
2187
  ])
2188
 
2189
- load_samples_button.click(
2190
  fn=preload_sample_models,
2191
  inputs=[model_opacity, scale_to_target, scale_mode, *all_scale_inputs],
2192
  outputs=preload_outputs,
@@ -2211,7 +2285,90 @@ def build_demo() -> gr.Blocks:
2211
  inputs=refresh_inputs,
2212
  outputs=refresh_outputs,
2213
  )
2214
- for scale_control in (scale_to_target, scale_mode, *all_scale_inputs):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2215
  scale_control.change(
2216
  fn=refresh_all_model_viewers,
2217
  inputs=refresh_inputs,
 
41
  SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
42
  SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
43
  DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
44
+ UNIFORM_TARGET_AXES = ("X", "Y", "Z")
45
+ SCALE_MODE_TARGET_DIMENSIONS = "Independent X/Y/Z"
46
+ SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions"
47
  FRONT_CAMERA = (90, 80, None)
48
  APP_CSS = """
49
  .gradio-container {
 
979
  return (target[0], target[1], target[2])
980
 
981
 
982
+ def _axis_index(axis: str | None) -> int:
983
+ normalized = (axis or "X").upper()
984
+ if normalized not in UNIFORM_TARGET_AXES:
985
+ raise ValueError("Uniform target side must be X, Y, or Z.")
986
+ return UNIFORM_TARGET_AXES.index(normalized)
987
+
988
+
989
+ def _resolve_uniform_scale_from_targets(
990
+ mesh: trimesh.Trimesh,
991
  scale_to_target: bool | None,
992
+ target_x: float | None,
993
+ target_y: float | None,
994
+ target_z: float | None,
995
+ anchor_axis: str | None = "X",
996
  ) -> float | None:
997
  if not scale_to_target:
998
  return None
999
 
1000
+ targets = (target_x, target_y, target_z)
1001
+ anchor_index = _axis_index(anchor_axis)
1002
+ target_size = targets[anchor_index]
1003
+ if target_size is None:
1004
+ raise ValueError("Target side length is required when uniform STL scaling is enabled.")
1005
+
1006
+ target_size = float(target_size)
1007
+ if not math.isfinite(target_size) or target_size <= 0:
1008
+ raise ValueError("Target side length must be greater than zero.")
1009
 
1010
+ current_size = float(mesh.extents[anchor_index])
1011
+ if current_size <= 0:
1012
+ axis = UNIFORM_TARGET_AXES[anchor_index]
1013
+ raise ValueError(f"Cannot scale uniformly from a zero-sized {axis} extent.")
1014
 
1015
+ return target_size / current_size
1016
 
1017
 
1018
  def _normalize_scale_mode(scale_mode: str | None) -> str:
 
1039
  )
1040
 
1041
 
 
 
 
 
 
 
 
 
1042
  def _resolve_mesh_scale_factors(
1043
  mesh: trimesh.Trimesh,
1044
  scale_to_target: bool | None,
 
1046
  target_x: float | None,
1047
  target_y: float | None,
1048
  target_z: float | None,
 
1049
  ) -> tuple[float, float, float] | None:
1050
  if not scale_to_target:
1051
  return None
1052
 
1053
  if _normalize_scale_mode(scale_mode) == SCALE_MODE_UNIFORM_FACTOR:
1054
+ scale = _resolve_uniform_scale_from_targets(
1055
+ mesh,
1056
+ True,
1057
+ target_x,
1058
+ target_y,
1059
+ target_z,
1060
+ anchor_axis="X",
1061
+ )
1062
  return (scale, scale, scale)
1063
 
1064
  target_extents = _resolve_target_extents(True, target_x, target_y, target_z)
 
1067
  return scale_factors_for_target_extents(mesh, target_extents)
1068
 
1069
 
1070
+ def _uniform_target_extents_from_anchor(
1071
+ mesh: trimesh.Trimesh,
1072
+ anchor_axis: str | None,
1073
+ target_x: float | None,
1074
+ target_y: float | None,
1075
+ target_z: float | None,
1076
+ ) -> tuple[float, float, float]:
1077
+ scale = _resolve_uniform_scale_from_targets(
1078
+ mesh,
1079
+ True,
1080
+ target_x,
1081
+ target_y,
1082
+ target_z,
1083
+ anchor_axis=anchor_axis,
1084
+ )
1085
+ extents = np.asarray(mesh.extents, dtype=float)
1086
+ return (
1087
+ float(extents[0] * scale),
1088
+ float(extents[1] * scale),
1089
+ float(extents[2] * scale),
1090
+ )
1091
+
1092
+
1093
+ def _dimension_update(current: float | None, target: float) -> dict[str, Any]:
1094
+ rounded = round(float(target), 6)
1095
+ try:
1096
+ if current is not None and math.isclose(float(current), rounded, rel_tol=1e-9, abs_tol=1e-6):
1097
+ return gr.update()
1098
+ except (TypeError, ValueError):
1099
+ pass
1100
+ return gr.update(value=rounded)
1101
+
1102
+
1103
  def _load_model_mesh(
1104
  stl_file: str | Path,
1105
  scale_to_target: bool | None = False,
 
1107
  target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1108
  target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1109
  target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1110
  ) -> tuple[trimesh.Trimesh, tuple[float, float, float]]:
1111
  mesh = load_mesh(stl_file)
1112
  scale_factors = _resolve_mesh_scale_factors(
 
1116
  target_x,
1117
  target_y,
1118
  target_z,
 
1119
  )
1120
  if scale_factors is None:
1121
  return mesh, (1.0, 1.0, 1.0)
 
1126
  return gr.update(value=model_path, camera_position=FRONT_CAMERA)
1127
 
1128
 
1129
+ def update_scaling_controls_visibility(
1130
+ scale_to_target: bool | None,
1131
+ scale_mode: str | None,
1132
+ ) -> tuple[dict[str, Any], ...]:
1133
+ enabled = bool(scale_to_target)
1134
+
1135
+ return (
1136
+ gr.update(visible=enabled),
1137
+ gr.update(visible=enabled),
1138
+ gr.update(visible=enabled),
1139
+ gr.update(visible=enabled),
1140
+ )
1141
+
1142
+
1143
+ def sync_uniform_target_dimensions(
1144
+ stl_file: str | None,
1145
+ scale_to_target: bool | None,
1146
+ scale_mode: str | None,
1147
+ changed_axis: str,
1148
+ target_x: float | None,
1149
+ target_y: float | None,
1150
+ target_z: float | None,
1151
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
1152
+ if (
1153
+ not stl_file
1154
+ or not scale_to_target
1155
+ or _normalize_scale_mode(scale_mode) != SCALE_MODE_UNIFORM_FACTOR
1156
+ ):
1157
+ return gr.update(), gr.update(), gr.update()
1158
+
1159
+ try:
1160
+ mesh = load_mesh(stl_file)
1161
+ x_value, y_value, z_value = _uniform_target_extents_from_anchor(
1162
+ mesh,
1163
+ changed_axis,
1164
+ target_x,
1165
+ target_y,
1166
+ target_z,
1167
+ )
1168
+ except Exception:
1169
+ return gr.update(), gr.update(), gr.update()
1170
+
1171
+ return (
1172
+ _dimension_update(target_x, x_value),
1173
+ _dimension_update(target_y, y_value),
1174
+ _dimension_update(target_z, z_value),
1175
+ )
1176
+
1177
+
1178
  def _build_annotated_scene(mesh: trimesh.Trimesh, opacity: float = 1.0) -> str:
1179
  """Export a GLB containing the mesh, origin axes, and a Z=0 grid plane."""
1180
  scene = trimesh.Scene()
 
1300
  target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1301
  target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1302
  target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1303
  ) -> tuple[str | None, str]:
1304
  if not stl_file:
1305
  return _viewer_update(None), "No model loaded."
 
1310
  target_x=target_x,
1311
  target_y=target_y,
1312
  target_z=target_z,
 
1313
  )
1314
  glb_path = _build_annotated_scene(mesh, opacity=_resolve_model_opacity(opacity))
1315
  return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
 
1322
  target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1323
  target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1324
  target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1325
  target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1326
  target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1327
  target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1328
  target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1329
  target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1330
  target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1331
  ) -> tuple:
1332
  outputs: list[Any] = []
1333
  resolved_opacity = _resolve_model_opacity(opacity)
 
1342
  target3_y,
1343
  target3_z,
1344
  )
 
1345
 
1346
  for index, filename in enumerate(SAMPLE_STL_FILENAMES):
1347
  stl_path = SAMPLE_STL_DIR / filename
 
1361
  target_x=target_values[index][0],
1362
  target_y=target_values[index][1],
1363
  target_z=target_values[index][2],
 
1364
  )
1365
  except Exception as exc:
1366
  outputs.extend([
 
1389
  target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1390
  target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1391
  target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1392
  target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1393
  target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1394
  target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1395
  target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1396
  target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1397
  target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1398
  ) -> tuple:
1399
  outputs: list[Any] = []
1400
  resolved_opacity = _resolve_model_opacity(opacity)
 
1409
  target3_y,
1410
  target3_z,
1411
  )
 
1412
 
1413
+ for stl_file, values in zip((stl1, stl2, stl3), target_values):
1414
  if not stl_file:
1415
  outputs.extend([_viewer_update(None), "No model loaded."])
1416
  continue
 
1423
  values[0],
1424
  values[1],
1425
  values[2],
 
1426
  )
1427
  )
1428
  return tuple(outputs)
 
1439
  target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1440
  target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1441
  target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1442
  target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1443
  target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1444
  target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1445
  target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
1446
  target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
1447
  target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
 
1448
  progress: gr.Progress = gr.Progress(),
1449
  ):
1450
  files = [stl1, stl2, stl3]
 
1459
  target3_y,
1460
  target3_z,
1461
  )
 
1462
  valid_count = max(1, sum(1 for f in files if f))
1463
  results: list = []
1464
  completed = 0
1465
 
1466
+ for stl_file, values in zip(files, target_values):
1467
  if not stl_file:
1468
  results.extend([
1469
  _empty_state(),
 
1492
  values[0],
1493
  values[1],
1494
  values[2],
 
1495
  )
1496
 
1497
  stack = slice_stl_to_tiffs(
 
2010
  gr.Markdown(
2011
  """
2012
  # STL to TIFF Slicer
2013
+ Upload up to three STL files, optionally scale each shape, choose layer height and XY pixel size, then generate TIFF stacks for all uploaded models.
2014
  """
2015
  )
2016
 
 
2030
  )
2031
  with gr.Column(scale=0, min_width=260):
2032
  scale_to_target = gr.Checkbox(
2033
+ label="Scale STLs",
2034
  value=False,
2035
  )
2036
+
2037
+ with gr.Group(visible=False) as scaling_details_group:
2038
+ with gr.Row():
2039
+ with gr.Column(scale=0, min_width=260):
2040
+ scale_mode = gr.Radio(
2041
+ choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
2042
+ value=SCALE_MODE_TARGET_DIMENSIONS,
2043
+ label="Scaling Mode",
2044
+ )
2045
 
2046
  # --- Upload + 3D viewer row ---
2047
  stl_files: list[gr.File] = []
2048
  model_viewers: list[gr.Model3D] = []
2049
  model_details_list: list[gr.Markdown] = []
2050
+ target_groups: list[gr.Group] = []
2051
  target_xs: list[gr.Number] = []
2052
  target_ys: list[gr.Number] = []
2053
  target_zs: list[gr.Number] = []
 
2054
 
2055
  with gr.Row():
2056
  for i in range(3):
 
2068
  height=270,
2069
  )
2070
  model_details = gr.Markdown(f"No model {i + 1} loaded.")
2071
+ with gr.Group(visible=False) as target_group:
2072
+ with gr.Row():
2073
+ target_x = gr.Number(
2074
+ label="Target X (mm)",
2075
+ value=DEFAULT_TARGET_EXTENTS[0],
2076
+ minimum=0.0001,
2077
+ step=0.1,
2078
+ )
2079
+ target_y = gr.Number(
2080
+ label="Target Y (mm)",
2081
+ value=DEFAULT_TARGET_EXTENTS[1],
2082
+ minimum=0.0001,
2083
+ step=0.1,
2084
+ )
2085
+ target_z = gr.Number(
2086
+ label="Target Z (mm)",
2087
+ value=DEFAULT_TARGET_EXTENTS[2],
2088
+ minimum=0.0001,
2089
+ step=0.1,
2090
+ )
 
 
 
 
 
2091
  stl_files.append(stl_file)
2092
  model_viewers.append(model_viewer)
2093
  model_details_list.append(model_details)
2094
+ target_groups.append(target_group)
2095
  target_xs.append(target_x)
2096
  target_ys.append(target_y)
2097
  target_zs.append(target_z)
 
2098
 
2099
  # --- Shared slicing controls ---
2100
  with gr.Row():
 
2218
  target_xs[0],
2219
  target_ys[0],
2220
  target_zs[0],
 
2221
  target_xs[1],
2222
  target_ys[1],
2223
  target_zs[1],
 
2224
  target_xs[2],
2225
  target_ys[2],
2226
  target_zs[2],
 
2227
  ]
2228
 
2229
  for i in range(3):
 
2237
  target_xs[i],
2238
  target_ys[i],
2239
  target_zs[i],
 
2240
  ],
2241
  outputs=[model_viewers[i], model_details_list[i]],
2242
  )
 
2260
  model_details_list[i],
2261
  ])
2262
 
2263
+ sample_load_event = load_samples_button.click(
2264
  fn=preload_sample_models,
2265
  inputs=[model_opacity, scale_to_target, scale_mode, *all_scale_inputs],
2266
  outputs=preload_outputs,
 
2285
  inputs=refresh_inputs,
2286
  outputs=refresh_outputs,
2287
  )
2288
+
2289
+ visibility_outputs = [scaling_details_group, *target_groups]
2290
+ scale_to_target.change(
2291
+ fn=update_scaling_controls_visibility,
2292
+ inputs=[scale_to_target, scale_mode],
2293
+ outputs=visibility_outputs,
2294
+ queue=False,
2295
+ )
2296
+ scale_mode.change(
2297
+ fn=update_scaling_controls_visibility,
2298
+ inputs=[scale_to_target, scale_mode],
2299
+ outputs=visibility_outputs,
2300
+ queue=False,
2301
+ )
2302
+ for i, stl_file in enumerate(stl_files):
2303
+ target_xs[i].change(
2304
+ fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions(
2305
+ stl, enabled, mode, "X", x, y, z
2306
+ ),
2307
+ inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]],
2308
+ outputs=[target_xs[i], target_ys[i], target_zs[i]],
2309
+ queue=False,
2310
+ ).then(
2311
+ fn=refresh_all_model_viewers,
2312
+ inputs=refresh_inputs,
2313
+ outputs=refresh_outputs,
2314
+ )
2315
+ target_ys[i].change(
2316
+ fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions(
2317
+ stl, enabled, mode, "Y", x, y, z
2318
+ ),
2319
+ inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]],
2320
+ outputs=[target_xs[i], target_ys[i], target_zs[i]],
2321
+ queue=False,
2322
+ ).then(
2323
+ fn=refresh_all_model_viewers,
2324
+ inputs=refresh_inputs,
2325
+ outputs=refresh_outputs,
2326
+ )
2327
+ target_zs[i].change(
2328
+ fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions(
2329
+ stl, enabled, mode, "Z", x, y, z
2330
+ ),
2331
+ inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]],
2332
+ outputs=[target_xs[i], target_ys[i], target_zs[i]],
2333
+ queue=False,
2334
+ ).then(
2335
+ fn=refresh_all_model_viewers,
2336
+ inputs=refresh_inputs,
2337
+ outputs=refresh_outputs,
2338
+ )
2339
+ scale_to_target.change(
2340
+ fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions(
2341
+ stl, enabled, mode, "X", x, y, z
2342
+ ),
2343
+ inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]],
2344
+ outputs=[target_xs[i], target_ys[i], target_zs[i]],
2345
+ queue=False,
2346
+ )
2347
+ scale_mode.change(
2348
+ fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions(
2349
+ stl, enabled, mode, "X", x, y, z
2350
+ ),
2351
+ inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]],
2352
+ outputs=[target_xs[i], target_ys[i], target_zs[i]],
2353
+ queue=False,
2354
+ )
2355
+ stl_file.change(
2356
+ fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions(
2357
+ stl, enabled, mode, "X", x, y, z
2358
+ ),
2359
+ inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]],
2360
+ outputs=[target_xs[i], target_ys[i], target_zs[i]],
2361
+ queue=False,
2362
+ )
2363
+ sample_load_event.then(
2364
+ fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions(
2365
+ stl, enabled, mode, "X", x, y, z
2366
+ ),
2367
+ inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]],
2368
+ outputs=[target_xs[i], target_ys[i], target_zs[i]],
2369
+ queue=False,
2370
+ )
2371
+ for scale_control in (scale_to_target, scale_mode):
2372
  scale_control.change(
2373
  fn=refresh_all_model_viewers,
2374
  inputs=refresh_inputs,
tests/test_app_scaling.py CHANGED
@@ -7,10 +7,11 @@ from app import (
7
  SCALE_MODE_TARGET_DIMENSIONS,
8
  SCALE_MODE_UNIFORM_FACTOR,
9
  _resolve_mesh_scale_factors,
 
10
  )
11
 
12
 
13
- def test_resolve_mesh_scale_factors_uses_uniform_factor_for_all_axes() -> None:
14
  mesh = trimesh.creation.box(extents=(2.0, 4.0, 8.0))
15
 
16
  scale_factors = _resolve_mesh_scale_factors(
@@ -20,10 +21,9 @@ def test_resolve_mesh_scale_factors_uses_uniform_factor_for_all_axes() -> None:
20
  target_x=10.0,
21
  target_y=20.0,
22
  target_z=30.0,
23
- uniform_scale=1.5,
24
  )
25
 
26
- assert scale_factors == (1.5, 1.5, 1.5)
27
 
28
 
29
  def test_resolve_mesh_scale_factors_fits_each_axis_in_target_mode() -> None:
@@ -36,7 +36,20 @@ def test_resolve_mesh_scale_factors_fits_each_axis_in_target_mode() -> None:
36
  target_x=10.0,
37
  target_y=20.0,
38
  target_z=4.0,
39
- uniform_scale=1.5,
40
  )
41
 
42
  np.testing.assert_allclose(scale_factors, (5.0, 5.0, 0.5))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  SCALE_MODE_TARGET_DIMENSIONS,
8
  SCALE_MODE_UNIFORM_FACTOR,
9
  _resolve_mesh_scale_factors,
10
+ _uniform_target_extents_from_anchor,
11
  )
12
 
13
 
14
+ def test_resolve_mesh_scale_factors_uses_x_target_for_uniform_scaling() -> None:
15
  mesh = trimesh.creation.box(extents=(2.0, 4.0, 8.0))
16
 
17
  scale_factors = _resolve_mesh_scale_factors(
 
21
  target_x=10.0,
22
  target_y=20.0,
23
  target_z=30.0,
 
24
  )
25
 
26
+ assert scale_factors == (5.0, 5.0, 5.0)
27
 
28
 
29
  def test_resolve_mesh_scale_factors_fits_each_axis_in_target_mode() -> None:
 
36
  target_x=10.0,
37
  target_y=20.0,
38
  target_z=4.0,
 
39
  )
40
 
41
  np.testing.assert_allclose(scale_factors, (5.0, 5.0, 0.5))
42
+
43
+
44
+ def test_uniform_target_extents_update_from_changed_side() -> None:
45
+ mesh = trimesh.creation.box(extents=(2.0, 4.0, 8.0))
46
+
47
+ target_extents = _uniform_target_extents_from_anchor(
48
+ mesh,
49
+ anchor_axis="Y",
50
+ target_x=10.0,
51
+ target_y=12.0,
52
+ target_z=30.0,
53
+ )
54
+
55
+ np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))