mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 22:34:56 +00:00
The torus primitive was creating "donut" shapes that looked unrealistic. Fixed 7 parts by replacing torus with layered cylinders: - flywheel: Now shows proper disc with bolt holes, no ring gear torus - first-gear through fifth-gear: Tiered cylinders with splined hub - release-bearing: Proper cylindrical bearing stack Added torus detection to validate-geometry.ts: - DONUT SHAPE: Warns when torus tubeR > 15% of body radius - DOMINANT TORUS: Warns when torus radius close to body size All 16 gearbox parts now pass validation with scores 84-162. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
301 lines
9.8 KiB
TypeScript
301 lines
9.8 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
import { readFileSync } from 'fs'
|
|
|
|
const VALID_TYPES = ['box', 'cylinder', 'sphere', 'torus', 'cone', 'extrude', 'revolve']
|
|
const REQUIRED_PROPS: Record<string, string[]> = {
|
|
box: ['width', 'height', 'depth'],
|
|
cylinder: ['r', 'height'],
|
|
sphere: ['r'],
|
|
torus: ['r', 'tubeR'],
|
|
cone: ['r1', 'r2', 'height'],
|
|
}
|
|
|
|
interface Geometry3D {
|
|
type: string
|
|
subtract?: boolean
|
|
intersect?: boolean
|
|
width?: number
|
|
height?: number
|
|
depth?: number
|
|
r?: number
|
|
r1?: number
|
|
r2?: number
|
|
tubeR?: number
|
|
offsetX?: number
|
|
offsetY?: number
|
|
offsetZ?: number
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface ValidationResult {
|
|
errors: string[]
|
|
warnings: string[]
|
|
}
|
|
|
|
function validate(filePath: string): ValidationResult {
|
|
const data = JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
const errors: string[] = []
|
|
const warnings: string[] = []
|
|
|
|
if (!data.geometry3d) {
|
|
errors.push('Missing geometry3d array')
|
|
return { errors, warnings }
|
|
}
|
|
|
|
if (!Array.isArray(data.geometry3d)) {
|
|
errors.push('geometry3d must be an array')
|
|
return { errors, warnings }
|
|
}
|
|
|
|
if (data.geometry3d.length === 0) {
|
|
errors.push('geometry3d array is empty')
|
|
return { errors, warnings }
|
|
}
|
|
|
|
const geoms: Geometry3D[] = data.geometry3d
|
|
|
|
// Per-shape validation
|
|
geoms.forEach((geom: Geometry3D, i: number) => {
|
|
if (!geom.type) {
|
|
errors.push(`[${i}] Missing type property`)
|
|
return
|
|
}
|
|
|
|
if (!VALID_TYPES.includes(geom.type)) {
|
|
errors.push(`[${i}] Invalid type: ${geom.type}`)
|
|
}
|
|
|
|
const required = REQUIRED_PROPS[geom.type] || []
|
|
required.forEach(prop => {
|
|
if (geom[prop] === undefined) {
|
|
errors.push(`[${i}] ${geom.type} missing required prop: ${prop}`)
|
|
}
|
|
})
|
|
|
|
// Validate numeric properties
|
|
const numericProps = ['r', 'r1', 'r2', 'width', 'height', 'depth', 'tubeR', 'offsetX', 'offsetY', 'offsetZ', 'rotateX', 'rotateY', 'rotateZ']
|
|
numericProps.forEach(prop => {
|
|
if (geom[prop] !== undefined && typeof geom[prop] !== 'number') {
|
|
errors.push(`[${i}] ${prop} must be a number, got ${typeof geom[prop]}`)
|
|
}
|
|
})
|
|
|
|
// Validate fill color format (accepts #RGB or #RRGGBB)
|
|
if (geom.fill && typeof geom.fill === 'string' && !geom.fill.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)) {
|
|
errors.push(`[${i}] Invalid fill color format: ${geom.fill} (expected #RGB or #RRGGBB)`)
|
|
}
|
|
|
|
// Detect zero or negative dimensions
|
|
const dimProps = ['width', 'height', 'depth', 'r', 'r1', 'r2', 'tubeR']
|
|
dimProps.forEach(prop => {
|
|
const val = geom[prop] as number | undefined
|
|
if (val !== undefined && val <= 0) {
|
|
errors.push(`[${i}] ${prop} must be positive, got ${val}`)
|
|
}
|
|
})
|
|
})
|
|
|
|
// Structural/semantic validation (bogus shape detection)
|
|
detectBogusPatterns(geoms, errors, warnings)
|
|
|
|
return { errors, warnings }
|
|
}
|
|
|
|
function detectBogusPatterns(geoms: Geometry3D[], errors: string[], warnings: string[]): void {
|
|
// Count shape types
|
|
const unionShapes = geoms.filter(g => !g.subtract && !g.intersect)
|
|
const subtractions = geoms.filter(g => g.subtract)
|
|
const intersections = geoms.filter(g => g.intersect)
|
|
|
|
// Pattern 1: First shape is a subtraction (nothing to subtract from)
|
|
if (geoms.length > 0 && geoms[0].subtract) {
|
|
errors.push('First shape cannot be a subtraction (nothing to subtract from)')
|
|
}
|
|
|
|
// Pattern 2: First shape is an intersection (nothing to intersect with)
|
|
if (geoms.length > 0 && geoms[0].intersect) {
|
|
errors.push('First shape cannot be an intersection (nothing to intersect with)')
|
|
}
|
|
|
|
// Pattern 3: Only subtractions after first shape (breeze block)
|
|
if (unionShapes.length === 1 && subtractions.length > 0 && geoms.length > 2) {
|
|
warnings.push('BREEZE BLOCK: Single base shape with only subtractions - internal cuts are invisible from outside')
|
|
}
|
|
|
|
// Pattern 4: Subtraction larger than base in ALL dimensions (will hollow out completely)
|
|
// Exception: Long thin cylinders are shaft bores (height >> radius is OK)
|
|
if (unionShapes.length >= 1 && subtractions.length > 0) {
|
|
const baseShape = unionShapes[0]
|
|
const baseDims = getShapeDimensions(baseShape)
|
|
const baseMaxDim = Math.max(...baseDims)
|
|
|
|
subtractions.forEach((sub) => {
|
|
const subDims = getShapeDimensions(sub)
|
|
|
|
// Skip shaft bore detection: thin cylinder (r small, height large)
|
|
if (sub.type === 'cylinder' && sub.r && sub.height) {
|
|
const aspectRatio = sub.height / (sub.r * 2)
|
|
if (aspectRatio > 3) {
|
|
// This is a shaft bore - long thin cylinder, skip warning
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check if subtraction is larger than base in all dimensions
|
|
const subLargerInAll = subDims.every((d, i) => d > baseDims[i] * 0.9)
|
|
if (subLargerInAll && !sub.offsetX && !sub.offsetY && !sub.offsetZ) {
|
|
warnings.push(`Subtraction [${geoms.indexOf(sub)}] encompasses base shape - may hollow out completely`)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Pattern 5: No external features (all offsets are zero or on subtractions)
|
|
const externalFeatures = geoms.filter(g =>
|
|
!g.subtract && !g.intersect &&
|
|
(g.offsetX || g.offsetY || g.offsetZ)
|
|
)
|
|
if (externalFeatures.length === 0 && subtractions.length > 0) {
|
|
warnings.push('NO EXTERNAL FEATURES: All non-subtractions are centered - part will look like a basic shape with invisible internal cuts')
|
|
}
|
|
|
|
// Pattern 6: Duplicate shapes (exact same params)
|
|
const shapeSignatures = new Map<string, number[]>()
|
|
geoms.forEach((g, i) => {
|
|
const sig = JSON.stringify(g)
|
|
if (!shapeSignatures.has(sig)) {
|
|
shapeSignatures.set(sig, [])
|
|
}
|
|
shapeSignatures.get(sig)!.push(i)
|
|
})
|
|
shapeSignatures.forEach((indices, sig) => {
|
|
if (indices.length > 1) {
|
|
warnings.push(`Duplicate shapes at indices [${indices.join(', ')}] - may be unintentional`)
|
|
}
|
|
})
|
|
|
|
// Pattern 7: Subtraction completely outside base shape
|
|
if (unionShapes.length >= 1) {
|
|
const baseShape = unionShapes[0]
|
|
const baseBounds = getShapeBounds(baseShape)
|
|
|
|
subtractions.forEach((sub) => {
|
|
const subOffset = Math.sqrt(
|
|
Math.pow(sub.offsetX || 0, 2) +
|
|
Math.pow(sub.offsetY || 0, 2) +
|
|
Math.pow(sub.offsetZ || 0, 2)
|
|
)
|
|
const subBounds = getShapeBounds(sub)
|
|
|
|
// If subtraction is offset beyond base bounds, it does nothing
|
|
if (subOffset > baseBounds.maxDim + subBounds.maxDim / 2) {
|
|
const idx = geoms.indexOf(sub)
|
|
warnings.push(`Subtraction [${idx}] is outside base shape bounds - has no effect`)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Pattern 8: Very thin shapes (likely invisible)
|
|
geoms.forEach((g, i) => {
|
|
if (g.subtract) return // Thin subtractions are fine
|
|
|
|
const dims = getShapeDimensions(g)
|
|
const minDim = Math.min(...dims.filter(d => d > 0))
|
|
const maxDim = Math.max(...dims)
|
|
|
|
if (minDim < 1 && maxDim > 20) {
|
|
warnings.push(`Shape [${i}] has very thin dimension (${minDim}mm) - may be invisible`)
|
|
}
|
|
})
|
|
|
|
// Pattern 9: Dominant torus (donut shape overwhelms part)
|
|
// A torus with tubeR > 10% of main body radius makes the part look like a donut
|
|
const torusShapes = geoms.filter(g => g.type === 'torus' && !g.subtract)
|
|
if (torusShapes.length > 0 && unionShapes.length > 0) {
|
|
const mainBody = unionShapes[0]
|
|
const mainDims = getShapeDimensions(mainBody)
|
|
const mainRadius = Math.max(...mainDims) / 2
|
|
|
|
torusShapes.forEach((torus) => {
|
|
const tubeR = torus.tubeR || 0
|
|
const torusR = torus.r || 0
|
|
|
|
// Warning if torus tube is thick relative to main body
|
|
if (tubeR > mainRadius * 0.15) {
|
|
const idx = geoms.indexOf(torus)
|
|
warnings.push(`DONUT SHAPE: Torus [${idx}] has thick tube (${tubeR}mm) relative to body (${mainRadius}mm radius) - part will look like a donut`)
|
|
}
|
|
|
|
// Warning if torus radius is close to or larger than main body
|
|
if (torusR > mainRadius * 0.9) {
|
|
const idx = geoms.indexOf(torus)
|
|
warnings.push(`DOMINANT TORUS: Torus [${idx}] radius (${torusR}mm) dominates body (${mainRadius}mm) - consider using cylinder rings instead`)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function getShapeBounds(geom: Geometry3D): { maxDim: number } {
|
|
const dims = getShapeDimensions(geom)
|
|
return { maxDim: Math.max(...dims, 1) }
|
|
}
|
|
|
|
function getShapeDimensions(geom: Geometry3D): number[] {
|
|
switch (geom.type) {
|
|
case 'box':
|
|
return [geom.width || 0, geom.height || 0, geom.depth || 0]
|
|
case 'cylinder':
|
|
return [(geom.r || 0) * 2, geom.height || 0, (geom.r || 0) * 2]
|
|
case 'sphere':
|
|
const d = (geom.r || 0) * 2
|
|
return [d, d, d]
|
|
case 'torus':
|
|
const outer = ((geom.r || 0) + (geom.tubeR || 0)) * 2
|
|
return [outer, (geom.tubeR || 0) * 2, outer]
|
|
case 'cone':
|
|
const maxR = Math.max(geom.r1 || 0, geom.r2 || 0) * 2
|
|
return [maxR, geom.height || 0, maxR]
|
|
default:
|
|
return [0, 0, 0]
|
|
}
|
|
}
|
|
|
|
// Main
|
|
const file = process.argv[2]
|
|
const strictMode = process.argv.includes('--strict')
|
|
|
|
if (!file) {
|
|
console.error('Usage: npx tsx validate-geometry.ts <path-to-part.json> [--strict]')
|
|
console.error(' --strict: Treat warnings as errors')
|
|
process.exit(1)
|
|
}
|
|
|
|
try {
|
|
const { errors, warnings } = validate(file)
|
|
|
|
if (errors.length) {
|
|
console.error(`✗ Validation FAILED for ${file}:`)
|
|
errors.forEach(e => console.error(` ✗ ${e}`))
|
|
}
|
|
|
|
if (warnings.length) {
|
|
console.warn(`\n⚠ Warnings for ${file}:`)
|
|
warnings.forEach(w => console.warn(` ⚠ ${w}`))
|
|
}
|
|
|
|
if (errors.length === 0 && warnings.length === 0) {
|
|
console.log(`✓ Valid geometry3d in ${file}`)
|
|
} else if (errors.length === 0) {
|
|
console.log(`\n→ Structurally valid but has ${warnings.length} warning(s)`)
|
|
}
|
|
|
|
// Exit code
|
|
if (errors.length > 0) {
|
|
process.exit(1)
|
|
} else if (strictMode && warnings.length > 0) {
|
|
process.exit(1)
|
|
}
|
|
} catch (err) {
|
|
console.error(`✗ Error reading file: ${(err as Error).message}`)
|
|
process.exit(1)
|
|
}
|