Spaces:
Running
Running
jasondo OpenAI Codex commited on
Commit ·
7a8df56
1
Parent(s): a617d11
Fit deterministic scene geometry
Browse filesCo-authored-by: OpenAI Codex <codex@openai.com>
- AGENTS.md +13 -0
- index.html +131 -20
- 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
|
| 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 |
-
|
| 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
|
| 1223 |
-
geometry = new THREE.CylinderGeometry(radius, radius,
|
|
|
|
| 1224 |
} else if (geometryData.shape === "cone") {
|
| 1225 |
-
|
|
|
|
|
|
|
| 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
|
| 1230 |
-
|
|
|
|
|
|
|
| 1231 |
} else if (geometryData.shape === "rod") {
|
| 1232 |
-
|
| 1233 |
-
geometry.
|
|
|
|
| 1234 |
} else if (geometryData.shape === "gear") {
|
| 1235 |
-
|
|
|
|
|
|
|
| 1236 |
} else if (geometryData.shape === "torus") {
|
| 1237 |
-
const
|
| 1238 |
-
const
|
|
|
|
| 1239 |
geometry = new THREE.TorusGeometry(Math.max(0.05, outerRadius - tube), Math.max(0.025, tube), 16, 64);
|
| 1240 |
-
|
| 1241 |
} else if (geometryData.shape === "spring") {
|
| 1242 |
-
|
|
|
|
|
|
|
| 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.
|
| 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
|