632 lines
17 KiB
TypeScript
632 lines
17 KiB
TypeScript
/**
|
|
* Modular Kitchen Component System
|
|
* Intelligent cabinet placement with constraints and validation
|
|
*/
|
|
|
|
import * as THREE from 'three'
|
|
|
|
export interface KitchenComponent {
|
|
id: string
|
|
type: 'base-cabinet' | 'wall-cabinet' | 'tall-cabinet' | 'corner-cabinet' | 'sink-cabinet' | 'appliance'
|
|
subtype?: string
|
|
dimensions: { width: number; height: number; depth: number }
|
|
connectionPoints: ConnectionPoint[]
|
|
constraints: ComponentConstraint[]
|
|
position: THREE.Vector3
|
|
rotation: THREE.Euler
|
|
mesh: THREE.Object3D
|
|
}
|
|
|
|
export interface ConnectionPoint {
|
|
id: string
|
|
type: 'left' | 'right' | 'top' | 'bottom'
|
|
position: THREE.Vector3 // Relative to component
|
|
normal: THREE.Vector3
|
|
connected: boolean
|
|
connectedTo?: string // ID of connected component
|
|
}
|
|
|
|
export interface ComponentConstraint {
|
|
type: 'min-height' | 'max-height' | 'must-align' | 'requires-support' | 'cannot-overlap' | 'must-connect'
|
|
value?: any
|
|
relatedComponent?: string
|
|
}
|
|
|
|
/**
|
|
* Kitchen Component Manager
|
|
*/
|
|
export class KitchenComponentManager {
|
|
private components: Map<string, KitchenComponent> = new Map()
|
|
private connections: Map<string, string> = new Map() // connectionId -> connectedConnectionId
|
|
|
|
/**
|
|
* Add component
|
|
*/
|
|
addComponent(component: KitchenComponent): boolean {
|
|
// Validate placement
|
|
const validation = this.validatePlacement(component)
|
|
|
|
if (!validation.valid) {
|
|
console.warn('Invalid placement:', validation.reason)
|
|
return false
|
|
}
|
|
|
|
this.components.set(component.id, component)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Remove component
|
|
*/
|
|
removeComponent(id: string): void {
|
|
const component = this.components.get(id)
|
|
if (!component) return
|
|
|
|
// Disconnect all connections
|
|
component.connectionPoints.forEach((point) => {
|
|
if (point.connected) {
|
|
this.disconnect(point.id)
|
|
}
|
|
})
|
|
|
|
this.components.delete(id)
|
|
}
|
|
|
|
/**
|
|
* Validate component placement
|
|
*/
|
|
validatePlacement(component: KitchenComponent): { valid: boolean; reason?: string } {
|
|
// Check all constraints
|
|
for (const constraint of component.constraints) {
|
|
switch (constraint.type) {
|
|
case 'min-height':
|
|
if (component.position.y < constraint.value) {
|
|
return { valid: false, reason: `Minimum height is ${constraint.value}m` }
|
|
}
|
|
break
|
|
|
|
case 'max-height':
|
|
if (component.position.y > constraint.value) {
|
|
return { valid: false, reason: `Maximum height is ${constraint.value}m` }
|
|
}
|
|
break
|
|
|
|
case 'requires-support':
|
|
if (!this.hasSupport(component)) {
|
|
return { valid: false, reason: 'Component requires support below' }
|
|
}
|
|
break
|
|
|
|
case 'cannot-overlap':
|
|
if (this.checkOverlap(component)) {
|
|
return { valid: false, reason: 'Component overlaps with another' }
|
|
}
|
|
break
|
|
|
|
case 'must-align':
|
|
if (!this.checkAlignment(component, constraint.relatedComponent)) {
|
|
return { valid: false, reason: 'Component must align with others' }
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
}
|
|
|
|
/**
|
|
* Check if component has support below
|
|
*/
|
|
private hasSupport(component: KitchenComponent): boolean {
|
|
// Base cabinets on floor don't need support
|
|
if (component.type === 'base-cabinet' && component.position.y <= 0.01) {
|
|
return true
|
|
}
|
|
|
|
// Check for supporting components below
|
|
const componentBelow = this.findComponentBelow(component)
|
|
return componentBelow !== null
|
|
}
|
|
|
|
/**
|
|
* Find component directly below
|
|
*/
|
|
private findComponentBelow(component: KitchenComponent): KitchenComponent | null {
|
|
let closestBelow: KitchenComponent | null = null
|
|
let closestDistance = Infinity
|
|
|
|
this.components.forEach((other) => {
|
|
if (other.id === component.id) return
|
|
|
|
// Check if horizontally aligned
|
|
const dx = Math.abs(component.position.x - other.position.x)
|
|
const dz = Math.abs(component.position.z - other.position.z)
|
|
|
|
if (dx < component.dimensions.width / 2 && dz < component.dimensions.depth / 2) {
|
|
// Check if below
|
|
const verticalDistance = component.position.y - (other.position.y + other.dimensions.height)
|
|
|
|
if (verticalDistance > 0 && verticalDistance < closestDistance) {
|
|
closestDistance = verticalDistance
|
|
closestBelow = other
|
|
}
|
|
}
|
|
})
|
|
|
|
return closestBelow
|
|
}
|
|
|
|
/**
|
|
* Check for overlap with other components
|
|
*/
|
|
private checkOverlap(component: KitchenComponent): boolean {
|
|
const bounds1 = this.getComponentBounds(component)
|
|
|
|
for (const [id, other] of this.components) {
|
|
if (id === component.id) continue
|
|
|
|
const bounds2 = this.getComponentBounds(other)
|
|
|
|
if (bounds1.intersectsBox(bounds2)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Get component bounds
|
|
*/
|
|
private getComponentBounds(component: KitchenComponent): THREE.Box3 {
|
|
const halfWidth = component.dimensions.width / 2
|
|
const halfHeight = component.dimensions.height / 2
|
|
const halfDepth = component.dimensions.depth / 2
|
|
|
|
return new THREE.Box3(
|
|
new THREE.Vector3(
|
|
component.position.x - halfWidth,
|
|
component.position.y - halfHeight,
|
|
component.position.z - halfDepth
|
|
),
|
|
new THREE.Vector3(
|
|
component.position.x + halfWidth,
|
|
component.position.y + halfHeight,
|
|
component.position.z + halfDepth
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Check alignment with other components
|
|
*/
|
|
private checkAlignment(component: KitchenComponent, relatedId?: string): boolean {
|
|
if (!relatedId) {
|
|
// Check if aligned with any component
|
|
for (const [id, other] of this.components) {
|
|
if (this.areAligned(component, other)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
const related = this.components.get(relatedId)
|
|
if (!related) return false
|
|
|
|
return this.areAligned(component, related)
|
|
}
|
|
|
|
/**
|
|
* Check if two components are aligned
|
|
*/
|
|
private areAligned(comp1: KitchenComponent, comp2: KitchenComponent): boolean {
|
|
const tolerance = 0.01
|
|
|
|
// Check front alignment
|
|
const frontAlign = Math.abs(comp1.position.z - comp2.position.z) < tolerance
|
|
|
|
// Check top alignment
|
|
const topAlign = Math.abs(
|
|
(comp1.position.y + comp1.dimensions.height / 2) -
|
|
(comp2.position.y + comp2.dimensions.height / 2)
|
|
) < tolerance
|
|
|
|
return frontAlign || topAlign
|
|
}
|
|
|
|
/**
|
|
* Connect two components
|
|
*/
|
|
connect(componentId1: string, connectionPoint1: string, componentId2: string, connectionPoint2: string): boolean {
|
|
const comp1 = this.components.get(componentId1)
|
|
const comp2 = this.components.get(componentId2)
|
|
|
|
if (!comp1 || !comp2) return false
|
|
|
|
const point1 = comp1.connectionPoints.find(p => p.id === connectionPoint1)
|
|
const point2 = comp2.connectionPoints.find(p => p.id === connectionPoint2)
|
|
|
|
if (!point1 || !point2) return false
|
|
|
|
// Check if connection is valid (opposite types)
|
|
if ((point1.type === 'left' && point2.type !== 'left') ||
|
|
(point1.type === 'right' && point2.type !== 'right') ||
|
|
(point1.type === 'top' && point2.type !== 'top') ||
|
|
(point1.type === 'bottom' && point2.type !== 'bottom')) {
|
|
return false
|
|
}
|
|
|
|
// Mark as connected
|
|
point1.connected = true
|
|
point1.connectedTo = componentId2
|
|
point2.connected = true
|
|
point2.connectedTo = componentId1
|
|
|
|
this.connections.set(point1.id, point2.id)
|
|
this.connections.set(point2.id, point1.id)
|
|
|
|
// Snap components together
|
|
this.snapToConnection(comp1, point1, comp2, point2)
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Disconnect components
|
|
*/
|
|
disconnect(connectionPointId: string): void {
|
|
const connectedTo = this.connections.get(connectionPointId)
|
|
if (!connectedTo) return
|
|
|
|
this.connections.delete(connectionPointId)
|
|
this.connections.delete(connectedTo)
|
|
|
|
// Update connection points
|
|
this.components.forEach((component) => {
|
|
component.connectionPoints.forEach((point) => {
|
|
if (point.id === connectionPointId || point.id === connectedTo) {
|
|
point.connected = false
|
|
point.connectedTo = undefined
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Snap component to connection point
|
|
*/
|
|
private snapToConnection(
|
|
comp1: KitchenComponent,
|
|
point1: ConnectionPoint,
|
|
comp2: KitchenComponent,
|
|
point2: ConnectionPoint
|
|
): void {
|
|
// Calculate world position of connection points
|
|
const worldPoint1 = point1.position.clone().applyEuler(comp1.rotation).add(comp1.position)
|
|
const worldPoint2 = point2.position.clone().applyEuler(comp2.rotation).add(comp2.position)
|
|
|
|
// Calculate offset needed
|
|
const offset = new THREE.Vector3().subVectors(worldPoint2, worldPoint1)
|
|
|
|
// Move comp1 to align with comp2
|
|
comp1.position.sub(offset)
|
|
|
|
// Update mesh position
|
|
comp1.mesh.position.copy(comp1.position)
|
|
}
|
|
|
|
/**
|
|
* Auto-arrange components
|
|
*/
|
|
autoArrange(components: KitchenComponent[], layoutType: 'L-shape' | 'U-shape' | 'galley' | 'island'): void {
|
|
switch (layoutType) {
|
|
case 'L-shape':
|
|
this.arrangeLShape(components)
|
|
break
|
|
case 'U-shape':
|
|
this.arrangeUShape(components)
|
|
break
|
|
case 'galley':
|
|
this.arrangeGalley(components)
|
|
break
|
|
case 'island':
|
|
this.arrangeIsland(components)
|
|
break
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Arrange in L-shape
|
|
*/
|
|
private arrangeLShape(components: KitchenComponent[]): void {
|
|
let currentX = 0
|
|
let currentZ = 0
|
|
let corner = false
|
|
|
|
components.forEach((comp, index) => {
|
|
if (!corner && index === Math.floor(components.length / 2)) {
|
|
// Turn corner
|
|
corner = true
|
|
currentX = 0
|
|
currentZ = 0
|
|
}
|
|
|
|
comp.position.set(
|
|
corner ? currentZ : currentX,
|
|
comp.type === 'base-cabinet' ? comp.dimensions.height / 2 : 1.5,
|
|
corner ? 0 : currentZ
|
|
)
|
|
|
|
if (corner) {
|
|
currentZ += comp.dimensions.width
|
|
} else {
|
|
currentX += comp.dimensions.width
|
|
}
|
|
|
|
comp.mesh.position.copy(comp.position)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Arrange in U-shape
|
|
*/
|
|
private arrangeUShape(components: KitchenComponent[]): void {
|
|
const perSide = Math.ceil(components.length / 3)
|
|
let currentX = 0
|
|
let currentZ = 0
|
|
let side = 0
|
|
|
|
components.forEach((comp, index) => {
|
|
if (index === perSide || index === perSide * 2) {
|
|
side++
|
|
currentX = side === 1 ? 0 : -perSide * 0.6
|
|
currentZ = side === 1 ? perSide * 0.6 : 0
|
|
}
|
|
|
|
comp.position.set(
|
|
side === 0 ? currentX : side === 1 ? perSide * 0.6 : currentX,
|
|
comp.type === 'base-cabinet' ? comp.dimensions.height / 2 : 1.5,
|
|
side === 0 ? 0 : side === 1 ? currentZ : perSide * 0.6
|
|
)
|
|
|
|
if (side === 0 || side === 2) {
|
|
currentX += comp.dimensions.width
|
|
} else {
|
|
currentZ += comp.dimensions.width
|
|
}
|
|
|
|
comp.mesh.position.copy(comp.position)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Arrange in galley (parallel)
|
|
*/
|
|
private arrangeGalley(components: KitchenComponent[]): void {
|
|
const half = Math.ceil(components.length / 2)
|
|
let currentX1 = 0
|
|
let currentX2 = 0
|
|
|
|
components.forEach((comp, index) => {
|
|
const isFirstRow = index < half
|
|
|
|
comp.position.set(
|
|
isFirstRow ? currentX1 : currentX2,
|
|
comp.type === 'base-cabinet' ? comp.dimensions.height / 2 : 1.5,
|
|
isFirstRow ? 0 : 2
|
|
)
|
|
|
|
if (isFirstRow) {
|
|
currentX1 += comp.dimensions.width
|
|
} else {
|
|
currentX2 += comp.dimensions.width
|
|
}
|
|
|
|
comp.mesh.position.copy(comp.position)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Arrange island
|
|
*/
|
|
private arrangeIsland(components: KitchenComponent[]): void {
|
|
const perSide = Math.ceil(components.length / 4)
|
|
let currentX = 0
|
|
let side = 0
|
|
|
|
components.forEach((comp, index) => {
|
|
if (index > 0 && index % perSide === 0) {
|
|
side++
|
|
currentX = 0
|
|
}
|
|
|
|
const angle = (side * Math.PI) / 2
|
|
const radius = 2
|
|
|
|
comp.position.set(
|
|
Math.cos(angle) * radius + currentX,
|
|
comp.dimensions.height / 2,
|
|
Math.sin(angle) * radius
|
|
)
|
|
|
|
comp.rotation.y = angle + Math.PI / 2
|
|
|
|
currentX += comp.dimensions.width
|
|
|
|
comp.mesh.position.copy(comp.position)
|
|
comp.mesh.rotation.copy(comp.rotation)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Find best placement for component
|
|
*/
|
|
findBestPlacement(
|
|
component: KitchenComponent,
|
|
roomBounds: THREE.Box3,
|
|
existingComponents: KitchenComponent[]
|
|
): THREE.Vector3 | null {
|
|
const candidates: Array<{ position: THREE.Vector3; score: number }> = []
|
|
|
|
// Try along walls
|
|
const walls = [
|
|
{ normal: new THREE.Vector3(-1, 0, 0), pos: roomBounds.min.x },
|
|
{ normal: new THREE.Vector3(1, 0, 0), pos: roomBounds.max.x },
|
|
{ normal: new THREE.Vector3(0, 0, -1), pos: roomBounds.min.z },
|
|
{ normal: new THREE.Vector3(0, 0, 1), pos: roomBounds.max.z }
|
|
]
|
|
|
|
walls.forEach((wall) => {
|
|
// Try positions along this wall
|
|
for (let offset = 0; offset < 10; offset += 0.5) {
|
|
const testPos = new THREE.Vector3()
|
|
|
|
if (Math.abs(wall.normal.x) > 0) {
|
|
testPos.set(wall.pos, component.dimensions.height / 2, offset - 5)
|
|
} else {
|
|
testPos.set(offset - 5, component.dimensions.height / 2, wall.pos)
|
|
}
|
|
|
|
// Score this position
|
|
const score = this.scorePosition(component, testPos, existingComponents)
|
|
|
|
if (score > 0) {
|
|
candidates.push({ position: testPos, score })
|
|
}
|
|
}
|
|
})
|
|
|
|
// Sort by score and return best
|
|
candidates.sort((a, b) => b.score - a.score)
|
|
|
|
return candidates.length > 0 ? candidates[0].position : null
|
|
}
|
|
|
|
/**
|
|
* Score placement position
|
|
*/
|
|
private scorePosition(
|
|
component: KitchenComponent,
|
|
position: THREE.Vector3,
|
|
existingComponents: KitchenComponent[]
|
|
): number {
|
|
let score = 100
|
|
|
|
// Create temporary component at this position
|
|
const tempComponent: KitchenComponent = {
|
|
...component,
|
|
position: position.clone()
|
|
}
|
|
|
|
// Check overlap (heavily penalize)
|
|
if (this.checkOverlap(tempComponent)) {
|
|
return 0
|
|
}
|
|
|
|
// Reward proximity to other components (for continuity)
|
|
const nearestDistance = this.findNearestComponentDistance(position, existingComponents)
|
|
if (nearestDistance < 0.1) {
|
|
score += 50
|
|
} else if (nearestDistance < 0.5) {
|
|
score += 20
|
|
}
|
|
|
|
// Reward wall proximity
|
|
// (already along wall in candidates)
|
|
score += 30
|
|
|
|
return score
|
|
}
|
|
|
|
/**
|
|
* Check if component overlaps with any existing component
|
|
*/
|
|
private checkRotatedOverlap(component: KitchenComponent): boolean {
|
|
const bounds1 = this.getRotatedBounds(component)
|
|
|
|
for (const [id, other] of this.components) {
|
|
if (id === component.id) continue
|
|
|
|
const bounds2 = this.getRotatedBounds(other)
|
|
|
|
if (bounds1.intersectsBox(bounds2)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Get component bounding box
|
|
*/
|
|
private getRotatedBounds(component: KitchenComponent): THREE.Box3 {
|
|
const halfWidth = component.dimensions.width / 2
|
|
const halfHeight = component.dimensions.height / 2
|
|
const halfDepth = component.dimensions.depth / 2
|
|
|
|
// Apply rotation (simplified - assumes Y-axis rotation only)
|
|
const cos = Math.cos(component.rotation.y)
|
|
const sin = Math.sin(component.rotation.y)
|
|
|
|
const w = Math.abs(halfWidth * cos) + Math.abs(halfDepth * sin)
|
|
const d = Math.abs(halfWidth * sin) + Math.abs(halfDepth * cos)
|
|
|
|
return new THREE.Box3(
|
|
new THREE.Vector3(
|
|
component.position.x - w,
|
|
component.position.y - halfHeight,
|
|
component.position.z - d
|
|
),
|
|
new THREE.Vector3(
|
|
component.position.x + w,
|
|
component.position.y + halfHeight,
|
|
component.position.z + d
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Find nearest component distance
|
|
*/
|
|
private findNearestComponentDistance(
|
|
position: THREE.Vector3,
|
|
components: KitchenComponent[]
|
|
): number {
|
|
if (components.length === 0) return Infinity
|
|
|
|
let minDistance = Infinity
|
|
|
|
components.forEach((comp) => {
|
|
const distance = position.distanceTo(comp.position)
|
|
minDistance = Math.min(minDistance, distance)
|
|
})
|
|
|
|
return minDistance
|
|
}
|
|
|
|
/**
|
|
* Get all components
|
|
*/
|
|
getAllComponents(): KitchenComponent[] {
|
|
return Array.from(this.components.values())
|
|
}
|
|
|
|
/**
|
|
* Get component
|
|
*/
|
|
getComponent(id: string): KitchenComponent | undefined {
|
|
return this.components.get(id)
|
|
}
|
|
|
|
/**
|
|
* Clear all components
|
|
*/
|
|
clear(): void {
|
|
this.components.clear()
|
|
this.connections.clear()
|
|
}
|
|
}
|
|
|