jasondo OpenAI Codex commited on
Commit
7a8df56
·
1 Parent(s): a617d11

Fit deterministic scene geometry

Browse files

Co-authored-by: OpenAI Codex <codex@openai.com>

Files changed (3) hide show
  1. AGENTS.md +13 -0
  2. index.html +131 -20
  3. snap2sim/prompts.py +4 -0
AGENTS.md CHANGED
@@ -334,6 +334,19 @@ technical cutaway animation.
334
  returned a real Modal analysis for `cutaway mechanism` at `0.7` confidence
335
  with 3 parts, and `/generate_scene` returned `renderer: three`,
336
  `render_mode: three`, and no HTML field.
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
  ## Next Work
339
 
 
334
  returned a real Modal analysis for `cutaway mechanism` at `0.7` confidence
335
  with 3 parts, and `/generate_scene` returned `renderer: three`,
336
  `render_mode: three`, and no HTML field.
337
+ - Implemented the `REVIEW3.md` deterministic renderer pass on June 15, 2026:
338
+ axial and disk-like primitives now infer their axis from `geometry.size`,
339
+ radius comes from the cross-section pair, explicit `geometry.rotation`
340
+ remains additive, the Three.js assembly recenters before reveal animation,
341
+ camera/OrbitControls fit is derived from the bounding sphere and viewport
342
+ aspect, the grid moves to the recentered assembly bottom, and label
343
+ projection uses world positions. Prompt wording now tells the model that the
344
+ renderer infers primitive orientation from the odd-one-out size dimension.
345
+ - Local verification after the `REVIEW3.md` pass: prompt/schema imports,
346
+ schema validation, parser/coercion validation, FastAPI `TestClient` checks
347
+ in `INFERENCE_BACKEND=local`, browser verification for `MODEL_OUTPUT.json`
348
+ and `EXAMPLE_ANALYSIS`, desktop/mobile nonblank screenshots, mobile
349
+ no-overflow measurement, orbit drag, and Reset view all passed.
350
 
351
  ## Next Work
352
 
index.html CHANGED
@@ -922,8 +922,9 @@
922
  scene.fog = new THREE.Fog(0x0f1318, 8, 20);
923
 
924
  const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
 
925
  const defaultCameraPosition = new THREE.Vector3(5.4, 3.9, 6.2);
926
- const defaultTarget = new THREE.Vector3(0, 0.1, 0);
927
  camera.position.copy(defaultCameraPosition);
928
 
929
  const controls = window.THREE.OrbitControls
@@ -931,8 +932,6 @@
931
  : null;
932
  if (controls) {
933
  controls.enableDamping = true;
934
- controls.minDistance = 2.4;
935
- controls.maxDistance = 12;
936
  controls.maxPolarAngle = Math.PI * 0.48;
937
  controls.target.copy(defaultTarget);
938
  if (window.THREE.TOUCH) {
@@ -955,6 +954,8 @@
955
  grid.material.opacity = 0.75;
956
  scene.add(grid);
957
 
 
 
958
  const meshes = (analysis.parts || []).slice(0, 6).map((part, index) => {
959
  const mesh = buildPartMesh(part, index);
960
  const position = part.geometry && part.geometry.position || [0, 0, 0];
@@ -969,10 +970,13 @@
969
  ? part.motion.pivot
970
  : [0, 0, 0];
971
  mesh.userData.pivot = new THREE.Vector3(pivot[0], pivot[1], pivot[2]);
972
- mesh.scale.setScalar(0.001);
973
- scene.add(mesh);
974
  return mesh;
975
  });
 
 
 
 
976
 
977
  const clock = new THREE.Clock();
978
  let frameId = 0;
@@ -983,6 +987,8 @@
983
  camera.position.copy(defaultCameraPosition);
984
  if (controls) {
985
  controls.target.copy(defaultTarget);
 
 
986
  controls.update();
987
  }
988
  },
@@ -996,8 +1002,8 @@
996
  function resize() {
997
  const rect = viewport.getBoundingClientRect();
998
  camera.aspect = Math.max(1, rect.width) / Math.max(1, rect.height);
999
- camera.updateProjectionMatrix();
1000
  renderer.setSize(Math.max(1, rect.width), Math.max(1, rect.height));
 
1001
  }
1002
 
1003
  function animate() {
@@ -1211,6 +1217,50 @@
1211
  sourceCard.hidden = false;
1212
  }
1213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1214
  function buildPartMesh(part, index) {
1215
  const geometryData = part.geometry || {};
1216
  const size = Array.isArray(geometryData.size) ? geometryData.size : [1, 1, 1];
@@ -1219,27 +1269,38 @@
1219
  const z = Math.max(0.05, Number(size[2]) || 1);
1220
  let geometry;
1221
  if (geometryData.shape === "cylinder") {
1222
- const radius = Math.max(x, z) / 2;
1223
- geometry = new THREE.CylinderGeometry(radius, radius, y, 48);
 
1224
  } else if (geometryData.shape === "cone") {
1225
- geometry = new THREE.ConeGeometry(Math.max(x, z) / 2, y, 48);
 
 
1226
  } else if (geometryData.shape === "sphere") {
1227
  geometry = new THREE.SphereGeometry(Math.max(x, y, z) / 2, 36, 18);
1228
  } else if (geometryData.shape === "capsule") {
1229
- const radius = Math.min(Math.max(x, z) / 2, y / 2);
1230
- geometry = new THREE.CapsuleGeometry(radius, Math.max(0.05, y - radius * 2), 8, 24);
 
 
1231
  } else if (geometryData.shape === "rod") {
1232
- geometry = new THREE.CylinderGeometry(Math.max(x, y) / 2, Math.max(x, y) / 2, z, 24);
1233
- geometry.rotateX(Math.PI / 2);
 
1234
  } else if (geometryData.shape === "gear") {
1235
- geometry = gearGeometry(Math.max(x, z) / 2, y, geometryData.teeth || 18);
 
 
1236
  } else if (geometryData.shape === "torus") {
1237
- const outerRadius = Math.max(x, z) / 2;
1238
- const tube = Math.min(y / 2, outerRadius * 0.36);
 
1239
  geometry = new THREE.TorusGeometry(Math.max(0.05, outerRadius - tube), Math.max(0.025, tube), 16, 64);
1240
- geometry.rotateX(Math.PI / 2);
1241
  } else if (geometryData.shape === "spring") {
1242
- geometry = springGeometry(Math.max(x, z) / 2, y, geometryData.coils || 5, geometryData.wire);
 
 
1243
  } else {
1244
  geometry = new THREE.BoxGeometry(x, y, z);
1245
  }
@@ -1255,6 +1316,57 @@
1255
  return new THREE.Mesh(geometry, material);
1256
  }
1257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1258
  function gearGeometry(radius, depth, teeth) {
1259
  const shape = new THREE.Shape();
1260
  const steps = teeth * 2;
@@ -1271,7 +1383,6 @@
1271
  shape.holes.push(hole);
1272
  const geometry = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
1273
  geometry.center();
1274
- geometry.rotateX(Math.PI / 2);
1275
  return geometry;
1276
  }
1277
 
@@ -1338,7 +1449,7 @@
1338
  const rect = viewport.getBoundingClientRect();
1339
  labelLayer.replaceChildren();
1340
  for (const mesh of meshes) {
1341
- const projected = mesh.position.clone().project(camera);
1342
  if (projected.z < -1 || projected.z > 1) continue;
1343
  const label = document.createElement("div");
1344
  label.className = "scene-label";
 
922
  scene.fog = new THREE.Fog(0x0f1318, 8, 20);
923
 
924
  const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
925
+ const defaultViewDirection = new THREE.Vector3(5.4, 3.9, 6.2).normalize();
926
  const defaultCameraPosition = new THREE.Vector3(5.4, 3.9, 6.2);
927
+ const defaultTarget = new THREE.Vector3(0, 0, 0);
928
  camera.position.copy(defaultCameraPosition);
929
 
930
  const controls = window.THREE.OrbitControls
 
932
  : null;
933
  if (controls) {
934
  controls.enableDamping = true;
 
 
935
  controls.maxPolarAngle = Math.PI * 0.48;
936
  controls.target.copy(defaultTarget);
937
  if (window.THREE.TOUCH) {
 
954
  grid.material.opacity = 0.75;
955
  scene.add(grid);
956
 
957
+ const partGroup = new THREE.Group();
958
+ scene.add(partGroup);
959
  const meshes = (analysis.parts || []).slice(0, 6).map((part, index) => {
960
  const mesh = buildPartMesh(part, index);
961
  const position = part.geometry && part.geometry.position || [0, 0, 0];
 
970
  ? part.motion.pivot
971
  : [0, 0, 0];
972
  mesh.userData.pivot = new THREE.Vector3(pivot[0], pivot[1], pivot[2]);
973
+ partGroup.add(mesh);
 
974
  return mesh;
975
  });
976
+ const fitBounds = fitAssembly(partGroup, grid);
977
+ for (const mesh of meshes) {
978
+ mesh.scale.setScalar(0.001);
979
+ }
980
 
981
  const clock = new THREE.Clock();
982
  let frameId = 0;
 
987
  camera.position.copy(defaultCameraPosition);
988
  if (controls) {
989
  controls.target.copy(defaultTarget);
990
+ controls.minDistance = Math.max(0.05, fitBounds.radius * 0.6);
991
+ controls.maxDistance = Math.max(controls.minDistance * 2, fitBounds.radius * 6);
992
  controls.update();
993
  }
994
  },
 
1002
  function resize() {
1003
  const rect = viewport.getBoundingClientRect();
1004
  camera.aspect = Math.max(1, rect.width) / Math.max(1, rect.height);
 
1005
  renderer.setSize(Math.max(1, rect.width), Math.max(1, rect.height));
1006
+ fitCameraToBounds(camera, controls, fitBounds, defaultViewDirection, defaultCameraPosition, defaultTarget);
1007
  }
1008
 
1009
  function animate() {
 
1217
  sourceCard.hidden = false;
1218
  }
1219
 
1220
+ function fitAssembly(partGroup, grid) {
1221
+ partGroup.updateMatrixWorld(true);
1222
+ const box = new THREE.Box3().setFromObject(partGroup);
1223
+ if (box.isEmpty()) {
1224
+ return { radius: 1, target: new THREE.Vector3(0, 0, 0) };
1225
+ }
1226
+ const center = box.getCenter(new THREE.Vector3());
1227
+ partGroup.position.sub(center);
1228
+ partGroup.updateMatrixWorld(true);
1229
+
1230
+ const recenteredBox = new THREE.Box3().setFromObject(partGroup);
1231
+ const sphere = recenteredBox.getBoundingSphere(new THREE.Sphere());
1232
+ const radius = Math.max(0.35, sphere.radius);
1233
+ if (Number.isFinite(recenteredBox.min.y)) {
1234
+ grid.position.y = recenteredBox.min.y;
1235
+ }
1236
+ return { radius, target: new THREE.Vector3(0, 0, 0) };
1237
+ }
1238
+
1239
+ function fitCameraToBounds(camera, controls, bounds, viewDirection, defaultCameraPosition, defaultTarget) {
1240
+ const target = bounds.target || new THREE.Vector3(0, 0, 0);
1241
+ const radius = Math.max(0.35, bounds.radius || 1);
1242
+ const verticalFov = THREE.MathUtils.degToRad(camera.fov);
1243
+ const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(0.1, camera.aspect));
1244
+ const verticalDistance = radius / Math.sin(verticalFov / 2);
1245
+ const horizontalDistance = radius / Math.sin(horizontalFov / 2);
1246
+ const distance = Math.max(verticalDistance, horizontalDistance) * 1.35;
1247
+
1248
+ defaultTarget.copy(target);
1249
+ defaultCameraPosition.copy(target).add(viewDirection.clone().multiplyScalar(distance));
1250
+ camera.position.copy(defaultCameraPosition);
1251
+ camera.near = Math.max(0.01, distance / 100);
1252
+ camera.far = Math.max(100, distance * 100);
1253
+ camera.updateProjectionMatrix();
1254
+
1255
+ if (controls) {
1256
+ controls.target.copy(defaultTarget);
1257
+ controls.minDistance = Math.max(0.05, radius * 0.6);
1258
+ controls.maxDistance = Math.max(controls.minDistance * 2, radius * 6);
1259
+ controls.update();
1260
+ controls.saveState();
1261
+ }
1262
+ }
1263
+
1264
  function buildPartMesh(part, index) {
1265
  const geometryData = part.geometry || {};
1266
  const size = Array.isArray(geometryData.size) ? geometryData.size : [1, 1, 1];
 
1269
  const z = Math.max(0.05, Number(size[2]) || 1);
1270
  let geometry;
1271
  if (geometryData.shape === "cylinder") {
1272
+ const profile = axisProfile([x, y, z], "max");
1273
+ geometry = new THREE.CylinderGeometry(profile.radius, profile.radius, profile.length, 48);
1274
+ orientYAxisGeometry(geometry, profile.axis);
1275
  } else if (geometryData.shape === "cone") {
1276
+ const profile = axisProfile([x, y, z], "max");
1277
+ geometry = new THREE.ConeGeometry(profile.radius, profile.length, 48);
1278
+ orientYAxisGeometry(geometry, profile.axis);
1279
  } else if (geometryData.shape === "sphere") {
1280
  geometry = new THREE.SphereGeometry(Math.max(x, y, z) / 2, 36, 18);
1281
  } else if (geometryData.shape === "capsule") {
1282
+ const profile = axisProfile([x, y, z], "max");
1283
+ const radius = Math.min(profile.radius, profile.length / 2);
1284
+ geometry = new THREE.CapsuleGeometry(radius, Math.max(0.05, profile.length - radius * 2), 8, 24);
1285
+ orientYAxisGeometry(geometry, profile.axis);
1286
  } else if (geometryData.shape === "rod") {
1287
+ const profile = axisProfile([x, y, z], "max");
1288
+ geometry = new THREE.CylinderGeometry(profile.radius, profile.radius, profile.length, 24);
1289
+ orientYAxisGeometry(geometry, profile.axis);
1290
  } else if (geometryData.shape === "gear") {
1291
+ const profile = axisProfile([x, y, z], "min");
1292
+ geometry = gearGeometry(profile.radius, profile.length, geometryData.teeth || 18);
1293
+ orientZAxisGeometry(geometry, profile.axis);
1294
  } else if (geometryData.shape === "torus") {
1295
+ const profile = axisProfile([x, y, z], "min");
1296
+ const outerRadius = profile.radius;
1297
+ const tube = Math.min(profile.length / 2, outerRadius * 0.36);
1298
  geometry = new THREE.TorusGeometry(Math.max(0.05, outerRadius - tube), Math.max(0.025, tube), 16, 64);
1299
+ orientZAxisGeometry(geometry, profile.axis);
1300
  } else if (geometryData.shape === "spring") {
1301
+ const profile = axisProfile([x, y, z], "max");
1302
+ geometry = springGeometry(profile.radius, profile.length, geometryData.coils || 5, geometryData.wire);
1303
+ orientYAxisGeometry(geometry, profile.axis);
1304
  } else {
1305
  geometry = new THREE.BoxGeometry(x, y, z);
1306
  }
 
1316
  return new THREE.Mesh(geometry, material);
1317
  }
1318
 
1319
+ function axisProfile(extents, fallback) {
1320
+ const values = extents.map((value) => Math.max(0.05, Number(value) || 1));
1321
+ const maxValue = Math.max(...values);
1322
+ const minValue = Math.min(...values);
1323
+ let axis = 1;
1324
+ if (!dimensionsAreClose(maxValue, minValue)) {
1325
+ const pairs = [
1326
+ { axis: 2, diff: relativeDifference(values[0], values[1]) },
1327
+ { axis: 1, diff: relativeDifference(values[0], values[2]) },
1328
+ { axis: 0, diff: relativeDifference(values[1], values[2]) }
1329
+ ].sort((left, right) => left.diff - right.diff);
1330
+ if (pairs[0].diff <= 0.2) {
1331
+ axis = pairs[0].axis;
1332
+ } else if (fallback === "min") {
1333
+ axis = values.indexOf(minValue);
1334
+ } else if (fallback === "max") {
1335
+ axis = values.indexOf(maxValue);
1336
+ }
1337
+ }
1338
+ const crossSection = values.filter((_, index) => index !== axis);
1339
+ return {
1340
+ axis,
1341
+ length: values[axis],
1342
+ radius: Math.max(0.025, (crossSection[0] + crossSection[1]) / 4)
1343
+ };
1344
+ }
1345
+
1346
+ function dimensionsAreClose(left, right) {
1347
+ return relativeDifference(left, right) <= 0.2;
1348
+ }
1349
+
1350
+ function relativeDifference(left, right) {
1351
+ return Math.abs(left - right) / Math.max(0.05, Math.max(Math.abs(left), Math.abs(right)));
1352
+ }
1353
+
1354
+ function orientYAxisGeometry(geometry, axis) {
1355
+ if (axis === 0) {
1356
+ geometry.rotateZ(-Math.PI / 2);
1357
+ } else if (axis === 2) {
1358
+ geometry.rotateX(Math.PI / 2);
1359
+ }
1360
+ }
1361
+
1362
+ function orientZAxisGeometry(geometry, axis) {
1363
+ if (axis === 0) {
1364
+ geometry.rotateY(Math.PI / 2);
1365
+ } else if (axis === 1) {
1366
+ geometry.rotateX(-Math.PI / 2);
1367
+ }
1368
+ }
1369
+
1370
  function gearGeometry(radius, depth, teeth) {
1371
  const shape = new THREE.Shape();
1372
  const steps = teeth * 2;
 
1383
  shape.holes.push(hole);
1384
  const geometry = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
1385
  geometry.center();
 
1386
  return geometry;
1387
  }
1388
 
 
1449
  const rect = viewport.getBoundingClientRect();
1450
  labelLayer.replaceChildren();
1451
  for (const mesh of meshes) {
1452
+ const projected = mesh.getWorldPosition(new THREE.Vector3()).project(camera);
1453
  if (projected.z < -1 || projected.z > 1) continue;
1454
  const label = document.createElement("div");
1455
  label.className = "scene-label";
snap2sim/prompts.py CHANGED
@@ -38,6 +38,10 @@ Motions, with axis as a numeric vector like [0, 1, 0]:
38
  Every geometry must use size: [x, y, z] and position: [x, y, z]. Do not use
39
  radius, height, length, width, or depth fields. Every motion axis must be a
40
  numeric vector such as [0, 1, 0], never a string like x, y, or z.
 
 
 
 
41
 
42
  Use 2 to 6 parts; prefer the fewest that explain the mechanism. Keep names and
43
  descriptions short. When possible, include annotation.point in normalized image
 
38
  Every geometry must use size: [x, y, z] and position: [x, y, z]. Do not use
39
  radius, height, length, width, or depth fields. Every motion axis must be a
40
  numeric vector such as [0, 1, 0], never a string like x, y, or z.
41
+ For axial or disk-like shapes, express the main axis or disk normal through the
42
+ size vector: one dimension should differ from the other two. The renderer uses
43
+ that odd-one-out dimension for the primitive axis and treats rotation as a
44
+ fine-tuning offset only.
45
 
46
  Use 2 to 6 parts; prefer the fewest that explain the mechanism. Keep names and
47
  descriptions short. When possible, include annotation.point in normalized image