| name | cabinet-configurator |
| description | Cabinet Editor web application development skill. Use when working on Cabinet Editor codebase - a 2D/3D furniture configurator built with Canvas API and Three.js. Covers architecture (Panel and Drawer classes, connections system, virtual panels), coordinate systems, panel/drawer movement logic, cabinet dimension changes (width/height/depth/base), 3D rendering with rank-based depth, ribs system, and drawer box calculations. Essential for debugging, adding features, or understanding how panels/drawers interact with cabinet sizing. |
Cabinet Configurator Skill
Development guide for Cabinet Editor - a web-based furniture configurator with 2D Canvas editing and 3D Three.js visualization.
Project Setup
Location: C:\Users\admin\Desktop\OMS\cabinet-editor
CRITICAL: Always use filesystem MCP tools for file operations
- Use
filesystem:read_text_file,filesystem:edit_file,filesystem:write_file - NEVER use bash/powershell commands like
cat,sed,type, etc. for reading/editing files - Reason: Prevents encoding issues (UTF-8 vs CP1251/CP866), handles line endings correctly, provides proper error handling
- The filesystem tools have access to the project directory and handle Windows paths correctly
Architecture Overview
Core Classes
App (js/App.js)
- Main application controller
- Manages
this.cabinet(width, height, depth, base) - Stores
this.panelsMap of Panel instances - Handles interaction (dragging, mode selection)
- Manages history (undo/redo)
Panel (js/Panel.js)
- Represents shelves and dividers
- Properties:
type: 'shelf' | 'divider'id: unique identifierposition: { x?, y? } - center point (one coordinate per panel)bounds: { startX, endX } for shelves, { startY, endY } for dividersconnections: { left?, right?, top?, bottom? } - references to adjacent Panel objectsribs: array of rib objects (shelves only)isHorizontal: true for shelves, false for dividers
Drawer (js/Drawer.js)
- Represents pull-out drawer boxes
- Properties:
type: 'drawer'id: unique identifierconnections: { bottomShelf, topShelf, leftDivider, rightDivider } - Panel/virtual panel referencesvolume: calculated 3D bounding box { x: {start, end}, y: {start, end}, z: {start, end} }boxLength: selected standard box size (270, 350, 450, 550mm)parts: calculated drawer components (front, leftSide, rightSide, back, bottom)
Viewer3D (js/Viewer3D.js)
- Three.js 3D visualization
- Manages scene, camera, renderer, controls
- Builds cabinet structure and panel meshes
Panel System
Shelves (horizontal panels)
position.y: Y coordinate of shelf centerbounds.startX,bounds.endX: left and right edgesconnections.left/right: dividers that bound the shelf horizontallyconnections.top/bottom: used by dividers that terminate at this shelf
Dividers (vertical panels)
position.x: X coordinate of divider centerbounds.startY,bounds.endY: bottom and top edgesconnections.bottom/top: shelves that bound the divider verticallyconnections.left/right: used by shelves that terminate at this divider
Virtual Panels Drawers can connect to "virtual panels" representing cabinet boundaries:
type: 'left'or'right': virtual side panels at cabinet edgestype: 'bottom'or'top': virtual horizontal panels at cabinet base/roof- Virtual panels don't exist in
app.panelsMap but behave like real panels for drawer connections - Enable drawers to span full width/height of cabinet
Coordinate System
Canvas coordinates (2D):
- Origin (0,0) at bottom-left
- X increases right (0 to cabinet.width)
- Y increases up (0 to cabinet.height)
- Cabinet structure:
- Left side: x = CONFIG.DSP/2 (8mm)
- Right side: x = cabinet.width - CONFIG.DSP/2
- Bottom (plinth top): y = cabinet.base
- Top (roof bottom): y = cabinet.height - CONFIG.DSP
3D coordinates:
- X/Y match 2D canvas
- Z is depth: 0 (back) to cabinet.depth (front)
- Panels recede based on
rank: depth = (cabinet.depth - 3) - rank
Key measurements:
CONFIG.DSP: 16mm (panel thickness for ДСП)CONFIG.HDF: 3mm (back panel thickness for ХДФ)CONFIG.MIN_GAP: 150mm (minimum spacing between panels)CONFIG.MIN_SIZE: 200mm (minimum panel dimension)cabinet.base: plinth height (min 60mm)cabinet.width: total cabinet width (400-3000mm)cabinet.height: total cabinet heightcabinet.depth: total cabinet depth (300-800mm, adjustable in 1mm increments)
Movement Logic
Movable Cabinet Boundaries
Cabinet boundaries (sides, bottom, roof) can be moved in "move" mode by detecting them in findPanelAt():
Left/Right Sides (moveSide method):
- Changes
cabinet.width - Left side: shifts all panels right when expanding
- Right side: only changes width
- Limits: MIN_CABINET_WIDTH (400mm) to MAX_CABINET_WIDTH (3000mm)
- Cannot pass through dividers (MIN_GAP spacing)
Bottom (moveHorizontalSide with isBottom):
- Changes
cabinet.base(plinth height, min 60mm) - Dividers WITHOUT
connections.bottom:bounds.startY = cabinet.base(stretch/shrink) - Dividers WITH
connections.bottom: no change (stay with shelf) - Shelves: remain at absolute Y coordinates (do not move)
- Updates
position.yfor affected dividers
Roof (moveHorizontalSide with !isBottom):
- Changes
cabinet.height(total height) - Cannot pass through shelves (stops at highest shelf + MIN_GAP)
- Dividers WITHOUT
connections.top: stretchbounds.endYto new height - Updates
position.yfor stretched dividers
Panel Movement
Shelves:
- Move vertically (change
position.y) bounds.startX/endXdetermined byconnections.left/right- Connected dividers update their
bounds.startYorbounds.endY
Dividers:
- Move horizontally (change
position.x) bounds.startY/endYdetermined byconnections.bottom/top- Connected shelves update their
bounds.startXorbounds.endX
Important: After changing bounds, always update position:
// For dividers
panel.position.y = (panel.bounds.startY + panel.bounds.endY) / 2;
// For shelves
panel.position.x = (panel.bounds.startX + panel.bounds.endX) / 2;
Update Pattern
When moving panels that affect others:
- Update the moved panel's position/bounds
- Call
updateConnectedPanels(movedPanel)to update connected panels - Update ribs:
panel.updateRibs(this.panels, this.cabinet.width)for affected shelves - Call
updateMesh(this, panel)for 3D updates - Call
render2D(this)andrenderAll3D(this)to redraw
When moving cabinet boundaries:
- Update
cabinet.width/height/base - Update affected panel bounds and positions
- Call
updateCalc()to recalculate derived dimensions - Update ribs for all shelves
- Call
updateCanvas()if canvas scaling changed - Call
viewer3D.rebuildCabinet()to rebuild 3D structure - Call
render2D(this)andrenderAll3D(this)
3D Rendering
Rank System
Panels have a rank that determines their Z-depth (recess from front):
calculatePanelRank(panel) {
// Fixed ranks
if (panel.type === 'back') return -1; // ХДФ back
if (panel.type === 'left' || panel.type === 'right') return 0; // Sides
if (panel.type === 'bottom' || panel.type === 'top') return 1; // Floor/ceiling
// Dynamic rank = max(parent ranks) + 1
let maxRank = 0;
for (let parent of Object.values(panel.connections)) {
if (parent?.type) {
maxRank = Math.max(maxRank, this.calculatePanelRank(parent));
}
}
return maxRank + 1;
}
3D depth calculation:
const rank = app.calculatePanelRank(panel);
const depth = (cabinet.depth - 3) - rank; // Recess from front
Cabinet Structure (3D)
Built by Viewer3D.rebuildCabinet():
- Left/right sides: 16mm thick, full height, depth - 3mm
- Bottom/top: between sides, 16mm thick, depth - 4mm
- Back (ХДФ): 3mm thick, behind everything
- Front/back plinth: 16mm thick, below base height
Drawer System
Drawers are pull-out boxes defined by 4 boundary panels (real or virtual).
Drawer Structure
Connections:
bottomShelf: lower boundary (shelf or virtual 'bottom')topShelf: upper boundary (shelf or virtual 'top')leftDivider: left boundary (divider or virtual 'left')rightDivider: right boundary (divider or virtual 'right')
Volume Calculation (calculateVolume):
// Find minimum depth among connected panels (based on rank)
const depths = [
(cabinet.depth - CONFIG.HDF) - calculatePanelRank(bottomShelf),
(cabinet.depth - CONFIG.HDF) - calculatePanelRank(topShelf),
(cabinet.depth - CONFIG.HDF) - calculatePanelRank(leftDivider),
(cabinet.depth - CONFIG.HDF) - calculatePanelRank(rightDivider)
];
const minDepth = Math.min(...depths);
// For virtual panels, use cabinet dimensions directly
const leftEdge = leftDivider.type === 'left'
? CONFIG.DSP
: leftDivider.position.x + CONFIG.DSP;
const volume = {
x: { start: leftEdge, end: rightEdge },
y: { start: bottomEdge, end: topEdge },
z: { start: CONFIG.DSP, end: minDepth - 2 } // 16mm offset from back for rib + 2mm clearance from panel
};
Box Length Selection:
- Standard sizes: 270, 350, 450, 550mm (from
CONFIG.DRAWER.SIZES) - Selected based on available depth:
volDepth + CONFIG.DSP - If no suitable size, drawer creation fails
Parts Calculation (calculateParts):
Drawer consists of 5 components with precise dimensions and Z-coordinates:
Front panel (facade)
- Width:
volWidth - 4mm(2mm gaps on sides) - Height:
volHeight - 30mm(26mm gap on top, 4mm on bottom) - Depth: 16mm (CONFIG.DSP)
- Position Z:
frontZ = vol.z.end
- Width:
Left/Right sides
- Height:
volHeight - 56mm - Depth:
boxLength - 26mm - Thickness: 16mm
- Z range:
[sidesZ1, sidesZ2]wheresidesZ2 = frontZ - 16
- Height:
Back panel
- Width:
volWidth - 42mm - Height:
volHeight - 68mm - Positioned at:
backZ = sidesZ1 + CONFIG.DRAWER.BACK_OFFSET
- Width:
Bottom panel
- Width:
volWidth - 42mm - Depth:
boxLength - 44mm - Z range:
[bottomZ1, bottomZ2]starting atsidesZ1 + 16 + BOTTOM_OFFSET
- Width:
Drawer Config Constants (from CONFIG.DRAWER):
DRAWER: {
SIZES: [270, 320, 370, 420, 470, 520, 570, 620], // Стандартные длины коробов (8 размеров)
MIN_WIDTH: 150, // Минимальная ширина ящика (как MIN_GAP)
MAX_WIDTH: 1200, // Максимальная ширина ящика
MIN_HEIGHT: 80, // Минимальная высота ящика
MAX_HEIGHT: 400, // Максимальная высота ящика
GAP_FRONT: 2, // Зазоры фасада
GAP_TOP: 28,
GAP_BOTTOM: 2,
SIDE_OFFSET_X: 5, // Отступы боковин
SIDE_OFFSET_Y: 17,
INNER_OFFSET: 21, // Отступ задней стенки/дна
BACK_OFFSET: 2,
BOTTOM_OFFSET: 2
}
Drawer Lifecycle
Adding a drawer (addDrawer):
- Find 4 boundary panels at click position (or use virtual panels)
- Create Drawer instance with connections
- Call
drawer.calculateParts(app)- returns false if volume too small - Add to
app.drawersMap - Call
updateDrawerMeshes(app, drawer)for 3D - Save history and render
Updating drawers: Drawers must be recalculated when connected panels move or cabinet dimensions change:
// After panel movement
for (let drawer of app.drawers.values()) {
if (drawer.connections includes movedPanel) {
drawer.updateParts(app);
updateDrawerMeshes(app, drawer);
}
}
// After cabinet dimension change (width/height/depth/base)
for (let drawer of app.drawers.values()) {
drawer.updateParts(app);
updateDrawerMeshes(app, drawer);
}
Deleting drawers:
- Delete when any connected panel is deleted
- Can be deleted individually in delete mode
- Use
removeDrawerMeshes(app, drawer)before removing from Map
Mirroring: When mirroring cabinet content:
// Swap left/right divider connections
const tempLeft = drawer.connections.leftDivider;
drawer.connections.leftDivider = drawer.connections.rightDivider;
drawer.connections.rightDivider = tempLeft;
// Update virtual panel types if present
if (leftDivider?.type === 'right') leftDivider.type = 'left';
if (rightDivider?.type === 'left') rightDivider.type = 'right';
drawer.updateParts(app);
3D Rendering
Drawer meshes (from render3D.js):
- Each drawer creates 5 separate meshes (front, sides, back, bottom)
- Stored in
app.mesh3Dwith keys:${drawer.id}-front,${drawer.id}-leftSide, etc. - Material: orange color (
0xff9800) to distinguish from panels - Box geometry with precise dimensions from
drawer.parts
Update pattern:
import { updateDrawerMeshes, removeDrawerMeshes } from './modules/render3D.js';
// After drawer modification
updateDrawerMeshes(app, drawer); // Removes old meshes, creates new ones
// Before deletion
removeDrawerMeshes(app, drawer); // Cleans up all 5 meshes
Global export (for HTML inline scripts):
// In main.js
import { updateDrawerMeshes } from './modules/render3D.js';
window.updateDrawerMeshes = updateDrawerMeshes;
Ribs System
Ribs (ребра жесткости) are vertical supports under shelves, preventing sagging.
When added:
- Shelves longer than threshold need ribs
- Thresholds: 800mm (no ribs), 1000mm (1 rib), 1200mm (2 ribs)
Calculation (Panel.updateRibs()):
updateRibs(allPanels, cabinetWidth) {
const shelfWidth = this.bounds.endX - this.bounds.startX;
// Find dividers that cross this shelf
const crossingDividers = Array.from(allPanels.values())
.filter(p => !p.isHorizontal &&
p.bounds.startY <= this.position.y &&
p.bounds.endY >= this.position.y &&
p.position.x > this.bounds.startX &&
p.position.x < this.bounds.endX)
.map(p => p.position.x)
.sort((a, b) => a - b);
// Calculate segments between dividers
const points = [
this.bounds.startX,
...crossingDividers,
this.bounds.endX
];
// Add ribs to segments that need them
this.ribs = [];
for (let i = 0; i < points.length - 1; i++) {
const segmentStart = points[i] + (crossingDividers.includes(points[i]) ? CONFIG.DSP : 0);
const segmentEnd = points[i + 1];
const segmentWidth = segmentEnd - segmentStart;
const ribsNeeded = calculateRibsForSegment(segmentWidth);
if (ribsNeeded > 0) {
// Distribute ribs evenly in segment
for (let j = 0; j < ribsNeeded; j++) {
const ribX = segmentStart + (segmentWidth / (ribsNeeded + 1)) * (j + 1);
this.ribs.push({ startX: ribX, endX: ribX + CONFIG.DSP });
}
}
}
}
3D rendering:
- Ribs are 16mm wide, 100mm tall
- Positioned below shelf:
y = shelf.position.y - 100 - Same depth as shelf (based on rank)
Common Patterns
For detailed code examples, see references/examples.md.
Adding a new panel
- Create Panel instance with type, id, position, bounds, connections
- Add to
app.panelsMap - Call
panel.updateRibs()if shelf - Call
app.saveHistory() - Call
render2D(app)andrenderAll3D(app)
Deleting a panel
- Find dependent panels via
connections - Call
removeMesh(app, panel)for each - Remove from
app.panels - Recalculate bounds for affected panels
- Update ribs for remaining shelves
- Call
app.saveHistory() - Call
render2D(app)andrenderAll3D(app)
Changing cabinet dimensions
- Update
app.cabinet.width/height/depth/base - Update panel bounds that depend on cabinet size
- Call
app.updateCalc() - Recalculate ALL drawers (they depend on cabinet dimensions via virtual panels)
- If width/height changed:
app.updateCanvas() - Rebuild 3D:
app.viewer3D.rebuildCabinet() - Update all panel meshes or call
renderAll3D(app)
Adding a drawer
- Click in drawer mode to select area
- Find 4 boundary panels (use virtual panels for cabinet edges)
- Create Drawer instance:
new Drawer(id, connections) - Calculate parts:
drawer.calculateParts(app)- check return value - Add to
app.drawersMap - Call
updateDrawerMeshes(app, drawer)for 3D - Save history and render
File Structure
cabinet-editor/
├── index.html - UI structure, bottom sheets, event handlers
├── css/
│ └── main.css - Styling
├── js/
│ ├── main.js - Entry point, app initialization, global exports
│ ├── App.js - Main application class
│ ├── Panel.js - Panel class
│ ├── Drawer.js - Drawer class
│ ├── Viewer3D.js - 3D viewer class
│ ├── config.js - Constants and configuration
│ ├── exportJSON.js - Export functionality
│ └── modules/
│ ├── render2D.js - Canvas 2D rendering
│ ├── render3D.js - Three.js mesh management
│ ├── historyLogging.js - History UI
│ └── historyDebug.js - History debugging tools
Debugging Tips
Check panel state:
// In browser console
window.app.panels.forEach(p => console.log(p.id, p.position, p.bounds, p.connections))
Check drawer state:
// Inspect drawers
window.app.drawers.forEach(d => console.log(d.id, d.volume, d.boxLength, d.parts));
// Test drawer in specific area
const testDrawer = new Drawer('test', {
bottomShelf: window.app.panels.get('shelf-0'),
topShelf: window.app.panels.get('shelf-1'),
leftDivider: { type: 'left', position: { x: 8 } },
rightDivider: window.app.panels.get('divider-0')
});
testDrawer.calculateParts(window.app);
console.log(testDrawer);
Verify connections:
// Check if connections are Panel objects, not IDs
const panel = window.app.panels.get('shelf-0');
console.log(panel.connections.left?.type); // Should be 'divider', not undefined
Test cabinet dimensions:
console.log(window.app.cabinet); // { width, height, depth, base }
console.log(window.app.calc); // { innerWidth, innerDepth, workHeight }
Force 3D rebuild:
window.app.viewer3D.rebuildCabinet();
window.app.renderAll3D();