feat: Latest production version with interior scene and glass
Includes room interior with floor, walls, glass you can see through, and all uncommitted production changes that were running live. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Asset mapping for Aluwdoors textures
|
||||
* Asset mapping for Proinn textures
|
||||
* Maps configurator state values to texture file paths
|
||||
*/
|
||||
|
||||
@@ -8,49 +8,49 @@ export type GlassTexture = 'blank' | 'brons-tint' | 'grijs-tint' | 'mat-blank' |
|
||||
export type HandleType = 'beugelgreep' | 'geen' | 'hoekgreep' | 'maangreep' | 'ovaalgreep';
|
||||
export type DividerType = 'platte-roede' | 't-roede';
|
||||
|
||||
const TEXTURE_BASE = '/textures/aluwdoors';
|
||||
const TEXTURE_BASE = '/textures/proinn';
|
||||
|
||||
/**
|
||||
* Metal texture mapping
|
||||
*/
|
||||
export const metalTextures: Record<MetalTexture, string> = {
|
||||
antraciet: `${TEXTURE_BASE}/aluwdoors-configurator-metaalkleur-antraciet.jpg`,
|
||||
beige: `${TEXTURE_BASE}/aluwdoors-configurator-metaalkleur-beige.jpg`,
|
||||
brons: `${TEXTURE_BASE}/aluwdoors-configurator-metaalkleur-brons.jpg`,
|
||||
goud: `${TEXTURE_BASE}/aluwdoors-configurator-metaalkleur-goud.jpg`,
|
||||
zwart: `${TEXTURE_BASE}/aluwdoors-configurator-metaalkleur-zwart.jpg`,
|
||||
ral: `${TEXTURE_BASE}/aluwdoors-configurator-metaalkleur-ral-keuze.jpg`,
|
||||
antraciet: `${TEXTURE_BASE}/proinn-metaalkleur-antraciet.jpg`,
|
||||
beige: `${TEXTURE_BASE}/proinn-metaalkleur-beige.jpg`,
|
||||
brons: `${TEXTURE_BASE}/proinn-metaalkleur-brons.jpg`,
|
||||
goud: `${TEXTURE_BASE}/proinn-metaalkleur-goud.jpg`,
|
||||
zwart: `${TEXTURE_BASE}/proinn-metaalkleur-zwart.jpg`,
|
||||
ral: `${TEXTURE_BASE}/proinn-metaalkleur-ral-keuze.jpg`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Glass texture mapping
|
||||
*/
|
||||
export const glassTextures: Record<GlassTexture, string> = {
|
||||
'blank': `${TEXTURE_BASE}/aluwdoors-configurator-glaskleur-blank.jpg`,
|
||||
'brons-tint': `${TEXTURE_BASE}/aluwdoors-configurator-glaskleur-brons.jpg`,
|
||||
'grijs-tint': `${TEXTURE_BASE}/aluwdoors-configurator-glaskleur-grijs.jpg`,
|
||||
'mat-blank': `${TEXTURE_BASE}/aluwdoors-configurator-glaskleur-mat-blank.jpg`,
|
||||
'mat-brons': `${TEXTURE_BASE}/aluwdoors-configurator-glaskleur-mat-brons.jpg`,
|
||||
'mat-zwart': `${TEXTURE_BASE}/aluwdoors-configurator-glaskleur-mat-zwart.jpg`,
|
||||
'blank': `${TEXTURE_BASE}/proinn-glaskleur-blank.jpg`,
|
||||
'brons-tint': `${TEXTURE_BASE}/proinn-glaskleur-brons.jpg`,
|
||||
'grijs-tint': `${TEXTURE_BASE}/proinn-glaskleur-grijs.jpg`,
|
||||
'mat-blank': `${TEXTURE_BASE}/proinn-glaskleur-mat-blank.jpg`,
|
||||
'mat-brons': `${TEXTURE_BASE}/proinn-glaskleur-mat-brons.jpg`,
|
||||
'mat-zwart': `${TEXTURE_BASE}/proinn-glaskleur-mat-zwart.jpg`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle SVG mapping
|
||||
*/
|
||||
export const handleSVGs: Record<HandleType, string> = {
|
||||
beugelgreep: `${TEXTURE_BASE}/aluwdoors-configurator-fineer-handgreep-beugelgreep.svg`,
|
||||
geen: `${TEXTURE_BASE}/aluwdoors-configurator-fineer-handgreep-geen.svg`,
|
||||
hoekgreep: `${TEXTURE_BASE}/aluwdoors-configurator-fineer-handgreep-hoekgreep.svg`,
|
||||
maangreep: `${TEXTURE_BASE}/aluwdoors-configurator-fineer-handgreep-maangreep.svg`,
|
||||
ovaalgreep: `${TEXTURE_BASE}/aluwdoors-configurator-fineer-handgreep-ovaalgreep.svg`,
|
||||
beugelgreep: `${TEXTURE_BASE}/proinn-fineer-handgreep-beugelgreep.svg`,
|
||||
geen: `${TEXTURE_BASE}/proinn-fineer-handgreep-geen.svg`,
|
||||
hoekgreep: `${TEXTURE_BASE}/proinn-fineer-handgreep-hoekgreep.svg`,
|
||||
maangreep: `${TEXTURE_BASE}/proinn-fineer-handgreep-maangreep.svg`,
|
||||
ovaalgreep: `${TEXTURE_BASE}/proinn-fineer-handgreep-ovaalgreep.svg`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Divider SVG mapping
|
||||
*/
|
||||
export const dividerSVGs: Record<DividerType, string> = {
|
||||
'platte-roede': `${TEXTURE_BASE}/aluwdoors-configurator-roedetype-platte-roede.svg`,
|
||||
't-roede': `${TEXTURE_BASE}/aluwdoors-configurator-roedetype-t-roede.svg`,
|
||||
'platte-roede': `${TEXTURE_BASE}/proinn-roedetype-platte-roede.svg`,
|
||||
't-roede': `${TEXTURE_BASE}/proinn-roedetype-t-roede.svg`,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -100,26 +100,17 @@ export function getGlassMaterial(glassType: GlassTexture): GlassMaterialProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aluwdoors extracted color scheme
|
||||
* Proinn color scheme
|
||||
*/
|
||||
export const aluwColors = {
|
||||
// Primary action color (from CSS analysis)
|
||||
primary: '#b1de6e', // Pistachio green
|
||||
primaryDark: '#9fcd5b',
|
||||
|
||||
// Dark backgrounds
|
||||
darkest: '#1b2221',
|
||||
export const proinnColors = {
|
||||
primary: '#C4D668',
|
||||
primaryDark: '#b5c75a',
|
||||
darkest: '#1A2E2E',
|
||||
dark: '#2b3937',
|
||||
darkMedium: '#3e4b49',
|
||||
|
||||
// Light backgrounds
|
||||
light: '#e0e5e5',
|
||||
lightest: '#f0f3f3',
|
||||
|
||||
// Neutral
|
||||
lightest: '#F5F5F3',
|
||||
gray: '#868c8b',
|
||||
|
||||
// Accent/Error
|
||||
error: '#e74242',
|
||||
errorDark: '#c40c0c',
|
||||
} as const;
|
||||
|
||||
@@ -8,55 +8,27 @@
|
||||
// MANUFACTURING CONSTANTS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Steel Profile Dimensions (40x40mm Square Tube)
|
||||
* Standard industrial steel door profile
|
||||
*/
|
||||
export const PROFILE_WIDTH = 40; // mm - Face width
|
||||
export const PROFILE_DEPTH = 40; // mm - Tube depth
|
||||
export const PROFILE_CORNER_RADIUS = 2; // mm - Rounded corners for welding
|
||||
|
||||
/**
|
||||
* Steel Profile Named Exports (aliases for pricing/manufacturing clarity)
|
||||
*/
|
||||
export const STILE_WIDTH = 40; // mm - Vertical profiles (same as PROFILE_WIDTH)
|
||||
export const STILE_WIDTH = 40; // mm - Vertical profiles
|
||||
export const RAIL_WIDTH = 20; // mm - Horizontal slim-line profiles
|
||||
|
||||
/**
|
||||
* Glass Specifications - Standard 33.1 laminated safety glass (VSG 33.1)
|
||||
*/
|
||||
export const GLASS_THICKNESS = 7; // mm - Standard 33.1 Safety Glass
|
||||
export const GLASS_OFFSET = 15; // mm - Center glass in 40mm profile: (40-7)/2 - 1.5mm clearance
|
||||
export const GLASS_OFFSET = 15; // mm - Center glass in 40mm profile
|
||||
|
||||
/**
|
||||
* Rail Height Variations
|
||||
*/
|
||||
export const RAIL_HEIGHT_SLIM = 20; // mm - Slim horizontal rails
|
||||
export const RAIL_HEIGHT_ROBUST = 40; // mm - Standard robust rails (same as profile)
|
||||
export const RAIL_HEIGHT_ROBUST = 40; // mm - Standard robust rails
|
||||
|
||||
/**
|
||||
* Taats (Pivot) Door Mechanism
|
||||
*/
|
||||
export const TAATS_PIVOT_OFFSET = 60; // mm - Pivot axis offset from wall for Taats doors
|
||||
export const TAATS_PIVOT_OFFSET = 60; // mm
|
||||
export const STELRUIMTE = 10; // mm
|
||||
export const HANGNAAD = 3; // mm
|
||||
export const WALL_THICKNESS = 150; // mm
|
||||
|
||||
/**
|
||||
* Wall Mounting Dimensions (Sparingsmaat / Deurmaat)
|
||||
* Dutch building standard: Sparingsmaat = rough wall opening
|
||||
*/
|
||||
export const STELRUIMTE = 10; // mm - Total tolerance between wall and frame (5mm per side)
|
||||
export const HANGNAAD = 3; // mm - Gap between frame and door leaf per side
|
||||
export const WALL_THICKNESS = 150; // mm - Standard interior wall thickness
|
||||
|
||||
/**
|
||||
* Calculate mounting dimensions from Sparingsmaat (wall opening).
|
||||
*
|
||||
* Sparingsmaat (input) -> Frame -> Door Leaf
|
||||
* Frame = Sparingsmaat - STELRUIMTE (10mm tolerance)
|
||||
* DoorLeaf = Frame - 2*PROFILE_WIDTH - 2*HANGNAAD (6mm gap)
|
||||
*/
|
||||
export function calculateMountingDimensions(sparingsmaatWidth: number, sparingsmaatHeight: number) {
|
||||
const frameOuterWidth = sparingsmaatWidth - STELRUIMTE;
|
||||
const frameOuterHeight = sparingsmaatHeight - STELRUIMTE / 2; // 5mm top tolerance only
|
||||
const frameOuterHeight = sparingsmaatHeight - STELRUIMTE / 2;
|
||||
const doorLeafWidth = frameOuterWidth - (2 * HANGNAAD);
|
||||
const doorLeafHeight = frameOuterHeight - (2 * HANGNAAD);
|
||||
|
||||
@@ -67,8 +39,8 @@ export function calculateMountingDimensions(sparingsmaatWidth: number, sparingsm
|
||||
frameOuterHeight,
|
||||
doorLeafWidth,
|
||||
doorLeafHeight,
|
||||
stelruimtePerSide: STELRUIMTE / 2, // 5mm gap visible on each side
|
||||
hangnaadPerSide: HANGNAAD, // 3mm gap between frame and leaf
|
||||
stelruimtePerSide: STELRUIMTE / 2,
|
||||
hangnaadPerSide: HANGNAAD,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,51 +50,69 @@ export function calculateMountingDimensions(sparingsmaatWidth: number, sparingsm
|
||||
|
||||
export type PartType = 'stile' | 'rail' | 'glass' | 'divider';
|
||||
export type DoorModel = 'taats' | 'scharnier' | 'paneel';
|
||||
export type GridLayout = '3-vlak' | '4-vlak' | 'geen';
|
||||
export type GridLayout =
|
||||
| 'geen'
|
||||
| '2-vlak'
|
||||
| '3-vlak'
|
||||
| '4-vlak'
|
||||
| '6-vlak'
|
||||
| '8-vlak'
|
||||
| 'kruis'
|
||||
| 'ongelijk-3'
|
||||
| 'boerderij'
|
||||
| 'herenhuis';
|
||||
|
||||
/**
|
||||
* Physical Door Component
|
||||
* Represents an actual steel part that will be manufactured
|
||||
*/
|
||||
export interface PhysicalPart {
|
||||
type: PartType;
|
||||
// Position in 3D space (in mm, relative to door center)
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
// Dimensions in mm
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
// Metadata
|
||||
label?: string;
|
||||
isGlass?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Door Assembly
|
||||
*/
|
||||
export interface DoorAssembly {
|
||||
modelId: DoorModel;
|
||||
gridLayout: GridLayout;
|
||||
doorWidth: number; // mm - Actual door leaf width
|
||||
doorHeight: number; // mm - Door height
|
||||
doorWidth: number;
|
||||
doorHeight: number;
|
||||
parts: PhysicalPart[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GRID PATTERN DEFINITIONS (DATA-DRIVEN)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Grid pattern definition as data.
|
||||
* horizontalPositions: fractional Y positions (0 = bottom, 1 = top) for horizontal dividers
|
||||
* verticalPositions: fractional X positions (0 = left, 1 = right) for vertical dividers
|
||||
*/
|
||||
interface GridPatternDef {
|
||||
horizontalPositions: number[];
|
||||
verticalPositions: number[];
|
||||
}
|
||||
|
||||
const GRID_PATTERNS: Record<GridLayout, GridPatternDef> = {
|
||||
'geen': { horizontalPositions: [], verticalPositions: [] },
|
||||
'2-vlak': { horizontalPositions: [0.5], verticalPositions: [] },
|
||||
'3-vlak': { horizontalPositions: [1 / 3, 2 / 3], verticalPositions: [] },
|
||||
'4-vlak': { horizontalPositions: [0.25, 0.5, 0.75], verticalPositions: [] },
|
||||
'6-vlak': { horizontalPositions: [1 / 3, 2 / 3], verticalPositions: [0.5] },
|
||||
'8-vlak': { horizontalPositions: [0.25, 0.5, 0.75], verticalPositions: [0.5] },
|
||||
'kruis': { horizontalPositions: [0.5], verticalPositions: [0.5] },
|
||||
'ongelijk-3': { horizontalPositions: [0.35, 0.65], verticalPositions: [] },
|
||||
'boerderij': { horizontalPositions: [0.7], verticalPositions: [0.5] },
|
||||
'herenhuis': { horizontalPositions: [0.3, 0.7], verticalPositions: [] },
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// LAYOUT GENERATION
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generate physical parts list for door manufacturing
|
||||
*
|
||||
* @param modelId - Door model (taats, scharnier, paneel)
|
||||
* @param gridLayout - Grid division (3-vlak, 4-vlak, geen)
|
||||
* @param doorWidth - Door leaf width in mm
|
||||
* @param doorHeight - Door height in mm
|
||||
* @returns Complete assembly with all physical parts
|
||||
*/
|
||||
export function generateDoorAssembly(
|
||||
modelId: DoorModel,
|
||||
gridLayout: GridLayout,
|
||||
@@ -130,12 +120,13 @@ export function generateDoorAssembly(
|
||||
doorHeight: number
|
||||
): DoorAssembly {
|
||||
const parts: PhysicalPart[] = [];
|
||||
const pattern = GRID_PATTERNS[gridLayout] || GRID_PATTERNS['geen'];
|
||||
|
||||
// ============================================
|
||||
// PERIMETER FRAME (All door types)
|
||||
// PERIMETER FRAME
|
||||
// ============================================
|
||||
|
||||
// LEFT STILE (Vertical)
|
||||
// LEFT STILE
|
||||
parts.push({
|
||||
type: 'stile',
|
||||
x: -doorWidth / 2 + PROFILE_WIDTH / 2,
|
||||
@@ -147,7 +138,7 @@ export function generateDoorAssembly(
|
||||
label: 'Left Stile',
|
||||
});
|
||||
|
||||
// RIGHT STILE (Vertical)
|
||||
// RIGHT STILE
|
||||
parts.push({
|
||||
type: 'stile',
|
||||
x: doorWidth / 2 - PROFILE_WIDTH / 2,
|
||||
@@ -159,7 +150,7 @@ export function generateDoorAssembly(
|
||||
label: 'Right Stile',
|
||||
});
|
||||
|
||||
// TOP RAIL (Horizontal)
|
||||
// TOP RAIL
|
||||
const topRailWidth = doorWidth - PROFILE_WIDTH * 2;
|
||||
parts.push({
|
||||
type: 'rail',
|
||||
@@ -172,7 +163,7 @@ export function generateDoorAssembly(
|
||||
label: 'Top Rail',
|
||||
});
|
||||
|
||||
// BOTTOM RAIL (Horizontal)
|
||||
// BOTTOM RAIL
|
||||
parts.push({
|
||||
type: 'rail',
|
||||
x: 0,
|
||||
@@ -185,80 +176,57 @@ export function generateDoorAssembly(
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// GRID DIVIDERS (Based on layout)
|
||||
// HORIZONTAL DIVIDERS (from pattern data)
|
||||
// ============================================
|
||||
|
||||
if (gridLayout === '3-vlak') {
|
||||
// Two horizontal dividers at 1/3 and 2/3 height
|
||||
const divider1Y = doorHeight / 2 - doorHeight / 3;
|
||||
const divider2Y = doorHeight / 2 - (2 * doorHeight) / 3;
|
||||
const innerHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2;
|
||||
const innerBottom = -doorHeight / 2 + RAIL_HEIGHT_ROBUST;
|
||||
|
||||
for (const fraction of pattern.horizontalPositions) {
|
||||
const dividerY = innerBottom + innerHeight * fraction;
|
||||
|
||||
// Determine width: if there are vertical dividers, horizontal dividers span full width
|
||||
// (vertical dividers will be handled separately)
|
||||
parts.push({
|
||||
type: 'divider',
|
||||
x: 0,
|
||||
y: divider1Y,
|
||||
y: dividerY,
|
||||
z: 0,
|
||||
width: topRailWidth,
|
||||
height: RAIL_HEIGHT_SLIM,
|
||||
depth: PROFILE_DEPTH,
|
||||
label: 'Divider 1/3',
|
||||
});
|
||||
|
||||
parts.push({
|
||||
type: 'divider',
|
||||
x: 0,
|
||||
y: divider2Y,
|
||||
z: 0,
|
||||
width: topRailWidth,
|
||||
height: RAIL_HEIGHT_SLIM,
|
||||
depth: PROFILE_DEPTH,
|
||||
label: 'Divider 2/3',
|
||||
});
|
||||
} else if (gridLayout === '4-vlak') {
|
||||
// Three horizontal dividers at 1/4, 1/2, 3/4 height
|
||||
const divider1Y = doorHeight / 2 - doorHeight / 4;
|
||||
const divider2Y = 0;
|
||||
const divider3Y = doorHeight / 2 - (3 * doorHeight) / 4;
|
||||
|
||||
parts.push({
|
||||
type: 'divider',
|
||||
x: 0,
|
||||
y: divider1Y,
|
||||
z: 0,
|
||||
width: topRailWidth,
|
||||
height: RAIL_HEIGHT_SLIM,
|
||||
depth: PROFILE_DEPTH,
|
||||
label: 'Divider 1/4',
|
||||
});
|
||||
|
||||
parts.push({
|
||||
type: 'divider',
|
||||
x: 0,
|
||||
y: divider2Y,
|
||||
z: 0,
|
||||
width: topRailWidth,
|
||||
height: RAIL_HEIGHT_SLIM,
|
||||
depth: PROFILE_DEPTH,
|
||||
label: 'Divider 1/2',
|
||||
});
|
||||
|
||||
parts.push({
|
||||
type: 'divider',
|
||||
x: 0,
|
||||
y: divider3Y,
|
||||
z: 0,
|
||||
width: topRailWidth,
|
||||
height: RAIL_HEIGHT_SLIM,
|
||||
depth: PROFILE_DEPTH,
|
||||
label: 'Divider 3/4',
|
||||
label: `H-Divider ${Math.round(fraction * 100)}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VERTICAL CENTER DIVIDER (Paneel type only)
|
||||
// VERTICAL DIVIDERS (from pattern data)
|
||||
// ============================================
|
||||
|
||||
if (modelId === 'paneel') {
|
||||
const innerWidth = doorWidth - PROFILE_WIDTH * 2;
|
||||
const innerLeft = -doorWidth / 2 + PROFILE_WIDTH;
|
||||
|
||||
for (const fraction of pattern.verticalPositions) {
|
||||
const dividerX = innerLeft + innerWidth * fraction;
|
||||
const verticalDividerHeight = innerHeight;
|
||||
|
||||
parts.push({
|
||||
type: 'divider',
|
||||
x: dividerX,
|
||||
y: 0,
|
||||
z: 0,
|
||||
width: PROFILE_WIDTH,
|
||||
height: verticalDividerHeight,
|
||||
depth: PROFILE_DEPTH,
|
||||
label: `V-Divider ${Math.round(fraction * 100)}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PANEEL TYPE CENTER VERTICAL DIVIDER
|
||||
// ============================================
|
||||
|
||||
if (modelId === 'paneel' && !pattern.verticalPositions.includes(0.5)) {
|
||||
const verticalDividerHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2;
|
||||
|
||||
parts.push({
|
||||
@@ -277,7 +245,6 @@ export function generateDoorAssembly(
|
||||
// GLASS PANELS
|
||||
// ============================================
|
||||
|
||||
// Calculate glass dimensions (inside frame with offset)
|
||||
const glassWidth = doorWidth - PROFILE_WIDTH * 2 - GLASS_OFFSET * 2;
|
||||
const glassHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2 - GLASS_OFFSET * 2;
|
||||
|
||||
@@ -302,41 +269,32 @@ export function generateDoorAssembly(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert mm to meters for Three.js 3D scene
|
||||
*/
|
||||
export function mmToMeters(mm: number): number {
|
||||
return mm / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get divider positions in meters (for backward compatibility)
|
||||
*/
|
||||
export function getDividerPositions(
|
||||
gridLayout: GridLayout,
|
||||
doorHeight: number
|
||||
): number[] {
|
||||
const pattern = GRID_PATTERNS[gridLayout];
|
||||
if (!pattern) return [];
|
||||
|
||||
const doorHeightMeters = mmToMeters(doorHeight);
|
||||
const innerHeight = doorHeightMeters - mmToMeters(RAIL_HEIGHT_ROBUST * 2);
|
||||
const innerBottom = -doorHeightMeters / 2 + mmToMeters(RAIL_HEIGHT_ROBUST);
|
||||
|
||||
if (gridLayout === '3-vlak') {
|
||||
return [-doorHeightMeters / 3, doorHeightMeters / 3];
|
||||
} else if (gridLayout === '4-vlak') {
|
||||
return [-doorHeightMeters / 2, 0, doorHeightMeters / 2];
|
||||
}
|
||||
|
||||
return [];
|
||||
return pattern.horizontalPositions.map(
|
||||
(fraction) => innerBottom + innerHeight * fraction
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation: Check if door dimensions are manufacturable
|
||||
*/
|
||||
export function validateDoorDimensions(
|
||||
doorWidth: number,
|
||||
doorHeight: number
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Minimum dimensions check
|
||||
if (doorWidth < PROFILE_WIDTH * 3) {
|
||||
errors.push(`Door width too small (min: ${PROFILE_WIDTH * 3}mm)`);
|
||||
}
|
||||
@@ -345,7 +303,6 @@ export function validateDoorDimensions(
|
||||
errors.push(`Door height too small (min: ${RAIL_HEIGHT_ROBUST * 3}mm)`);
|
||||
}
|
||||
|
||||
// Maximum dimensions check (based on steel profile strength)
|
||||
if (doorWidth > 1200) {
|
||||
errors.push('Door width exceeds maximum (1200mm) - structural integrity');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Pricing Engine for Proinn Configurator
|
||||
* Based on Dutch market standard pricing (Metalworks/Aluwdoors reference)
|
||||
* Based on Dutch market standard pricing
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -28,22 +28,64 @@ const HANDLE_PRICES: Record<string, number> = {
|
||||
'geen': 0,
|
||||
};
|
||||
|
||||
// Premium finish surcharges
|
||||
const FINISH_SURCHARGES: Record<string, number> = {
|
||||
'zwart': 0,
|
||||
'grijs': 0,
|
||||
'brons': 0,
|
||||
'goud': 150,
|
||||
'beige': 75,
|
||||
'ral': 200,
|
||||
};
|
||||
|
||||
// Frame size multipliers (relative to standard 40mm)
|
||||
const FRAME_SIZE_MULTIPLIERS: Record<number, number> = {
|
||||
20: 0.7,
|
||||
30: 0.85,
|
||||
40: 1.0,
|
||||
};
|
||||
|
||||
function getHorizontalDividerCount(gridLayout: GridLayout): number {
|
||||
switch (gridLayout) {
|
||||
case '2-vlak': return 1;
|
||||
case '3-vlak': return 2;
|
||||
case '4-vlak': return 3;
|
||||
case '6-vlak': return 2;
|
||||
case '8-vlak': return 3;
|
||||
case 'kruis': return 1;
|
||||
case 'ongelijk-3': return 2;
|
||||
case 'boerderij': return 1;
|
||||
case 'herenhuis': return 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function hasVerticalDividers(gridLayout: GridLayout): boolean {
|
||||
return ['6-vlak', '8-vlak', 'kruis', 'boerderij'].includes(gridLayout);
|
||||
}
|
||||
|
||||
function calculateSteelLength(
|
||||
doorWidth: number,
|
||||
doorHeight: number,
|
||||
gridLayout: GridLayout,
|
||||
hasVerticalDivider: boolean
|
||||
hasPaneelVerticalDivider: boolean
|
||||
): number {
|
||||
const innerWidth = doorWidth - PROFILE_WIDTH * 2;
|
||||
// Perimeter
|
||||
let totalLength = doorHeight * 2 + innerWidth * 2;
|
||||
|
||||
if (gridLayout === '3-vlak') {
|
||||
totalLength += innerWidth * 2;
|
||||
} else if (gridLayout === '4-vlak') {
|
||||
totalLength += innerWidth * 3;
|
||||
// Horizontal dividers
|
||||
const hDividers = getHorizontalDividerCount(gridLayout);
|
||||
totalLength += innerWidth * hDividers;
|
||||
|
||||
// Vertical dividers from grid pattern
|
||||
if (hasVerticalDividers(gridLayout)) {
|
||||
const innerHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2;
|
||||
totalLength += innerHeight;
|
||||
}
|
||||
|
||||
if (hasVerticalDivider) {
|
||||
// Paneel type adds center vertical divider
|
||||
if (hasPaneelVerticalDivider) {
|
||||
totalLength += doorHeight - RAIL_HEIGHT_ROBUST * 2;
|
||||
}
|
||||
|
||||
@@ -58,11 +100,12 @@ function calculateGlassArea(
|
||||
const glassWidth = doorWidth - PROFILE_WIDTH * 2;
|
||||
const glassHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2;
|
||||
|
||||
let dividerArea = 0;
|
||||
if (gridLayout === '3-vlak') {
|
||||
dividerArea = glassWidth * RAIL_HEIGHT_SLIM * 2;
|
||||
} else if (gridLayout === '4-vlak') {
|
||||
dividerArea = glassWidth * RAIL_HEIGHT_SLIM * 3;
|
||||
const hDividers = getHorizontalDividerCount(gridLayout);
|
||||
let dividerArea = glassWidth * RAIL_HEIGHT_SLIM * hDividers;
|
||||
|
||||
// Vertical divider area
|
||||
if (hasVerticalDividers(gridLayout)) {
|
||||
dividerArea += PROFILE_WIDTH * (glassHeight - RAIL_HEIGHT_SLIM * hDividers);
|
||||
}
|
||||
|
||||
return (glassWidth * glassHeight - dividerArea) / 1_000_000;
|
||||
@@ -75,6 +118,7 @@ export interface PriceBreakdown {
|
||||
mechanismSurcharge: number;
|
||||
sidePanelSurcharge: number;
|
||||
handleCost: number;
|
||||
finishSurcharge: number;
|
||||
totalPrice: number;
|
||||
steelLengthM: number;
|
||||
glassAreaSqm: number;
|
||||
@@ -87,12 +131,15 @@ export function calculatePrice(
|
||||
gridLayout: GridLayout,
|
||||
doorConfig: 'enkele' | 'dubbele',
|
||||
sidePanel: 'geen' | 'links' | 'rechts' | 'beide',
|
||||
handle: string
|
||||
handle: string,
|
||||
finish: string = 'zwart',
|
||||
frameSize: number = 40
|
||||
): PriceBreakdown {
|
||||
const leafCount = doorConfig === 'dubbele' ? 2 : 1;
|
||||
const hasVerticalDivider = doorType === 'paneel';
|
||||
const hasPaneelVerticalDivider = doorType === 'paneel';
|
||||
const frameSizeMultiplier = FRAME_SIZE_MULTIPLIERS[frameSize] ?? 1.0;
|
||||
|
||||
const steelLengthPerLeaf = calculateSteelLength(doorWidth, doorHeight, gridLayout, hasVerticalDivider);
|
||||
const steelLengthPerLeaf = calculateSteelLength(doorWidth, doorHeight, gridLayout, hasPaneelVerticalDivider);
|
||||
const glassAreaPerLeaf = calculateGlassArea(doorWidth, doorHeight, gridLayout);
|
||||
|
||||
const totalSteelLength = steelLengthPerLeaf * leafCount;
|
||||
@@ -100,7 +147,7 @@ export function calculatePrice(
|
||||
|
||||
const sidePanelCount = sidePanel === 'beide' ? 2 : (sidePanel === 'geen' ? 0 : 1);
|
||||
|
||||
const steelCost = Math.round(totalSteelLength * STEEL_PRICE_PER_METER);
|
||||
const steelCost = Math.round(totalSteelLength * STEEL_PRICE_PER_METER * frameSizeMultiplier);
|
||||
const glassCost = Math.round(totalGlassArea * GLASS_PRICE_PER_SQM);
|
||||
|
||||
let mechanismSurcharge = 0;
|
||||
@@ -109,8 +156,9 @@ export function calculatePrice(
|
||||
|
||||
const handleCost = HANDLE_PRICES[handle] || 0;
|
||||
const sidePanelSurchrg = sidePanelCount * SIDE_PANEL_SURCHARGE;
|
||||
const finishSurcharge = FINISH_SURCHARGES[finish] ?? 0;
|
||||
|
||||
const totalPrice = steelCost + glassCost + BASE_FEE + mechanismSurcharge + sidePanelSurchrg + handleCost;
|
||||
const totalPrice = steelCost + glassCost + BASE_FEE + mechanismSurcharge + sidePanelSurchrg + handleCost + finishSurcharge;
|
||||
|
||||
return {
|
||||
steelCost,
|
||||
@@ -119,6 +167,7 @@ export function calculatePrice(
|
||||
mechanismSurcharge,
|
||||
sidePanelSurcharge: sidePanelSurchrg,
|
||||
handleCost,
|
||||
finishSurcharge,
|
||||
totalPrice,
|
||||
steelLengthM: Math.round(totalSteelLength * 100) / 100,
|
||||
glassAreaSqm: Math.round(totalGlassArea * 100) / 100,
|
||||
|
||||
88
lib/store.ts
88
lib/store.ts
@@ -12,8 +12,20 @@ import type { GlassPattern } from './glass-patterns';
|
||||
import { calculatePrice, type PriceBreakdown } from './pricing';
|
||||
|
||||
export type DoorType = 'taats' | 'scharnier' | 'paneel';
|
||||
export type GridType = '3-vlak' | '4-vlak' | 'geen';
|
||||
export type Finish = 'zwart' | 'brons' | 'grijs';
|
||||
export type GridType =
|
||||
| 'geen'
|
||||
| '2-vlak'
|
||||
| '3-vlak'
|
||||
| '4-vlak'
|
||||
| '6-vlak'
|
||||
| '8-vlak'
|
||||
| 'kruis'
|
||||
| 'ongelijk-3'
|
||||
| 'boerderij'
|
||||
| 'herenhuis';
|
||||
export type Finish = 'zwart' | 'brons' | 'grijs' | 'goud' | 'beige' | 'ral';
|
||||
export type GlassColor = 'helder' | 'grijs' | 'brons' | 'mat-blank' | 'mat-brons' | 'mat-zwart';
|
||||
export type FrameSize = 20 | 30 | 40;
|
||||
export type Handle = 'beugelgreep' | 'hoekgreep' | 'maangreep' | 'ovaalgreep' | 'klink' | 'u-greep' | 'geen';
|
||||
|
||||
interface ConfiguratorState {
|
||||
@@ -25,8 +37,10 @@ interface ConfiguratorState {
|
||||
// Styling
|
||||
gridType: GridType;
|
||||
finish: Finish;
|
||||
glassColor: GlassColor;
|
||||
handle: Handle;
|
||||
glassPattern: GlassPattern;
|
||||
frameSize: FrameSize;
|
||||
|
||||
// Dimensions (in mm)
|
||||
width: number;
|
||||
@@ -42,17 +56,37 @@ interface ConfiguratorState {
|
||||
// Pricing
|
||||
priceBreakdown: PriceBreakdown;
|
||||
|
||||
// Contact info
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
note: string;
|
||||
|
||||
// Extra options
|
||||
extraOptions: string[];
|
||||
|
||||
// Screenshot
|
||||
screenshotDataUrl: string | null;
|
||||
|
||||
// Actions
|
||||
setDoorType: (type: DoorType) => void;
|
||||
setDoorConfig: (config: DoorConfig) => void;
|
||||
setSidePanel: (panel: SidePanel) => void;
|
||||
setGridType: (type: GridType) => void;
|
||||
setFinish: (finish: Finish) => void;
|
||||
setGlassColor: (color: GlassColor) => void;
|
||||
setHandle: (handle: Handle) => void;
|
||||
setGlassPattern: (pattern: GlassPattern) => void;
|
||||
setFrameSize: (size: FrameSize) => void;
|
||||
setWidth: (width: number) => void;
|
||||
setHeight: (height: number) => void;
|
||||
setDimensions: (width: number, height: number) => void;
|
||||
setName: (name: string) => void;
|
||||
setEmail: (email: string) => void;
|
||||
setPhone: (phone: string) => void;
|
||||
setNote: (note: string) => void;
|
||||
toggleExtraOption: (option: string) => void;
|
||||
setScreenshotDataUrl: (url: string | null) => void;
|
||||
}
|
||||
|
||||
// Helper function for recalculation
|
||||
@@ -70,8 +104,8 @@ const recalculate = (get: () => ConfiguratorState, set: (state: Partial<Configur
|
||||
|
||||
// Helper function for price recalculation
|
||||
const recalculatePrice = (get: () => ConfiguratorState, set: (state: Partial<ConfiguratorState>) => void) => {
|
||||
const { doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle } = get();
|
||||
const priceBreakdown = calculatePrice(doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle);
|
||||
const { doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle, finish, frameSize } = get();
|
||||
const priceBreakdown = calculatePrice(doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle, finish, frameSize);
|
||||
set({ priceBreakdown });
|
||||
};
|
||||
|
||||
@@ -82,8 +116,10 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
sidePanel: 'geen',
|
||||
gridType: '3-vlak',
|
||||
finish: 'zwart',
|
||||
glassColor: 'helder',
|
||||
handle: 'beugelgreep',
|
||||
glassPattern: 'standard',
|
||||
frameSize: 40,
|
||||
width: 1000,
|
||||
height: 2400,
|
||||
|
||||
@@ -94,8 +130,20 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
minWidth: 860,
|
||||
maxWidth: 1360,
|
||||
|
||||
// Initial price (computed with defaults: taats, 3-vlak, enkele, geen, beugelgreep, 1000x2400)
|
||||
priceBreakdown: calculatePrice(1000, 2400, 'taats', '3-vlak', 'enkele', 'geen', 'beugelgreep'),
|
||||
// Initial price
|
||||
priceBreakdown: calculatePrice(1000, 2400, 'taats', '3-vlak', 'enkele', 'geen', 'beugelgreep', 'zwart', 40),
|
||||
|
||||
// Contact info
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
note: '',
|
||||
|
||||
// Extra options
|
||||
extraOptions: [],
|
||||
|
||||
// Screenshot
|
||||
screenshotDataUrl: null,
|
||||
|
||||
// Actions with automatic recalculation
|
||||
setDoorType: (doorType) => {
|
||||
@@ -121,7 +169,12 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setFinish: (finish) => set({ finish }),
|
||||
setFinish: (finish) => {
|
||||
set({ finish });
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setGlassColor: (glassColor) => set({ glassColor }),
|
||||
|
||||
setHandle: (handle) => {
|
||||
set({ handle });
|
||||
@@ -130,6 +183,11 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
|
||||
setGlassPattern: (glassPattern) => set({ glassPattern }),
|
||||
|
||||
setFrameSize: (frameSize) => {
|
||||
set({ frameSize });
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setWidth: (width) => {
|
||||
const { doorConfig, sidePanel } = get();
|
||||
const minWidth = calculateHoleMinWidth(doorConfig, sidePanel);
|
||||
@@ -151,4 +209,20 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
get().setWidth(width);
|
||||
get().setHeight(height);
|
||||
},
|
||||
|
||||
setName: (name) => set({ name }),
|
||||
setEmail: (email) => set({ email }),
|
||||
setPhone: (phone) => set({ phone }),
|
||||
setNote: (note) => set({ note }),
|
||||
|
||||
toggleExtraOption: (option) => {
|
||||
const { extraOptions } = get();
|
||||
if (extraOptions.includes(option)) {
|
||||
set({ extraOptions: extraOptions.filter((o) => o !== option) });
|
||||
} else {
|
||||
set({ extraOptions: [...extraOptions, option] });
|
||||
}
|
||||
},
|
||||
|
||||
setScreenshotDataUrl: (screenshotDataUrl) => set({ screenshotDataUrl }),
|
||||
}));
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// ── Step 1: Product ──────────────────────────────────────────────
|
||||
export const productTypes = ["Taatsdeur", "Scharnierdeur", "Vast Paneel"] as const;
|
||||
export const doorTypes = ["taats", "scharnier", "paneel"] as const;
|
||||
export const gridTypes = [
|
||||
"geen", "2-vlak", "3-vlak", "4-vlak", "6-vlak", "8-vlak",
|
||||
"kruis", "ongelijk-3", "boerderij", "herenhuis",
|
||||
] as const;
|
||||
|
||||
export const productSchema = z.object({
|
||||
productType: z.enum(productTypes),
|
||||
doorType: z.enum(doorTypes),
|
||||
gridType: z.enum(gridTypes),
|
||||
doorConfig: z.enum(["enkele", "dubbele"]),
|
||||
sidePanel: z.enum(["geen", "links", "rechts", "beide"]),
|
||||
});
|
||||
|
||||
// ── Step 2: Dimensions ──────────────────────────────────────────
|
||||
export const dimensionsSchema = z.object({
|
||||
height: z
|
||||
.number({ error: "Vul een geldige hoogte in" })
|
||||
.min(2000, "Minimaal 2000mm")
|
||||
.min(1800, "Minimaal 1800mm")
|
||||
.max(3000, "Maximaal 3000mm"),
|
||||
width: z
|
||||
.number({ error: "Vul een geldige breedte in" })
|
||||
@@ -20,15 +27,26 @@ export const dimensionsSchema = z.object({
|
||||
});
|
||||
|
||||
// ── Step 3: Options ─────────────────────────────────────────────
|
||||
export const glassTypes = ["Helder", "Rookglas", "Melkglas"] as const;
|
||||
export const finishTypes = ["Poedercoat Zwart", "Goud", "Brons"] as const;
|
||||
export const finishTypes = ["zwart", "brons", "grijs", "goud", "beige", "ral"] as const;
|
||||
export const glassColorTypes = ["helder", "grijs", "brons", "mat-blank", "mat-brons", "mat-zwart"] as const;
|
||||
export const handleTypes = [
|
||||
"beugelgreep", "hoekgreep", "maangreep", "ovaalgreep", "klink", "u-greep", "geen",
|
||||
] as const;
|
||||
export const frameSizes = [20, 30, 40] as const;
|
||||
|
||||
export const optionsSchema = z.object({
|
||||
glassType: z.enum(glassTypes),
|
||||
finish: z.enum(finishTypes),
|
||||
glassColor: z.enum(glassColorTypes),
|
||||
handle: z.enum(handleTypes),
|
||||
frameSize: z.enum(["20", "30", "40"]).transform(Number),
|
||||
});
|
||||
|
||||
// ── Step 4: Contact ─────────────────────────────────────────────
|
||||
// ── Step 4: Extras ──────────────────────────────────────────────
|
||||
export const extrasSchema = z.object({
|
||||
extraOptions: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── Step 5: Contact ─────────────────────────────────────────────
|
||||
export const contactSchema = z.object({
|
||||
name: z.string().min(2, "Vul uw naam in"),
|
||||
email: z.string().email("Vul een geldig e-mailadres in"),
|
||||
@@ -40,6 +58,7 @@ export const contactSchema = z.object({
|
||||
export const quoteSchema = productSchema
|
||||
.merge(dimensionsSchema)
|
||||
.merge(optionsSchema)
|
||||
.merge(extrasSchema)
|
||||
.merge(contactSchema);
|
||||
|
||||
export type QuoteData = z.infer<typeof quoteSchema>;
|
||||
@@ -49,5 +68,6 @@ export const stepSchemas = [
|
||||
productSchema,
|
||||
dimensionsSchema,
|
||||
optionsSchema,
|
||||
extrasSchema,
|
||||
contactSchema,
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user