Add files via upload

This commit is contained in:
2026-01-21 19:28:59 +00:00
committed by GitHub
parent 4107649b7e
commit ebcb90141d
33 changed files with 1779 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-dark: #0f0f1a;
--bg-panel: rgba(255,255,255,0.03);
--border: rgba(255,255,255,0.06);
--accent: #00d4ff;
--accent-alt: #00ff88;
--text: #fff;
--text-muted: #888;
--text-dim: #666;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, var(--bg-dark) 0%, #1a1a2e 50%, #0f1a2e 100%);
min-height: 100vh;
color: var(--text);
}
#app {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 15px 0 20px;
}
h1 {
font-size: 2rem;
font-weight: 300;
letter-spacing: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-alt), #7b2cbf);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradientShift 3s ease infinite;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% center; }
50% { background-position: 100% center; }
}
.subtitle { color: var(--text-dim); font-size: 0.85rem; letter-spacing: 1px; }
/* Breadcrumb */
#breadcrumb {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 15px;
background: var(--bg-panel);
border-radius: 8px;
margin-bottom: 15px;
font-size: 0.85rem;
flex-wrap: wrap;
}
#breadcrumb a {
color: var(--accent);
text-decoration: none;
transition: opacity 0.2s;
}
#breadcrumb a:hover { opacity: 0.7; }
#breadcrumb span { color: var(--text-dim); }
#breadcrumb .current { color: var(--text); font-weight: 500; }
/* Controls */
.controls {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 18px 25px;
background: var(--bg-panel);
border-radius: 16px;
border: 1px solid var(--border);
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
}
input[type="range"] {
width: 140px;
height: 4px;
-webkit-appearance: none;
background: linear-gradient(90deg, #333, #444);
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: linear-gradient(135deg, var(--accent), var(--accent-alt));
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
select, button {
padding: 10px 18px;
border: none;
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.3s;
}
select {
background: rgba(40, 40, 60, 0.8);
color: var(--text);
min-width: 170px;
border: 1px solid rgba(255,255,255,0.1);
}
select:hover { border-color: rgba(0, 212, 255, 0.3); }
button {
background: linear-gradient(135deg, rgba(0,212,255,0.2), rgba(123,44,191,0.2));
color: var(--text);
font-weight: 500;
border: 1px solid rgba(0,212,255,0.3);
}
button:hover {
background: linear-gradient(135deg, rgba(0,212,255,0.4), rgba(123,44,191,0.4));
transform: translateY(-1px);
box-shadow: 0 5px 20px rgba(0, 212, 255, 0.2);
}
/* Main layout */
main {
display: grid;
grid-template-columns: 1fr 280px;
gap: 20px;
}
@media (max-width: 1000px) {
main { grid-template-columns: 1fr; }
#sidebar { order: -1; }
}
.diagram-container {
background: #fafafa;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
#diagram { width: 100%; height: auto; display: block; }
/* Sidebar */
#sidebar {
display: flex;
flex-direction: column;
gap: 15px;
}
.panel {
background: var(--bg-panel);
border-radius: 12px;
padding: 15px;
border: 1px solid var(--border);
}
.panel h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--accent);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat {
text-align: center;
padding: 10px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
}
.stat-value { font-size: 1.4rem; font-weight: 600; color: var(--accent); }
.stat-label { font-size: 0.65rem; text-transform: uppercase; color: var(--text-dim); letter-spacing: 1px; }
.parts-list { max-height: 300px; overflow-y: auto; }
.part-item {
padding: 8px 10px;
margin-bottom: 4px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.part-item:hover { background: rgba(0, 212, 255, 0.1); }
.part-item.highlighted { background: rgba(0, 212, 255, 0.2); border: 1px solid rgba(0, 212, 255, 0.3); }
.part-item .name { font-size: 0.8rem; color: #ddd; }
.part-item .pn { font-size: 0.7rem; color: var(--text-dim); font-family: monospace; }
.legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.7rem; color: var(--text-muted); }
.legend-color { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
/* Tooltip */
.tooltip {
position: fixed;
background: rgba(10, 10, 20, 0.95);
padding: 12px 18px;
border-radius: 8px;
pointer-events: none;
z-index: 1000;
display: none;
border: 1px solid rgba(0, 212, 255, 0.3);
backdrop-filter: blur(10px);
max-width: 280px;
}
.tooltip.visible { display: block; }
.tooltip h4 { font-size: 0.95rem; color: var(--accent); margin-bottom: 4px; }
.tooltip .details { font-size: 0.75rem; color: var(--text-muted); line-height: 1.5; }
.tooltip .material {
display: inline-block;
padding: 2px 8px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
margin-top: 6px;
font-size: 0.7rem;
}
/* Scrollbar */
.parts-list::-webkit-scrollbar { width: 4px; }
.parts-list::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 2px; }
.parts-list::-webkit-scrollbar-thumb { background: rgba(0, 212, 255, 0.3); border-radius: 2px; }
/* Package browser */
.package-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.package-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.3s;
}
.package-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 212, 255, 0.1);
}
.package-card h4 { color: var(--text); margin-bottom: 5px; }
.package-card p { font-size: 0.8rem; color: var(--text-muted); }
.package-card .meta { font-size: 0.7rem; color: var(--text-dim); margin-top: 10px; }

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exploded Diagram Viewer</title>
<link rel="stylesheet" href="css/main.css">
</head>
<body>
<div id="app">
<header>
<h1>EXPLODED DIAGRAMS</h1>
<p class="subtitle">Modular Technical Illustrations</p>
</header>
<nav id="breadcrumb"></nav>
<div class="controls" id="controls"></div>
<main>
<div class="diagram-container">
<svg id="diagram" viewBox="0 0 900 750"></svg>
</div>
<aside id="sidebar"></aside>
</main>
<div id="tooltip" class="tooltip"></div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,334 @@
export class DiagramRenderer {
constructor(svgElement, assemblyData, materials) {
this.svg = svgElement;
this.assembly = assemblyData;
this.materials = materials;
this.explosion = 50;
this.rotation = 0;
this.highlightedPart = null;
this.animating = false;
this.gradientIds = {};
}
createDefs() {
let defs = '<defs>';
// Create gradients from materials
Object.entries(this.materials).forEach(([id, mat]) => {
const gradId = `grad-${id}`;
this.gradientIds[id] = gradId;
const g = mat.gradient;
const angle = g.angle || 0;
const rad = angle * Math.PI / 180;
const x2 = Math.round(50 + Math.cos(rad) * 50);
const y2 = Math.round(50 + Math.sin(rad) * 50);
defs += `<linearGradient id="${gradId}" x1="0%" y1="0%" x2="${x2}%" y2="${y2}%">`;
g.stops.forEach(s => {
defs += `<stop offset="${s.offset}%" stop-color="${s.color}"/>`;
});
defs += '</linearGradient>';
});
// Filters
defs += `
<filter id="dropShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="3" stdDeviation="3" flood-opacity="0.2"/>
</filter>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur"/>
<feFlood flood-color="#00d4ff" flood-opacity="0.6"/>
<feComposite in2="blur" operator="in"/>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
`;
defs += '</defs>';
return defs;
}
getFill(geo, partMaterial) {
if (geo.fill) return geo.fill;
const mat = geo.material || partMaterial;
return mat ? `url(#grad-${mat})` : '#888';
}
renderGeometry(geo, cx, cy, partMaterial) {
const ox = geo.offsetX || 0;
const oy = geo.offsetY || 0;
const x = cx + ox;
const y = cy + oy;
const fill = this.getFill(geo, partMaterial);
const opacity = geo.opacity !== undefined ? `opacity="${geo.opacity}"` : '';
switch (geo.type) {
case 'circle':
return `<circle cx="${x}" cy="${y}" r="${geo.r}" fill="${fill}" ${opacity}/>`;
case 'ellipse':
return `<ellipse cx="${x}" cy="${y}" rx="${geo.rx}" ry="${geo.ry}" fill="${fill}" ${opacity}/>`;
case 'rect': {
const rx = geo.rx || 0;
return `<rect x="${x - geo.width/2}" y="${y - geo.height/2}" width="${geo.width}" height="${geo.height}" rx="${rx}" fill="${fill}" ${opacity}/>`;
}
case 'line':
return `<line x1="${x + geo.x1}" y1="${y + geo.y1}" x2="${x + geo.x2}" y2="${y + geo.y2}" stroke="${geo.stroke}" stroke-width="${geo.strokeWidth}"/>`;
case 'polygon': {
const pts = [];
for (let i = 0; i < geo.points.length; i += 2) {
pts.push(`${x + geo.points[i]},${y + geo.points[i+1]}`);
}
const strokeAttr = geo.stroke ? `stroke="${geo.stroke}" stroke-width="${geo.strokeWidth || 1}"` : '';
const fillAttr = geo.fill === 'none' ? 'fill="none"' : `fill="${fill}"`;
return `<polygon points="${pts.join(' ')}" ${fillAttr} ${strokeAttr}/>`;
}
case 'cylinder': {
const { rx, ry, height } = geo;
const topY = y - height/2;
const botY = y + height/2;
return `
<ellipse cx="${x}" cy="${topY}" rx="${rx}" ry="${ry}" fill="${fill}"/>
<rect x="${x - rx}" y="${topY}" width="${rx * 2}" height="${height}" fill="${fill}"/>
<ellipse cx="${x}" cy="${botY}" rx="${rx}" ry="${ry}" fill="${fill}"/>
`;
}
case 'cone': {
const { topRx, topRy, bottomRx, bottomRy, height } = geo;
const topY = y - height/2;
const botY = y + height/2;
return `
<ellipse cx="${x}" cy="${topY}" rx="${topRx}" ry="${topRy}" fill="${fill}"/>
<path d="M${x - topRx},${topY} L${x - bottomRx},${botY} L${x + bottomRx},${botY} L${x + topRx},${topY} Z" fill="${fill}"/>
<ellipse cx="${x}" cy="${botY}" rx="${bottomRx}" ry="${bottomRy}" fill="${fill}"/>
`;
}
case 'coilSpring': {
const { coils, rx, ry, pitch, strokeWidth } = geo;
let svg = '';
for (let i = 0; i < coils; i++) {
const cy2 = y + i * pitch;
svg += `<ellipse cx="${x}" cy="${cy2}" rx="${rx}" ry="${ry}" fill="none" stroke="${fill}" stroke-width="${strokeWidth}"/>`;
}
return svg;
}
case 'gearRing': {
const { teeth, outerRadius, toothHeight } = geo;
let svg = '';
for (let i = 0; i < teeth; i++) {
const angle = (i / teeth) * Math.PI * 2;
const tx = x + Math.cos(angle) * outerRadius;
const ty = y + Math.sin(angle) * (outerRadius * 0.35);
const rot = (i / teeth) * 360;
svg += `<rect x="${tx - 3}" y="${ty - toothHeight/2}" width="6" height="${toothHeight}" fill="${fill}" transform="rotate(${rot}, ${tx}, ${ty})"/>`;
}
return svg;
}
case 'radialRects': {
const { count, radius, width, height, offsetY: oy2 = 0 } = geo;
const rectFill = geo.material ? `url(#grad-${geo.material})` : (geo.fill || fill);
let svg = '';
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const rx2 = x + Math.cos(angle) * radius;
svg += `<rect x="${rx2 - width/2}" y="${y + oy2}" width="${width}" height="${height}" fill="${rectFill}" rx="1"/>`;
}
return svg;
}
case 'radialBlades': {
const { count, radius, width, height, curve = 0, offsetY: oy2 = 0 } = geo;
let svg = '';
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const bx = x + Math.cos(angle) * radius;
const rot = (i / count) * 360 + curve;
svg += `<rect x="${bx - width/2}" y="${y + oy2}" width="${width}" height="${height}" fill="${fill}" rx="2" transform="rotate(${rot}, ${bx}, ${y + oy2 + height/2})"/>`;
}
return svg;
}
case 'text':
return `<text x="${x}" y="${y}" text-anchor="middle" fill="${geo.fill || '#333'}" font-size="${geo.fontSize || 10}" font-family="${geo.fontFamily || 'monospace'}">${geo.content}</text>`;
default:
console.warn(`Unknown geometry type: ${geo.type}`);
return '';
}
}
render() {
const { assembly } = this;
const centerX = 350;
const baseOffset = 90;
const maxExplosion = 70;
const explosionFactor = this.explosion / 100;
let svg = this.createDefs();
// Background
svg += `<rect width="900" height="750" fill="#fafafa"/>`;
// Title
svg += `
<text x="350" y="35" text-anchor="middle" font-family="Arial Black" font-size="18" fill="#1a1a1a">
${assembly.name.toUpperCase()}
</text>
<text x="350" y="52" text-anchor="middle" font-family="Arial" font-size="10" fill="#888">
${assembly.description || ''}
</text>
<line x1="150" y1="65" x2="550" y2="65" stroke="#e0e0e0" stroke-width="1.5"/>
`;
// Axis line
svg += `<line x1="${centerX}" y1="80" x2="${centerX}" y2="720" stroke="#ddd" stroke-width="1" stroke-dasharray="6,3"/>`;
// Render parts
assembly.parts.forEach((part, index) => {
const explosionOffset = index * maxExplosion * explosionFactor;
const baseY = baseOffset + part.baseY * 0.7 + explosionOffset;
const isHighlighted = this.highlightedPart === part.id;
const filter = isHighlighted ? 'url(#glow)' : 'url(#dropShadow)';
const transform = this.rotation !== 0 ? `transform="rotate(${this.rotation}, ${centerX}, ${baseY})"` : '';
svg += `<g class="part" data-part="${part.id}" filter="${filter}" ${transform} style="cursor:pointer">`;
part.geometry.forEach(geo => {
svg += this.renderGeometry(geo, centerX, baseY, part.material);
});
svg += '</g>';
// Leader line and label
const side = index % 2 === 0 ? 1 : -1;
const labelX = centerX + side * 160;
const lineStartX = centerX + side * 50;
svg += `
<line x1="${lineStartX}" y1="${baseY}" x2="${labelX - side * 8}" y2="${baseY}" stroke="#888" stroke-width="0.6"/>
<circle cx="${labelX - side * 8}" cy="${baseY}" r="2.5" fill="#888"/>
<text x="${labelX}" y="${baseY - 3}" text-anchor="${side === 1 ? 'start' : 'end'}" font-family="Arial" font-size="10" font-weight="bold" fill="#333">${part.name}</text>
<text x="${labelX}" y="${baseY + 9}" text-anchor="${side === 1 ? 'start' : 'end'}" font-family="monospace" font-size="8" fill="#777">${part.partNumber}</text>
`;
});
// Footer
const totalWeight = assembly.parts.reduce((sum, p) => sum + (p.weight * p.quantity), 0);
svg += `
<text x="20" y="735" font-family="Arial" font-size="8" fill="#aaa">
${assembly.parts.length} unique parts • ${totalWeight.toFixed(1)}g total weight
</text>
`;
this.svg.innerHTML = svg;
this.attachListeners();
}
attachListeners() {
const tooltip = document.getElementById('tooltip');
this.svg.querySelectorAll('.part').forEach(el => {
const partId = el.dataset.part;
const part = this.assembly.parts.find(p => p.id === partId);
el.addEventListener('mouseenter', (e) => {
this.highlightPart(partId);
if (part) {
const mat = this.materials[part.material];
tooltip.innerHTML = `
<h4>${part.name}</h4>
<div class="details">
<div>${part.partNumber} × ${part.quantity}</div>
<div>${part.weight}g each</div>
<span class="material">${mat?.name || part.material}</span>
</div>
`;
tooltip.classList.add('visible');
}
});
el.addEventListener('mousemove', (e) => {
tooltip.style.left = (e.clientX + 15) + 'px';
tooltip.style.top = (e.clientY - 10) + 'px';
});
el.addEventListener('mouseleave', () => {
this.highlightPart(null);
tooltip.classList.remove('visible');
});
});
}
highlightPart(partId) {
this.highlightedPart = partId;
this.render();
// Also highlight in parts list
document.querySelectorAll('.part-item').forEach(el => {
el.classList.toggle('highlighted', el.dataset.part === partId);
});
}
setExplosion(val) {
this.explosion = val;
this.render();
}
setRotation(val) {
this.rotation = val;
this.render();
}
animate() {
if (this.animating) return;
this.animating = true;
const start = this.explosion;
const target = start < 50 ? 100 : 0;
const duration = 1200;
const startTime = performance.now();
const tick = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
this.explosion = start + (target - start) * eased;
// Update slider
const slider = document.getElementById('explosionSlider');
const label = document.getElementById('explosionValue');
if (slider) slider.value = this.explosion;
if (label) label.textContent = Math.round(this.explosion);
this.render();
if (progress < 1) {
requestAnimationFrame(tick);
} else {
this.animating = false;
}
};
requestAnimationFrame(tick);
}
exportSVG(filename = 'diagram') {
const blob = new Blob([this.svg.outerHTML], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}-exploded.svg`;
a.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,72 @@
export class PackageLoader {
constructor(basePath = 'packages') {
this.basePath = basePath;
this.cache = new Map();
this.materials = null;
}
async fetchJSON(path) {
if (this.cache.has(path)) {
return this.cache.get(path);
}
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load ${path}: ${response.status}`);
}
const data = await response.json();
this.cache.set(path, data);
return data;
}
async getCategories() {
const index = await this.fetchJSON(`${this.basePath}/index.json`);
return index.categories;
}
async getManufacturers(category) {
const index = await this.fetchJSON(`${this.basePath}/${category}/index.json`);
return index.manufacturers;
}
async getProducts(category, manufacturer) {
const index = await this.fetchJSON(`${this.basePath}/${category}/${manufacturer}/index.json`);
return index.products;
}
async loadProductPackage(category, manufacturer, product) {
const pkgPath = `${this.basePath}/${category}/${manufacturer}/${product}`;
return await this.fetchJSON(`${pkgPath}/package.json`);
}
async loadAssembly(category, manufacturer, product, assembly) {
const basePath = `${this.basePath}/${category}/${manufacturer}/${product}/${assembly}`;
// Load assembly manifest
const manifest = await this.fetchJSON(`${basePath}/assembly.json`);
// Load shared materials if not already loaded
if (!this.materials) {
this.materials = await this.fetchJSON(`${this.basePath}/materials.json`);
}
// Load each part
const parts = await Promise.all(
manifest.parts.map(async (partRef) => {
const partData = await this.fetchJSON(`${basePath}/parts/${partRef}.json`);
return partData;
})
);
return {
...manifest,
parts
};
}
async loadPart(category, manufacturer, product, assembly, partId) {
const path = `${this.basePath}/${category}/${manufacturer}/${product}/${assembly}/parts/${partId}.json`;
return await this.fetchJSON(path);
}
}

View File

@@ -0,0 +1,244 @@
export class UIController {
constructor(app) {
this.app = app;
this.controlsEl = document.getElementById('controls');
this.sidebarEl = document.getElementById('sidebar');
this.breadcrumbEl = document.getElementById('breadcrumb');
this.diagramContainer = document.querySelector('.diagram-container');
// Callbacks
this.onExplosionChange = null;
this.onRotationChange = null;
this.onAnimate = null;
this.onExport = null;
this.onPartHover = null;
}
updateBreadcrumb(path) {
const labels = {
'rc': 'RC Vehicles',
'traxxas': 'Traxxas',
'slash-4x4': 'Slash 4x4',
'front-shock': 'Front Shock',
'rear-differential': 'Rear Differential',
'steering-servo': 'Steering Servo'
};
let html = `<a href="#" data-path="">Home</a>`;
path.forEach((segment, i) => {
const subPath = path.slice(0, i + 1).join('/');
const label = labels[segment] || segment.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
html += `<span></span>`;
if (i === path.length - 1) {
html += `<span class="current">${label}</span>`;
} else {
html += `<a href="#${subPath}" data-path="${subPath}">${label}</a>`;
}
});
this.breadcrumbEl.innerHTML = html;
// Attach click handlers
this.breadcrumbEl.querySelectorAll('a').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
const newPath = a.dataset.path ? a.dataset.path.split('/') : [];
this.app.navigate(newPath);
});
});
}
showControls() {
this.controlsEl.innerHTML = `
<div class="control-group">
<label>Explosion: <span id="explosionValue">50</span>%</label>
<input type="range" id="explosionSlider" min="0" max="100" value="50">
</div>
<div class="control-group">
<label>Rotation: <span id="rotationValue">0</span>°</label>
<input type="range" id="rotationSlider" min="-25" max="25" value="0">
</div>
<div class="control-group">
<label>&nbsp;</label>
<button id="animateBtn">▶ Explode</button>
</div>
<div class="control-group">
<label>&nbsp;</label>
<button id="exportBtn">⬇ Export SVG</button>
</div>
`;
// Wire up events
document.getElementById('explosionSlider').addEventListener('input', (e) => {
document.getElementById('explosionValue').textContent = e.target.value;
if (this.onExplosionChange) this.onExplosionChange(parseInt(e.target.value));
});
document.getElementById('rotationSlider').addEventListener('input', (e) => {
document.getElementById('rotationValue').textContent = e.target.value;
if (this.onRotationChange) this.onRotationChange(parseInt(e.target.value));
});
document.getElementById('animateBtn').addEventListener('click', () => {
if (this.onAnimate) this.onAnimate();
});
document.getElementById('exportBtn').addEventListener('click', () => {
if (this.onExport) this.onExport();
});
this.controlsEl.style.display = 'flex';
this.diagramContainer.style.display = 'block';
}
hideControls() {
this.controlsEl.style.display = 'none';
this.diagramContainer.style.display = 'none';
}
showSidebar(assembly) {
const totalParts = assembly.parts.reduce((sum, p) => sum + p.quantity, 0);
const totalWeight = assembly.parts.reduce((sum, p) => sum + (p.weight * p.quantity), 0);
// Get unique materials
const materials = [...new Set(assembly.parts.map(p => p.material))];
this.sidebarEl.innerHTML = `
<div class="panel">
<h3>Assembly Stats</h3>
<div class="stats-grid">
<div class="stat">
<div class="stat-value">${assembly.parts.length}</div>
<div class="stat-label">Unique Parts</div>
</div>
<div class="stat">
<div class="stat-value">${totalWeight < 1000 ? totalWeight.toFixed(0) + 'g' : (totalWeight/1000).toFixed(2) + 'kg'}</div>
<div class="stat-label">Weight</div>
</div>
<div class="stat">
<div class="stat-value">${assembly.category || '-'}</div>
<div class="stat-label">Category</div>
</div>
<div class="stat">
<div class="stat-value">${totalParts}</div>
<div class="stat-label">Total Pieces</div>
</div>
</div>
</div>
<div class="panel">
<h3>Parts List</h3>
<div class="parts-list">
${assembly.parts.map(p => `
<div class="part-item" data-part="${p.id}">
<span class="name">${p.name}</span>
<span class="pn">${p.partNumber}</span>
</div>
`).join('')}
</div>
</div>
<div class="panel">
<h3>Materials</h3>
<div class="legend-grid" id="legend">
${materials.map(m => `
<div class="legend-item">
<div class="legend-color" style="background: ${this.getMaterialColor(m)}"></div>
<span>${this.getMaterialName(m)}</span>
</div>
`).join('')}
</div>
</div>
`;
// Attach hover events to parts list
this.sidebarEl.querySelectorAll('.part-item').forEach(el => {
el.addEventListener('mouseenter', () => {
if (this.onPartHover) this.onPartHover(el.dataset.part);
});
el.addEventListener('mouseleave', () => {
if (this.onPartHover) this.onPartHover(null);
});
});
this.sidebarEl.style.display = 'flex';
}
getMaterialColor(materialId) {
const colors = {
aluminum: '#d0d0d0',
steel: '#6a6a6a',
blueAnodized: '#4a90d9',
redAnodized: '#d94a4a',
blackPlastic: '#2a2a2a',
copper: '#b87333',
titanium: '#8a8a8a',
brass: '#d4a84a',
spring: '#d94a4a',
greenSpring: '#4ad94a'
};
return colors[materialId] || '#888';
}
getMaterialName(materialId) {
const names = {
aluminum: 'Aluminum',
steel: 'Steel',
blueAnodized: 'Anodized (Blue)',
redAnodized: 'Anodized (Red)',
blackPlastic: 'Composite',
copper: 'Copper',
titanium: 'Titanium',
brass: 'Brass',
spring: 'Spring Steel',
greenSpring: 'Spring (Soft)'
};
return names[materialId] || materialId;
}
showPackageGrid(items, title, onClick) {
this.sidebarEl.innerHTML = '';
this.sidebarEl.style.display = 'none';
// Replace diagram with package browser
this.diagramContainer.innerHTML = `
<div style="padding: 30px;">
<h2 style="color: #333; margin-bottom: 20px; font-weight: 400;">${title}</h2>
<div class="package-grid">
${items.map(item => `
<div class="package-card" data-id="${item.id}">
<h4>${item.name}</h4>
<p>${item.description || ''}</p>
${item.meta ? `<div class="meta">${item.meta}</div>` : ''}
</div>
`).join('')}
</div>
</div>
`;
this.diagramContainer.style.display = 'block';
this.diagramContainer.style.background = '#fafafa';
// Attach click handlers
this.diagramContainer.querySelectorAll('.package-card').forEach(card => {
card.addEventListener('click', () => {
const item = items.find(i => i.id === card.dataset.id);
if (item && onClick) onClick(item);
});
});
}
showError(message) {
this.diagramContainer.innerHTML = `
<div style="padding: 50px; text-align: center;">
<h3 style="color: #d94a4a; margin-bottom: 10px;">Error</h3>
<p style="color: #666;">${message}</p>
<button onclick="location.hash=''" style="margin-top: 20px;">Go Home</button>
</div>
`;
this.diagramContainer.style.display = 'block';
}
}

View File

@@ -0,0 +1,135 @@
import { PackageLoader } from './PackageLoader.js';
import { DiagramRenderer } from './DiagramRenderer.js';
import { UIController } from './UIController.js';
class App {
constructor() {
this.loader = new PackageLoader();
this.renderer = null;
this.ui = null;
this.currentPath = [];
}
async init() {
this.ui = new UIController(this);
// Parse URL hash for deep linking
const hash = window.location.hash.slice(1);
if (hash) {
this.currentPath = hash.split('/').filter(Boolean);
}
await this.navigate(this.currentPath);
// Handle browser back/forward
window.addEventListener('hashchange', () => {
const newPath = window.location.hash.slice(1).split('/').filter(Boolean);
this.navigate(newPath);
});
}
async navigate(path) {
this.currentPath = path;
window.location.hash = path.join('/');
if (path.length === 0) {
// Show root - list categories
await this.showCategories();
} else if (path.length === 1) {
// Show manufacturers in category
await this.showManufacturers(path[0]);
} else if (path.length === 2) {
// Show products from manufacturer
await this.showProducts(path[0], path[1]);
} else if (path.length === 3) {
// Show assemblies in product
await this.showAssemblies(path[0], path[1], path[2]);
} else if (path.length >= 4) {
// Load and render assembly
await this.loadAssembly(path[0], path[1], path[2], path[3]);
}
this.ui.updateBreadcrumb(path);
}
async showCategories() {
const categories = await this.loader.getCategories();
this.ui.showPackageGrid(categories, 'Categories', (cat) => {
this.navigate([cat.id]);
});
this.ui.hideControls();
}
async showManufacturers(category) {
const manufacturers = await this.loader.getManufacturers(category);
this.ui.showPackageGrid(manufacturers, 'Manufacturers', (mfr) => {
this.navigate([category, mfr.id]);
});
this.ui.hideControls();
}
async showProducts(category, manufacturer) {
const products = await this.loader.getProducts(category, manufacturer);
this.ui.showPackageGrid(products, 'Products', (prod) => {
this.navigate([category, manufacturer, prod.id]);
});
this.ui.hideControls();
}
async showAssemblies(category, manufacturer, product) {
const pkg = await this.loader.loadProductPackage(category, manufacturer, product);
this.ui.showPackageGrid(pkg.assemblies.map(a => ({
id: a,
name: a.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
description: 'Assembly'
})), 'Assemblies', (asm) => {
this.navigate([category, manufacturer, product, asm.id]);
});
this.ui.hideControls();
}
async loadAssembly(category, manufacturer, product, assembly) {
try {
const data = await this.loader.loadAssembly(category, manufacturer, product, assembly);
this.renderer = new DiagramRenderer(
document.getElementById('diagram'),
data,
this.loader.materials
);
this.ui.showControls();
this.ui.showSidebar(data);
this.renderer.render();
// Wire up UI events
this.ui.onExplosionChange = (val) => {
this.renderer.setExplosion(val);
};
this.ui.onRotationChange = (val) => {
this.renderer.setRotation(val);
};
this.ui.onAnimate = () => {
this.renderer.animate();
};
this.ui.onExport = () => {
this.renderer.exportSVG(assembly);
};
this.ui.onPartHover = (partId) => {
this.renderer.highlightPart(partId);
};
} catch (err) {
console.error('Failed to load assembly:', err);
this.ui.showError(`Failed to load assembly: ${err.message}`);
}
}
}
// Boot
const app = new App();
app.init().catch(console.error);

View File

@@ -0,0 +1,11 @@
{
"version": "1.0.0",
"categories": [
{
"id": "rc",
"name": "RC Vehicles",
"description": "Radio controlled cars, trucks, and buggies",
"meta": "3 manufacturers"
}
]
}

View File

@@ -0,0 +1,97 @@
{
"aluminum": {
"name": "Aluminum",
"gradient": {
"angle": 135,
"stops": [
{ "offset": 0, "color": "#e8e8e8" },
{ "offset": 25, "color": "#d0d0d0" },
{ "offset": 50, "color": "#f5f5f5" },
{ "offset": 75, "color": "#c8c8c8" },
{ "offset": 100, "color": "#a8a8a8" }
]
}
},
"steel": {
"name": "Hardened Steel",
"gradient": {
"angle": 135,
"stops": [
{ "offset": 0, "color": "#6a6a6a" },
{ "offset": 30, "color": "#9a9a9a" },
{ "offset": 50, "color": "#5a5a5a" },
{ "offset": 100, "color": "#4a4a4a" }
]
}
},
"blueAnodized": {
"name": "Anodized Aluminum (Blue)",
"gradient": {
"angle": 135,
"stops": [
{ "offset": 0, "color": "#4a90d9" },
{ "offset": 30, "color": "#6ab0f9" },
{ "offset": 60, "color": "#3a70b9" },
{ "offset": 100, "color": "#2a5090" }
]
}
},
"redAnodized": {
"name": "Anodized Aluminum (Red)",
"gradient": {
"angle": 135,
"stops": [
{ "offset": 0, "color": "#d94a4a" },
{ "offset": 30, "color": "#f96a6a" },
{ "offset": 60, "color": "#b93a3a" },
{ "offset": 100, "color": "#902a2a" }
]
}
},
"blackPlastic": {
"name": "Composite/Nylon",
"gradient": {
"angle": 135,
"stops": [
{ "offset": 0, "color": "#3a3a3a" },
{ "offset": 40, "color": "#2a2a2a" },
{ "offset": 100, "color": "#1a1a1a" }
]
}
},
"copper": {
"name": "Copper",
"gradient": {
"angle": 135,
"stops": [
{ "offset": 0, "color": "#b87333" },
{ "offset": 30, "color": "#da9a62" },
{ "offset": 60, "color": "#a05a23" },
{ "offset": 100, "color": "#804a13" }
]
}
},
"brass": {
"name": "Brass",
"gradient": {
"angle": 135,
"stops": [
{ "offset": 0, "color": "#d4a84a" },
{ "offset": 30, "color": "#e8c878" },
{ "offset": 60, "color": "#b8923a" },
{ "offset": 100, "color": "#987020" }
]
}
},
"spring": {
"name": "Spring Steel (Red)",
"gradient": {
"angle": 0,
"stops": [
{ "offset": 0, "color": "#d94a4a" },
{ "offset": 50, "color": "#ff6a6a" },
{ "offset": 100, "color": "#b93a3a" }
]
}
}
}

View File

@@ -0,0 +1,11 @@
{
"category": "rc",
"manufacturers": [
{
"id": "traxxas",
"name": "Traxxas",
"description": "The Fastest Name in Radio Control",
"meta": "USA • Founded 1986"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"manufacturer": "traxxas",
"products": [
{
"id": "slash-4x4",
"name": "Slash 4x4",
"description": "1/10 Scale 4WD Short Course Truck",
"meta": "Model #68086-4"
}
]
}

View File

@@ -0,0 +1,17 @@
{
"id": "front-shock",
"name": "Front Shock Assembly",
"description": "GTR Long Shock - Oil-filled coilover with adjustable preload",
"category": "Suspension",
"version": "1.0.0",
"parts": [
"shock-cap",
"shaft",
"piston",
"spring",
"retainer",
"body",
"bushing",
"mount"
]
}

View File

@@ -0,0 +1,61 @@
{
"id": "body",
"name": "Shock Body",
"partNumber": "TRA-5461",
"material": "aluminum",
"quantity": 1,
"weight": 45.0,
"weightUnit": "g",
"baseY": 465,
"description": "6061-T6 aluminum shock body with reservoir",
"geometry": [
{
"type": "cylinder",
"rx": 30,
"ry": 10,
"height": 100
},
{
"type": "cylinder",
"rx": 8,
"ry": 3,
"height": 50,
"offsetX": 36,
"offsetY": -35
},
{
"type": "line",
"x1": -28,
"y1": -25,
"x2": 26,
"y2": -25,
"stroke": "#bbb",
"strokeWidth": 0.5
},
{
"type": "line",
"x1": -28,
"y1": 0,
"x2": 26,
"y2": 0,
"stroke": "#bbb",
"strokeWidth": 0.5
},
{
"type": "line",
"x1": -28,
"y1": 25,
"x2": 26,
"y2": 25,
"stroke": "#bbb",
"strokeWidth": 0.5
},
{
"type": "circle",
"r": 4,
"offsetX": 36,
"offsetY": -15,
"fill": "#888"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "bushing",
"name": "Lower Bushing",
"partNumber": "TRA-5468",
"material": "blackPlastic",
"quantity": 2,
"weight": 1.8,
"weightUnit": "g",
"baseY": 600,
"description": "Delrin shock bushing",
"geometry": [
{
"type": "cylinder",
"rx": 20,
"ry": 7,
"height": 20
},
{
"type": "ellipse",
"rx": 8,
"ry": 3,
"fill": "#0a0a0a"
}
]
}

View File

@@ -0,0 +1,43 @@
{
"id": "mount",
"name": "Shock Mount",
"partNumber": "TRA-5460",
"material": "aluminum",
"quantity": 1,
"weight": 15.2,
"weightUnit": "g",
"baseY": 660,
"description": "Aluminum lower shock mount bracket",
"geometry": [
{
"type": "polygon",
"points": [-35, 20, -20, -10, 20, -10, 35, 20]
},
{
"type": "circle",
"r": 8,
"offsetY": 2,
"fill": "#888"
},
{
"type": "circle",
"r": 5,
"offsetY": 2,
"fill": "#555"
},
{
"type": "circle",
"r": 4,
"offsetX": -22,
"offsetY": 15,
"fill": "#666"
},
{
"type": "circle",
"r": 4,
"offsetX": 22,
"offsetY": 15,
"fill": "#666"
}
]
}

View File

@@ -0,0 +1,43 @@
{
"id": "piston",
"name": "Piston",
"partNumber": "TRA-5467",
"material": "blackPlastic",
"quantity": 1,
"weight": 3.1,
"weightUnit": "g",
"baseY": 195,
"description": "PTFE composite piston with 4-hole pattern",
"geometry": [
{
"type": "cylinder",
"rx": 22,
"ry": 7,
"height": 14
},
{
"type": "circle",
"r": 3,
"offsetX": -10,
"fill": "#0a0a0a"
},
{
"type": "circle",
"r": 3,
"offsetX": 10,
"fill": "#0a0a0a"
},
{
"type": "circle",
"r": 3,
"offsetY": -4,
"fill": "#0a0a0a"
},
{
"type": "circle",
"r": 3,
"offsetY": 4,
"fill": "#0a0a0a"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "retainer",
"name": "Spring Retainer",
"partNumber": "TRA-5465",
"material": "blueAnodized",
"quantity": 1,
"weight": 5.4,
"weightUnit": "g",
"baseY": 410,
"description": "Aluminum spring retainer / preload adjuster",
"geometry": [
{
"type": "cylinder",
"rx": 32,
"ry": 10,
"height": 14
},
{
"type": "ellipse",
"rx": 12,
"ry": 4,
"fill": "#1a3a5a"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"id": "shaft",
"name": "Shock Shaft",
"partNumber": "TRA-5464",
"material": "steel",
"quantity": 1,
"weight": 12.5,
"weightUnit": "g",
"baseY": 55,
"description": "Hardened steel shock shaft - TiN coated",
"geometry": [
{
"type": "rect",
"width": 10,
"height": 120,
"rx": 2
},
{
"type": "rect",
"width": 3,
"height": 120,
"offsetX": -3,
"fill": "#aaa",
"opacity": 0.3
}
]
}

View File

@@ -0,0 +1,41 @@
{
"id": "shock-cap",
"name": "Shock Cap",
"partNumber": "TRA-5466",
"material": "blueAnodized",
"quantity": 1,
"weight": 8.2,
"weightUnit": "g",
"baseY": 0,
"description": "Aluminum shock cap with hex adjustment and bleed hole",
"geometry": [
{
"type": "cylinder",
"rx": 28,
"ry": 10,
"height": 30
},
{
"type": "polygon",
"points": [0, -12, 12, -6, 12, 6, 0, 12, -12, 6, -12, -6],
"offsetY": 8,
"stroke": "#2a5090",
"strokeWidth": 1.5,
"fill": "none"
},
{
"type": "circle",
"r": 5,
"fill": "#1a3a5a"
},
{
"type": "ellipse",
"rx": 6,
"ry": 3,
"offsetX": 8,
"offsetY": -10,
"fill": "#7ac0f9",
"opacity": 0.4
}
]
}

View File

@@ -0,0 +1,21 @@
{
"id": "spring",
"name": "Shock Spring",
"partNumber": "TRA-5442R",
"material": "spring",
"quantity": 1,
"weight": 22.0,
"weightUnit": "g",
"baseY": 250,
"description": "Progressive rate spring - Red (Firm)",
"geometry": [
{
"type": "coilSpring",
"coils": 8,
"rx": 26,
"ry": 8,
"pitch": 18,
"strokeWidth": 7
}
]
}

View File

@@ -0,0 +1,14 @@
{
"product": "slash-4x4",
"name": "Traxxas Slash 4x4",
"description": "1/10 Scale 4WD Electric Short Course Truck",
"model": "68086-4",
"manufacturer": "Traxxas",
"category": "Short Course Truck",
"scale": "1:10",
"assemblies": [
"front-shock",
"rear-differential",
"steering-servo"
]
}

View File

@@ -0,0 +1,15 @@
{
"id": "rear-differential",
"name": "Rear Differential Assembly",
"description": "Sealed gear differential with metal internals",
"category": "Drivetrain",
"version": "1.0.0",
"parts": [
"case-top",
"ring-gear",
"spider-gears",
"outdrives",
"bearings",
"case-bottom"
]
}

View File

@@ -0,0 +1,17 @@
{
"id": "bearings",
"name": "Diff Bearings",
"partNumber": "TRA-5118",
"material": "steel",
"quantity": 2,
"weight": 4.5,
"weightUnit": "g",
"baseY": 350,
"description": "10x15x4mm rubber sealed ball bearings",
"geometry": [
{ "type": "cylinder", "rx": 15, "ry": 5, "height": 10, "offsetX": -45 },
{ "type": "ellipse", "rx": 8, "ry": 3, "offsetX": -45, "fill": "#4a4a4a" },
{ "type": "cylinder", "rx": 15, "ry": 5, "height": 10, "offsetX": 45 },
{ "type": "ellipse", "rx": 8, "ry": 3, "offsetX": 45, "fill": "#4a4a4a" }
]
}

View File

@@ -0,0 +1,18 @@
{
"id": "case-bottom",
"name": "Diff Case (Bottom)",
"partNumber": "TRA-5381B",
"material": "blackPlastic",
"quantity": 1,
"weight": 32.0,
"weightUnit": "g",
"baseY": 430,
"description": "Glass-filled nylon differential case half with output shaft bore",
"geometry": [
{ "type": "cylinder", "rx": 55, "ry": 20, "height": 25 },
{ "type": "ellipse", "rx": 25, "ry": 9, "fill": "#1a1a1a" },
{ "type": "circle", "r": 5, "offsetX": -35, "fill": "#2a2a2a" },
{ "type": "circle", "r": 5, "offsetX": 35, "fill": "#2a2a2a" },
{ "type": "circle", "r": 8, "offsetY": 22, "fill": "#2a2a2a" }
]
}

View File

@@ -0,0 +1,17 @@
{
"id": "case-top",
"name": "Diff Case (Top)",
"partNumber": "TRA-5381",
"material": "blackPlastic",
"quantity": 1,
"weight": 28.5,
"weightUnit": "g",
"baseY": 0,
"description": "Glass-filled nylon differential case half",
"geometry": [
{ "type": "cylinder", "rx": 55, "ry": 20, "height": 15 },
{ "type": "ellipse", "rx": 25, "ry": 9, "fill": "#1a1a1a" },
{ "type": "circle", "r": 5, "offsetX": -35, "fill": "#2a2a2a" },
{ "type": "circle", "r": 5, "offsetX": 35, "fill": "#2a2a2a" }
]
}

View File

@@ -0,0 +1,19 @@
{
"id": "outdrives",
"name": "Outdrives",
"partNumber": "TRA-5380",
"material": "steel",
"quantity": 2,
"weight": 18.0,
"weightUnit": "g",
"baseY": 270,
"description": "Hardened steel differential outdrives",
"geometry": [
{ "type": "rect", "width": 35, "height": 12, "offsetX": -32 },
{ "type": "ellipse", "rx": 6, "ry": 11, "offsetX": -15 },
{ "type": "rect", "width": 15, "height": 8, "offsetX": -57 },
{ "type": "rect", "width": 35, "height": 12, "offsetX": 32 },
{ "type": "ellipse", "rx": 6, "ry": 11, "offsetX": 15 },
{ "type": "rect", "width": 15, "height": 8, "offsetX": 57 }
]
}

View File

@@ -0,0 +1,16 @@
{
"id": "ring-gear",
"name": "Ring Gear",
"partNumber": "TRA-5379",
"material": "steel",
"quantity": 1,
"weight": 52.0,
"weightUnit": "g",
"baseY": 80,
"description": "Hardened steel ring gear - 43T",
"geometry": [
{ "type": "gearRing", "teeth": 24, "outerRadius": 48, "toothHeight": 8 },
{ "type": "cylinder", "rx": 45, "ry": 16, "height": 30 },
{ "type": "ellipse", "rx": 25, "ry": 9, "fill": "#4a4a4a" }
]
}

View File

@@ -0,0 +1,18 @@
{
"id": "spider-gears",
"name": "Spider Gears",
"partNumber": "TRA-5382",
"material": "steel",
"quantity": 4,
"weight": 8.0,
"weightUnit": "g",
"baseY": 175,
"description": "Hardened steel spider gear set with cross pin",
"geometry": [
{ "type": "cylinder", "rx": 18, "ry": 6, "height": 15, "offsetX": -30 },
{ "type": "circle", "r": 5, "offsetX": -30, "fill": "#3a3a3a" },
{ "type": "cylinder", "rx": 18, "ry": 6, "height": 15, "offsetX": 30 },
{ "type": "circle", "r": 5, "offsetX": 30, "fill": "#3a3a3a" },
{ "type": "rect", "width": 16, "height": 25, "rx": 2, "offsetY": -5 }
]
}

View File

@@ -0,0 +1,14 @@
{
"id": "steering-servo",
"name": "Steering Servo Assembly",
"description": "High-torque digital servo with metal gear train",
"category": "Electronics",
"version": "1.0.0",
"parts": [
"servo-horn",
"horn-screw",
"output-gear",
"servo-body",
"servo-mount"
]
}

View File

@@ -0,0 +1,16 @@
{
"id": "horn-screw",
"name": "Horn Screw",
"partNumber": "TRA-2548",
"material": "steel",
"quantity": 1,
"weight": 0.8,
"weightUnit": "g",
"baseY": 50,
"description": "M3 servo horn retaining screw",
"geometry": [
{ "type": "circle", "r": 8, "offsetY": -8 },
{ "type": "rect", "width": 6, "height": 25 },
{ "type": "line", "x1": -4, "y1": -8, "x2": 4, "y2": -8, "stroke": "#444", "strokeWidth": 1.5 }
]
}

View File

@@ -0,0 +1,16 @@
{
"id": "output-gear",
"name": "Output Gear",
"partNumber": "TRA-2074",
"material": "brass",
"quantity": 1,
"weight": 6.2,
"weightUnit": "g",
"baseY": 105,
"description": "Brass output gear with 25T spline",
"geometry": [
{ "type": "gearRing", "teeth": 20, "outerRadius": 18, "toothHeight": 4 },
{ "type": "cylinder", "rx": 16, "ry": 5, "height": 15 },
{ "type": "ellipse", "rx": 6, "ry": 2, "fill": "#806020" }
]
}

View File

@@ -0,0 +1,23 @@
{
"id": "servo-body",
"name": "Servo Body",
"partNumber": "TRA-2075X",
"material": "blackPlastic",
"quantity": 1,
"weight": 55.0,
"weightUnit": "g",
"baseY": 165,
"description": "Digital high-torque servo - 255oz-in",
"geometry": [
{ "type": "rect", "width": 60, "height": 50, "rx": 3 },
{ "type": "rect", "width": 80, "height": 8, "offsetY": -10 },
{ "type": "circle", "r": 3, "offsetX": -35, "offsetY": -10, "fill": "#1a1a1a" },
{ "type": "circle", "r": 3, "offsetX": 35, "offsetY": -10, "fill": "#1a1a1a" },
{ "type": "rect", "width": 40, "height": 12, "offsetY": -18, "fill": "#2a2a2a" },
{ "type": "text", "content": "2075X", "offsetY": -15, "fill": "#00d4ff", "fontSize": 7 },
{ "type": "circle", "r": 8, "offsetY": -30, "material": "steel" },
{ "type": "rect", "width": 12, "height": 20, "offsetX": 21, "offsetY": 10, "fill": "#333" },
{ "type": "rect", "width": 3, "height": 14, "offsetX": 17, "offsetY": 10, "fill": "#d94a4a" },
{ "type": "rect", "width": 3, "height": 14, "offsetX": 25, "offsetY": 10, "fill": "#d4a84a" }
]
}

View File

@@ -0,0 +1,18 @@
{
"id": "servo-horn",
"name": "Servo Horn",
"partNumber": "TRA-2072",
"material": "blueAnodized",
"quantity": 1,
"weight": 4.5,
"weightUnit": "g",
"baseY": 0,
"description": "25T spline aluminum servo horn",
"geometry": [
{ "type": "ellipse", "rx": 12, "ry": 8 },
{ "type": "rect", "width": 100, "height": 10, "rx": 5, "offsetY": -4 },
{ "type": "circle", "r": 4, "offsetX": -40, "fill": "#1a3a5a" },
{ "type": "circle", "r": 4, "offsetX": 40, "fill": "#1a3a5a" },
{ "type": "circle", "r": 6, "fill": "#1a3a5a" }
]
}

View File

@@ -0,0 +1,20 @@
{
"id": "servo-mount",
"name": "Servo Mount",
"partNumber": "TRA-2073",
"material": "aluminum",
"quantity": 1,
"weight": 18.0,
"weightUnit": "g",
"baseY": 280,
"description": "Aluminum servo mounting bracket",
"geometry": [
{ "type": "rect", "width": 100, "height": 25, "rx": 3 },
{ "type": "rect", "width": 70, "height": 55, "rx": 2, "offsetY": 15 },
{ "type": "rect", "width": 56, "height": 35, "offsetY": 20, "fill": "#aaa" },
{ "type": "circle", "r": 4, "offsetX": -42, "fill": "#888" },
{ "type": "circle", "r": 4, "offsetX": 42, "fill": "#888" },
{ "type": "circle", "r": 4, "offsetX": -42, "offsetY": 38, "fill": "#888" },
{ "type": "circle", "r": 4, "offsetX": 42, "offsetY": 38, "fill": "#888" }
]
}