Treat object count as optimizer spec; drag cards onto room to pin/lock, optimizer fills the rest
This commit is contained in:
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>furniture-app</title>
|
||||
<script type="module" crossorigin src="/assets/index-Be3LQNz7.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CCxBSCEa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Gl7EQEpA.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+14
-72
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RoomCanvas } from './components/RoomCanvas';
|
||||
import { ControlPanel } from './components/ControlPanel';
|
||||
import type { FurnitureConfig, FurniturePreset, LayoutState } from './types';
|
||||
import type { LayoutState } from './types';
|
||||
import { runRandomSearch } from './optimization/randomSearch';
|
||||
import { optimizeWithNN } from './ai/nn';
|
||||
import { prepareForOptimize } from './optimization/prepare';
|
||||
import { DEFAULT_PRESETS } from './furniture/presets';
|
||||
import { clampRectToRoom, clampStateToRoom } from './geometry/bounds';
|
||||
|
||||
const initialState: LayoutState = {
|
||||
room: { width: 600, height: 400, gridSize: 10 },
|
||||
@@ -14,70 +14,6 @@ const initialState: LayoutState = {
|
||||
presets: DEFAULT_PRESETS,
|
||||
};
|
||||
|
||||
function makeFurniture(preset: FurniturePreset, index: number, room: LayoutState['room']): FurnitureConfig {
|
||||
const spawn = clampRectToRoom(
|
||||
{
|
||||
x: 80 + (index % 5) * 60,
|
||||
y: 80 + Math.floor(index / 5) * 60,
|
||||
width: preset.shape.width,
|
||||
height: preset.shape.height,
|
||||
},
|
||||
room,
|
||||
);
|
||||
return {
|
||||
id: `${preset.id}_${Date.now()}_${index}`,
|
||||
name: `${preset.name} ${index + 1}`,
|
||||
type: preset.id,
|
||||
shape: {
|
||||
...preset.shape,
|
||||
x: spawn.x,
|
||||
y: spawn.y,
|
||||
width: spawn.width,
|
||||
height: spawn.height,
|
||||
},
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
};
|
||||
}
|
||||
|
||||
function syncFurniture(state: LayoutState): LayoutState {
|
||||
const presets = state.presets || [];
|
||||
const validIds = new Set(presets.map((p) => p.id));
|
||||
let furniture = state.furniture.filter((f) => !f.type || f.type === 'custom' || validIds.has(f.type));
|
||||
|
||||
for (const preset of presets) {
|
||||
const desired = preset.amount ?? 0;
|
||||
const existing = furniture.filter((f) => f.type === preset.id);
|
||||
|
||||
if (existing.length > desired) {
|
||||
const removeIds = new Set(
|
||||
[...existing]
|
||||
.sort((a, b) => Number(a.placed) - Number(b.placed))
|
||||
.slice(0, existing.length - desired)
|
||||
.map((f) => f.id),
|
||||
);
|
||||
furniture = furniture.filter((f) => !removeIds.has(f.id));
|
||||
} else if (existing.length < desired) {
|
||||
for (let i = existing.length; i < desired; i++) {
|
||||
furniture.push(makeFurniture(preset, i, state.room));
|
||||
}
|
||||
}
|
||||
|
||||
furniture = furniture.map((f) =>
|
||||
f.type === preset.id
|
||||
? {
|
||||
...f,
|
||||
shape: { ...f.shape, width: preset.shape.width, height: preset.shape.height },
|
||||
color: preset.color,
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
}
|
||||
: f,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...state, furniture };
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<LayoutState>(initialState);
|
||||
const [mode, setMode] = useState<'layout' | 'optimize'>('layout');
|
||||
@@ -86,8 +22,17 @@ function App() {
|
||||
const [timeLeft, setTimeLeft] = useState(15);
|
||||
|
||||
useEffect(() => {
|
||||
setState((s) => clampStateToRoom(syncFurniture(s)));
|
||||
}, [state.presets]);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
|
||||
if (!selectedId || selectedId === '__entrance__') return;
|
||||
setState((s) => ({ ...s, furniture: s.furniture.filter((f) => f.id !== selectedId) }));
|
||||
setSelectedId(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optimizing) return;
|
||||
@@ -110,10 +55,7 @@ function App() {
|
||||
}, [optimizing, timeLeft]);
|
||||
|
||||
const handleOptimize = () => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
furniture: s.furniture.map((f) => ({ ...f, lockedForOptimization: f.placed ? true : false })),
|
||||
}));
|
||||
setState((s) => prepareForOptimize(s));
|
||||
setOptimizing(true);
|
||||
setTimeLeft(15);
|
||||
};
|
||||
|
||||
@@ -140,12 +140,22 @@ export function ControlPanel({
|
||||
|
||||
const renderObjectCard = (p: FurniturePreset) => {
|
||||
const open = openId === p.id;
|
||||
const placedCount = state.furniture.filter((f) => f.type === p.id && f.placed).length;
|
||||
return (
|
||||
<div key={p.id} className={`obj-card ${open ? 'open' : ''}`}>
|
||||
<div className="obj-head" onClick={() => toggleOpen(p.id)}>
|
||||
<div
|
||||
className="obj-head"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-preset-id', p.id);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
onClick={() => toggleOpen(p.id)}
|
||||
>
|
||||
<span className={`obj-caret ${open ? 'open' : ''}`}>▶</span>
|
||||
<span className="obj-swatch" style={{ background: p.color }} />
|
||||
<span className="obj-name">{p.name}</span>
|
||||
{placedCount > 0 && <span className="obj-badge">{placedCount} placed</span>}
|
||||
<span className="obj-count" onClick={(e) => e.stopPropagation()}>
|
||||
<NumberField value={p.amount ?? 0} min={0} width={44} onChange={(v) => setAmount(p.id, v)} />
|
||||
</span>
|
||||
@@ -340,6 +350,10 @@ export function ControlPanel({
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Objects</div>
|
||||
<div className="hint">
|
||||
Count tells the optimizer how many are needed. Drag a card onto the room to pin one in place
|
||||
(locked); the optimizer arranges the rest.
|
||||
</div>
|
||||
<div className="obj-list">{objects.map(renderObjectCard)}</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import type { LayoutState, Point } from '../types';
|
||||
import { computePaths } from '../pathfinding/paths';
|
||||
import { clampRectToRoom } from '../geometry/bounds';
|
||||
import { makePlaced } from '../optimization/prepare';
|
||||
|
||||
interface RoomCanvasProps {
|
||||
state: LayoutState;
|
||||
@@ -156,7 +157,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>): Point => {
|
||||
const getMousePos = (e: { clientX: number; clientY: number }): Point => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -250,6 +251,23 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const presetId = e.dataTransfer.getData('application/x-preset-id');
|
||||
if (!presetId) return;
|
||||
const preset = (state.presets || []).find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const pos = getMousePos(e);
|
||||
const f = makePlaced(preset, pos.x - preset.shape.width / 2, pos.y - preset.shape.height / 2, state.room);
|
||||
onStateChange({ ...state, furniture: [...state.furniture, f] });
|
||||
onSelect(f.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
@@ -259,6 +277,8 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
||||
import { clampRectToRoom, clampStateToRoom } from '../geometry/bounds';
|
||||
|
||||
export function makeGenerated(preset: FurniturePreset, index: number, room: RoomConfig): FurnitureConfig {
|
||||
const b = clampRectToRoom(
|
||||
{
|
||||
x: Math.random() * Math.max(0, room.width - preset.shape.width),
|
||||
y: Math.random() * Math.max(0, room.height - preset.shape.height),
|
||||
width: preset.shape.width,
|
||||
height: preset.shape.height,
|
||||
},
|
||||
room,
|
||||
);
|
||||
return {
|
||||
id: `${preset.id}_gen_${Date.now()}_${index}`,
|
||||
name: preset.name,
|
||||
type: preset.id,
|
||||
shape: { ...preset.shape, ...b },
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
placed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function makePlaced(preset: FurniturePreset, x: number, y: number, room: RoomConfig): FurnitureConfig {
|
||||
const b = clampRectToRoom(
|
||||
{ x, y, width: preset.shape.width, height: preset.shape.height },
|
||||
room,
|
||||
);
|
||||
return {
|
||||
id: `${preset.id}_placed_${Date.now()}_${Math.round(Math.random() * 1e6)}`,
|
||||
name: preset.name,
|
||||
type: preset.id,
|
||||
shape: { ...preset.shape, ...b },
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
placed: true,
|
||||
lockedForOptimization: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareForOptimize(state: LayoutState): LayoutState {
|
||||
const presets = state.presets || [];
|
||||
let furniture = [...state.furniture];
|
||||
|
||||
for (const preset of presets) {
|
||||
const wanted = preset.amount ?? 0;
|
||||
const ofType = furniture.filter((f) => f.type === preset.id);
|
||||
const placed = ofType.filter((f) => f.placed);
|
||||
let generated = ofType.filter((f) => !f.placed);
|
||||
|
||||
const need = Math.max(0, wanted - placed.length);
|
||||
if (generated.length > need) {
|
||||
generated = generated.slice(0, need);
|
||||
}
|
||||
while (generated.length < need) {
|
||||
generated.push(makeGenerated(preset, generated.length, state.room));
|
||||
}
|
||||
|
||||
const synced = [...placed, ...generated].map((f) => ({
|
||||
...f,
|
||||
name: preset.name,
|
||||
color: preset.color,
|
||||
shape: { ...f.shape, width: preset.shape.width, height: preset.shape.height },
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
lockedForOptimization: !!f.placed,
|
||||
}));
|
||||
|
||||
furniture = furniture.filter((f) => f.type !== preset.id);
|
||||
furniture = [...furniture, ...synced];
|
||||
}
|
||||
|
||||
return clampStateToRoom({ ...state, furniture });
|
||||
}
|
||||
Reference in New Issue
Block a user