Files
fabrikanabytok/apps/fabrikanabytok/lib/three/kitchen-component-system.ts
2025-11-28 20:48:15 +01:00

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()
}
}