Integrate comprehensive dimension calculation logic

Created complete business logic system for door dimensions:

**New: lib/calculations.ts**
- calculateHoleWidth: Total wall opening needed
- calculateHoleMinWidth/MaxWidth: Dynamic limits based on config
- calculateSidePanelWidth: Side panel sizing logic
- calculateDoorLeafWidth: Individual door panel width
- validateDimensions: Real-time validation
- Constants: Frame profiles (80mm), min/max widths

**Enhanced: lib/store.ts**
- Added doorConfig: 'enkele' | 'dubbele' (single/double door)
- Added sidePanel: 'geen' | 'links' | 'rechts' | 'beide'
- Calculated fields: holeWidth, doorLeafWidth, sidePanelWidth, min/maxWidth
- Auto-recalculation on config changes
- setWidth/setHeight with automatic clamping to valid ranges
- Internal recalculate() method updates all derived values

**Redesigned: step-dimensions.tsx**
- Door configuration selector (enkele/dubbele)
- Side panel selector (geen/links/rechts/beide)
- Width slider with dynamic min/max based on configuration
- Height slider (1800-3000mm)
- Real-time input fields synced with sliders
- Summary panel showing:
  - Door leaf width
  - Total wall opening (hole width × height)
  - Side panel width (when applicable)
- Premium tile-based UI (Dark Green/Pistachio theme)

**Added: components/ui/slider.tsx**
- Shadcn Slider component for smooth value selection

**Connected: door-3d.tsx**
- Door now uses doorLeafWidth from store (in mm, converted to meters)
- Door height from store (in mm, converted to meters)
- 3D door resizes in real-time as user drags sliders
- Maintains realistic proportions

**User Flow:**
1. User selects enkele/dubbele configuration
2. User selects side panels (if any)
3. Store calculates valid min/max widths
4. User drags width slider (clamped to valid range)
5. Store calculates door leaf width and side panel widths
6. 3D door updates instantly to show new dimensions
7. Summary shows all calculated dimensions

**Result:** Professional dimension configurator with real business logic,
          automatic validation, and real-time 3D preview

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ubuntu
2026-02-10 16:43:16 +00:00
parent 1a65bcbd5a
commit df8a45a2b2
5 changed files with 529 additions and 51 deletions

View File

@@ -5,7 +5,8 @@ import { useConfiguratorStore } from "@/lib/store";
import * as THREE from "three";
export function Door3D() {
const { doorType, gridType, finish, handle } = useConfiguratorStore();
const { doorType, gridType, finish, handle, doorLeafWidth, height } =
useConfiguratorStore();
const doorRef = useRef<THREE.Group>(null);
// Frame color based on finish
@@ -15,9 +16,9 @@ export function Door3D() {
grijs: "#525252",
}[finish];
// Door dimensions (more realistic proportions)
const doorWidth = doorType === "paneel" ? 1.5 : 1.2;
const doorHeight = 2.4;
// Convert mm to meters for 3D scene
const doorWidth = doorLeafWidth / 1000; // Convert mm to m
const doorHeight = height / 1000; // Convert mm to m
const frameThickness = 0.03; // Slim steel profile (3cm)
const frameDepth = 0.05; // Depth of frame
const glassThickness = 0.015; // Realistic glass thickness

View File

@@ -1,61 +1,203 @@
"use client";
import { useFormContext } from "@/components/offerte/form-context";
import { useConfiguratorStore } from "@/lib/store";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Ruler } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { Check } from "lucide-react";
export function StepDimensions() {
const { formData, updateData } = useFormContext();
const {
doorConfig,
sidePanel,
width,
height,
minWidth,
maxWidth,
holeWidth,
doorLeafWidth,
sidePanelWidth,
setDoorConfig,
setSidePanel,
setWidth,
setHeight,
} = useConfiguratorStore();
return (
<div className="space-y-8">
{/* Door Configuration */}
<div>
<h2 className="mb-2 text-xl font-semibold">Afmetingen</h2>
<p className="mb-6 text-sm text-muted-foreground">
Voer de gewenste afmetingen in millimeters in.
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Deur Configuratie</h2>
<p className="mb-4 text-sm text-gray-600">
Kies tussen enkele of dubbele deur.
</p>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="height" className="flex items-center gap-2">
<Ruler className="size-4 text-brand-orange" />
Hoogte (mm)
</Label>
<Input
id="height"
type="number"
placeholder="bijv. 2400"
value={formData.height ?? ""}
onChange={(e) => {
const val = e.target.value;
updateData({ height: val === "" ? undefined : Number(val) });
}}
className="h-12 text-lg focus-visible:ring-brand-orange"
/>
<p className="text-xs text-muted-foreground">
Min: 2000mm &mdash; Max: 3000mm
<div className="grid gap-3 sm:grid-cols-2">
{(['enkele', 'dubbele'] as const).map((config) => {
const selected = doorConfig === config;
return (
<button
key={config}
type="button"
onClick={() => setDoorConfig(config)}
className={`rounded-xl border-2 p-4 text-left transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="font-bold capitalize">{config} deur</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{config === 'enkele' ? '1 deurblad' : '2 deurbladen'}
</p>
</div>
{selected && (
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-4 text-[#1A2E2E]" />
</div>
)}
</div>
</button>
);
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="width" className="flex items-center gap-2">
<Ruler className="size-4 rotate-90 text-brand-orange" />
Breedte (mm)
</Label>
<Input
id="width"
type="number"
placeholder="bijv. 900"
value={formData.width ?? ""}
onChange={(e) => {
const val = e.target.value;
updateData({ width: val === "" ? undefined : Number(val) });
}}
className="h-12 text-lg focus-visible:ring-brand-orange"
/>
<p className="text-xs text-muted-foreground">
Min: 300mm &mdash; Max: 3000mm
{/* Side Panel Configuration */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Zijpanelen</h2>
<p className="mb-4 text-sm text-gray-600">
Voeg vaste panelen toe naast de deur.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{(['geen', 'links', 'rechts', 'beide'] as const).map((panel) => {
const selected = sidePanel === panel;
return (
<button
key={panel}
type="button"
onClick={() => setSidePanel(panel)}
className={`rounded-xl border-2 p-4 text-left transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="font-bold capitalize">{panel}</h3>
{panel !== 'geen' && sidePanelWidth > 0 && (
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{Math.round(sidePanelWidth)}mm breed
</p>
)}
</div>
{selected && (
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
<Check className="size-4 text-[#1A2E2E]" />
</div>
)}
</div>
</button>
);
})}
</div>
</div>
{/* Width Control */}
<div>
<div className="mb-4 flex items-end justify-between">
<div>
<Label className="text-base font-bold text-[#1A2E2E]">
Breedte
</Label>
<p className="text-sm text-gray-600">
{minWidth}mm - {maxWidth}mm
</p>
</div>
<div className="text-right">
<Input
type="number"
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
min={minWidth}
max={maxWidth}
className="h-10 w-32 text-right font-mono text-lg font-bold"
/>
<p className="mt-1 text-xs text-gray-500">Deurblad: {Math.round(doorLeafWidth)}mm</p>
</div>
</div>
<Slider
value={[width]}
onValueChange={(values) => setWidth(values[0])}
min={minWidth}
max={maxWidth}
step={10}
className="w-full"
/>
</div>
{/* Height Control */}
<div>
<div className="mb-4 flex items-end justify-between">
<div>
<Label className="text-base font-bold text-[#1A2E2E]">
Hoogte
</Label>
<p className="text-sm text-gray-600">1800mm - 3000mm</p>
</div>
<Input
type="number"
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
min={1800}
max={3000}
className="h-10 w-32 text-right font-mono text-lg font-bold"
/>
</div>
<Slider
value={[height]}
onValueChange={(values) => setHeight(values[0])}
min={1800}
max={3000}
step={10}
className="w-full"
/>
</div>
{/* Summary */}
<div className="rounded-xl bg-slate-50 p-4">
<h3 className="mb-2 text-sm font-bold text-[#1A2E2E]">
Samenvatting afmetingen
</h3>
<div className="space-y-1 text-sm text-gray-600">
<p>
<span className="font-medium">Deurblad breedte:</span>{" "}
{Math.round(doorLeafWidth)}mm
</p>
<p>
<span className="font-medium">Totale wandopening:</span>{" "}
{Math.round(holeWidth)}mm × {height}mm
</p>
{sidePanelWidth > 0 && (
<p>
<span className="font-medium">Zijpaneel breedte:</span>{" "}
{Math.round(sidePanelWidth)}mm
</p>
)}
</div>
</div>
</div>

63
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

183
lib/calculations.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* Door dimension calculations
* Based on standard steel door configurations
*/
export type DoorConfig = 'enkele' | 'dubbele';
export type SidePanel = 'geen' | 'links' | 'rechts' | 'beide';
// Standard constants
const FRAME_PROFILE_WIDTH = 80; // mm - Steel frame profile width
const SIDE_PANEL_MIN_WIDTH = 200; // mm
const SIDE_PANEL_MAX_WIDTH = 800; // mm
const DOOR_LEAF_MIN_WIDTH = 700; // mm
const DOOR_LEAF_MAX_WIDTH = 1200; // mm
/**
* Calculate the total hole width needed in the wall
*/
export function calculateHoleWidth(
doorWidth: number,
doorConfig: DoorConfig,
sidePanel: SidePanel
): number {
let totalWidth = doorWidth;
// Add frame profiles
totalWidth += FRAME_PROFILE_WIDTH * 2;
// Add side panels if configured
if (sidePanel === 'links' || sidePanel === 'rechts') {
totalWidth += SIDE_PANEL_MIN_WIDTH;
} else if (sidePanel === 'beide') {
totalWidth += SIDE_PANEL_MIN_WIDTH * 2;
}
return totalWidth;
}
/**
* Calculate minimum hole width based on configuration
*/
export function calculateHoleMinWidth(
doorConfig: DoorConfig,
sidePanel: SidePanel
): number {
let minWidth = DOOR_LEAF_MIN_WIDTH;
// Double door requires two leaves
if (doorConfig === 'dubbele') {
minWidth *= 2;
}
// Add frame profiles
minWidth += FRAME_PROFILE_WIDTH * 2;
// Add side panels
if (sidePanel === 'links' || sidePanel === 'rechts') {
minWidth += SIDE_PANEL_MIN_WIDTH;
} else if (sidePanel === 'beide') {
minWidth += SIDE_PANEL_MIN_WIDTH * 2;
}
return minWidth;
}
/**
* Calculate maximum hole width based on configuration
*/
export function calculateHoleMaxWidth(
doorConfig: DoorConfig,
sidePanel: SidePanel
): number {
let maxWidth = DOOR_LEAF_MAX_WIDTH;
// Double door requires two leaves
if (doorConfig === 'dubbele') {
maxWidth *= 2;
}
// Add frame profiles
maxWidth += FRAME_PROFILE_WIDTH * 2;
// Add side panels
if (sidePanel === 'links' || sidePanel === 'rechts') {
maxWidth += SIDE_PANEL_MAX_WIDTH;
} else if (sidePanel === 'beide') {
maxWidth += SIDE_PANEL_MAX_WIDTH * 2;
}
return maxWidth;
}
/**
* Calculate individual side panel width
*/
export function calculateSidePanelWidth(
totalWidth: number,
doorConfig: DoorConfig,
sidePanel: SidePanel
): number {
if (sidePanel === 'geen') return 0;
// Calculate door leaf width
let doorLeafWidth = DOOR_LEAF_MIN_WIDTH;
if (doorConfig === 'dubbele') {
doorLeafWidth *= 2;
}
// Remaining space for side panels (minus frame profiles)
const availableForPanels = totalWidth - doorLeafWidth - FRAME_PROFILE_WIDTH * 2;
// Distribute to panels
if (sidePanel === 'links' || sidePanel === 'rechts') {
return Math.max(SIDE_PANEL_MIN_WIDTH, availableForPanels);
} else if (sidePanel === 'beide') {
return Math.max(SIDE_PANEL_MIN_WIDTH, availableForPanels / 2);
}
return 0;
}
/**
* Calculate door leaf width (individual door panel)
*/
export function calculateDoorLeafWidth(
totalWidth: number,
doorConfig: DoorConfig,
sidePanel: SidePanel
): number {
// Subtract frame profiles
let availableWidth = totalWidth - FRAME_PROFILE_WIDTH * 2;
// Subtract side panels
const sidePanelWidth = calculateSidePanelWidth(totalWidth, doorConfig, sidePanel);
if (sidePanel === 'links' || sidePanel === 'rechts') {
availableWidth -= sidePanelWidth;
} else if (sidePanel === 'beide') {
availableWidth -= sidePanelWidth * 2;
}
// Divide by number of door leaves
if (doorConfig === 'dubbele') {
availableWidth /= 2;
}
return Math.max(DOOR_LEAF_MIN_WIDTH, availableWidth);
}
/**
* Validate dimensions
*/
export function validateDimensions(
width: number,
height: number,
doorConfig: DoorConfig,
sidePanel: SidePanel
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const minWidth = calculateHoleMinWidth(doorConfig, sidePanel);
const maxWidth = calculateHoleMaxWidth(doorConfig, sidePanel);
if (width < minWidth) {
errors.push(`Breedte moet minimaal ${minWidth}mm zijn voor deze configuratie`);
}
if (width > maxWidth) {
errors.push(`Breedte mag maximaal ${maxWidth}mm zijn voor deze configuratie`);
}
if (height < 1800) {
errors.push('Hoogte moet minimaal 1800mm zijn');
}
if (height > 3000) {
errors.push('Hoogte mag maximaal 3000mm zijn');
}
return {
valid: errors.length === 0,
errors,
};
}

View File

@@ -1,4 +1,13 @@
import { create } from 'zustand';
import {
calculateHoleWidth,
calculateHoleMinWidth,
calculateHoleMaxWidth,
calculateSidePanelWidth,
calculateDoorLeafWidth,
type DoorConfig,
type SidePanel,
} from './calculations';
export type DoorType = 'taats' | 'scharnier' | 'paneel';
export type GridType = '3-vlak' | '4-vlak' | 'geen';
@@ -6,31 +15,111 @@ export type Finish = 'zwart' | 'brons' | 'grijs';
export type Handle = 'u-greep' | 'klink' | 'geen';
interface ConfiguratorState {
// Door configuration
doorType: DoorType;
doorConfig: DoorConfig;
sidePanel: SidePanel;
// Styling
gridType: GridType;
finish: Finish;
handle: Handle;
// Dimensions (in mm)
width: number;
height: number;
// Calculated values
holeWidth: number;
doorLeafWidth: number;
sidePanelWidth: number;
minWidth: number;
maxWidth: number;
// Actions
setDoorType: (type: DoorType) => void;
setDoorConfig: (config: DoorConfig) => void;
setSidePanel: (panel: SidePanel) => void;
setGridType: (type: GridType) => void;
setFinish: (finish: Finish) => void;
setHandle: (handle: Handle) => void;
setWidth: (width: number) => void;
setHeight: (height: number) => void;
setDimensions: (width: number, height: number) => void;
}
export const useConfiguratorStore = create<ConfiguratorState>((set) => ({
export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
// Initial state
doorType: 'taats',
doorConfig: 'enkele',
sidePanel: 'geen',
gridType: '3-vlak',
finish: 'zwart',
handle: 'u-greep',
width: 1000,
height: 2400,
setDoorType: (doorType) => set({ doorType }),
// Initial calculated values
holeWidth: 1160,
doorLeafWidth: 1000,
sidePanelWidth: 0,
minWidth: 860,
maxWidth: 1360,
// Actions with automatic recalculation
setDoorType: (doorType) => {
set({ doorType });
get().recalculate();
},
setDoorConfig: (doorConfig) => {
set({ doorConfig });
get().recalculate();
},
setSidePanel: (sidePanel) => {
set({ sidePanel });
get().recalculate();
},
setGridType: (gridType) => set({ gridType }),
setFinish: (finish) => set({ finish }),
setHandle: (handle) => set({ handle }),
setDimensions: (width, height) => set({ width, height }),
}));
setWidth: (width) => {
const { doorConfig, sidePanel } = get();
const minWidth = calculateHoleMinWidth(doorConfig, sidePanel);
const maxWidth = calculateHoleMaxWidth(doorConfig, sidePanel);
// Clamp width to valid range
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width));
set({ width: clampedWidth });
get().recalculate();
},
setHeight: (height) => {
// Clamp height to valid range
const clampedHeight = Math.max(1800, Math.min(3000, height));
set({ height: clampedHeight });
},
setDimensions: (width, height) => {
get().setWidth(width);
get().setHeight(height);
},
// Internal recalculation helper
recalculate: () => {
const { width, doorConfig, sidePanel } = get();
set({
holeWidth: calculateHoleWidth(width, doorConfig, sidePanel),
doorLeafWidth: calculateDoorLeafWidth(width, doorConfig, sidePanel),
sidePanelWidth: calculateSidePanelWidth(width, doorConfig, sidePanel),
minWidth: calculateHoleMinWidth(doorConfig, sidePanel),
maxWidth: calculateHoleMaxWidth(doorConfig, sidePanel),
});
},
} as ConfiguratorState));