feat(furniture): add scratching post with simple, tree, condo, wall variants
New SCRATCHING_POST type under a new "pets" category with 8 presets covering single posts, multi-tier cat trees, enclosed condos, and wall-mounted scratcher pads. Includes 3D meshes, i18n labels (en/ru), and palette integration.
This commit is contained in:
@@ -323,6 +323,7 @@
|
||||
"furnitureCategory.electronics": "Electronics",
|
||||
"furnitureCategory.climate": "Climate",
|
||||
"furnitureCategory.decor": "Decor",
|
||||
"furnitureCategory.pets": "Pets",
|
||||
|
||||
"cableLength.label": "Cable length:",
|
||||
|
||||
|
||||
@@ -326,6 +326,7 @@
|
||||
"furnitureCategory.electronics": "Электроника",
|
||||
"furnitureCategory.climate": "Климат",
|
||||
"furnitureCategory.decor": "Декор",
|
||||
"furnitureCategory.pets": "Питомцы",
|
||||
|
||||
"cableLength.label": "Длина кабеля:",
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const CATEGORY_META: Record<FurnitureCategory, { icon: string; key: string }> =
|
||||
electronics: { icon: '\u{1F4FA}', key: 'furnitureCategory.electronics' },
|
||||
climate: { icon: '\u{1F525}', key: 'furnitureCategory.climate' },
|
||||
decor: { icon: '\u{1F5BC}', key: 'furnitureCategory.decor' },
|
||||
pets: { icon: '\u{1F431}', key: 'furnitureCategory.pets' },
|
||||
};
|
||||
|
||||
type CategoryFilter = FurnitureCategory | 'all';
|
||||
|
||||
@@ -22,6 +22,7 @@ export const FURNITURE_CATEGORIES = [
|
||||
'electronics',
|
||||
'climate',
|
||||
'decor',
|
||||
'pets',
|
||||
] as const;
|
||||
export type FurnitureCategory = (typeof FURNITURE_CATEGORIES)[number];
|
||||
|
||||
@@ -164,4 +165,17 @@ export const FURNITURE_DEFS: readonly FurnitureDef[] = [
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Wall Mirror (L)', width: 1.0, depth: 0.04, height: 1.5, icon: '\u{1FA9E}', defaultMetadata: { variant: 'wall' } },
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Floor Mirror', width: 0.5, depth: 0.4, height: 1.6, icon: '\u{1FA9E}', defaultMetadata: { variant: 'floor' } },
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Floor Mirror (L)', width: 0.6, depth: 0.5, height: 1.8, icon: '\u{1FA9E}', defaultMetadata: { variant: 'floor' } },
|
||||
// Scratching posts — cat furniture. Variants control the mesh style:
|
||||
// - `simple` → single sisal-wrapped post on a base with a small platform on top
|
||||
// - `tree` → multi-tier cat tree with 2–3 platforms at different heights
|
||||
// - `condo` → tower with an enclosed cubby and a platform on the roof
|
||||
// - `wall` → flat wall-mounted scratcher pad (thin, meant to be elevated)
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Scratching Post (S)', width: 0.3, depth: 0.3, height: 0.5, icon: '\u{1F431}', defaultMetadata: { variant: 'simple' } },
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Scratching Post', width: 0.35, depth: 0.35, height: 0.7, icon: '\u{1F431}', defaultMetadata: { variant: 'simple' } },
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Cat Tree (S)', width: 0.45, depth: 0.45, height: 0.9, icon: '\u{1F408}', defaultMetadata: { variant: 'tree' } },
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Cat Tree (M)', width: 0.55, depth: 0.55, height: 1.3, icon: '\u{1F408}', defaultMetadata: { variant: 'tree' } },
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Cat Tree (L)', width: 0.65, depth: 0.65, height: 1.7, icon: '\u{1F408}', defaultMetadata: { variant: 'tree' } },
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Cat Condo', width: 0.45, depth: 0.45, height: 1.0, icon: '\u{1F3E0}', defaultMetadata: { variant: 'condo' } },
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Cat Condo (L)', width: 0.55, depth: 0.55, height: 1.4, icon: '\u{1F3E0}', defaultMetadata: { variant: 'condo' } },
|
||||
{ type: 'SCRATCHING_POST', category: 'pets', label: 'Wall Scratcher', width: 0.2, depth: 0.04, height: 0.6, icon: '\u{1F431}', defaultMetadata: { variant: 'wall' } },
|
||||
];
|
||||
|
||||
@@ -46,6 +46,7 @@ const FURNITURE_COLORS: Record<FurnitureType, string> = {
|
||||
MIRROR: '#8a7a5c',
|
||||
DIGITAL_PIANO: '#1a1a22',
|
||||
SPEAKER: '#20201e',
|
||||
SCRATCHING_POST: '#c8a96e',
|
||||
OTHER: '#a0a0a0',
|
||||
};
|
||||
|
||||
@@ -1615,6 +1616,151 @@ function OfficeChairMesh({ item, color }: { readonly item: FurnitureItem; readon
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scratching Post ──────────────────────────────────────────────
|
||||
//
|
||||
// Variants:
|
||||
// simple → single sisal-wrapped post with a round platform on top
|
||||
// tree → multi-tier cat tree (2–3 posts + round platforms at offsets)
|
||||
// condo → enclosed cubby box with a platform on top and a post
|
||||
// wall → flat wall-mounted scratcher pad
|
||||
|
||||
const SISAL_COLOR = '#c8a96e';
|
||||
const PLATFORM_COLOR = '#8a7560';
|
||||
const CONDO_COLOR = '#7a6a5a';
|
||||
|
||||
function ScratchingPostMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
|
||||
const variant = (item.metadata as Record<string, unknown> | null)?.variant as string | undefined ?? 'simple';
|
||||
const sisalMat = useMemo(() => getFurnitureMaterial(color, 0.85), [color]);
|
||||
const platformMat = useMemo(() => getFurnitureMaterial(PLATFORM_COLOR, 0.7), []);
|
||||
const condoMat = useMemo(() => getFurnitureMaterial(CONDO_COLOR, 0.6), []);
|
||||
|
||||
const w = item.width;
|
||||
const d = item.depth;
|
||||
const h = item.height;
|
||||
const postRadius = Math.min(w, d) * 0.12;
|
||||
const platformRadius = Math.min(w, d) * 0.4;
|
||||
const platformThickness = 0.025;
|
||||
|
||||
if (variant === 'wall') {
|
||||
// Flat scratcher pad
|
||||
const padThickness = d > 0.01 ? d : 0.03;
|
||||
return (
|
||||
<group>
|
||||
<mesh material={sisalMat} castShadow receiveShadow>
|
||||
<boxGeometry args={[w, h, padThickness]} />
|
||||
<mesh position={[0, 0, padThickness / 2 + 0.002]}>
|
||||
<boxGeometry args={[w * 0.85, h * 0.85, 0.004]} />
|
||||
<meshStandardMaterial color="#b89a5a" roughness={0.95} />
|
||||
</mesh>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'condo') {
|
||||
const cubbyH = h * 0.45;
|
||||
const cubbyW = w * 0.8;
|
||||
const cubbyD = d * 0.8;
|
||||
const wallThick = 0.02;
|
||||
const postH = h - cubbyH - platformThickness;
|
||||
const openingRadius = Math.min(cubbyW, cubbyD) * 0.35;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Base platform */}
|
||||
<mesh position={[0, platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||||
</mesh>
|
||||
{/* Cubby box */}
|
||||
<mesh position={[0, platformThickness + cubbyH / 2, 0]} material={condoMat} castShadow receiveShadow>
|
||||
<boxGeometry args={[cubbyW, cubbyH, cubbyD]} />
|
||||
</mesh>
|
||||
{/* Cubby opening (dark circle on front face) */}
|
||||
<mesh position={[0, platformThickness + cubbyH * 0.45, cubbyD / 2 + 0.001]} rotation={[0, 0, 0]}>
|
||||
<circleGeometry args={[openingRadius, 24]} />
|
||||
<meshStandardMaterial color="#2a2018" roughness={0.95} />
|
||||
</mesh>
|
||||
{/* Post on top of cubby */}
|
||||
<mesh
|
||||
position={[0, platformThickness + cubbyH + postH / 2, 0]}
|
||||
material={sisalMat}
|
||||
castShadow
|
||||
>
|
||||
<cylinderGeometry args={[postRadius, postRadius, postH, 12]} />
|
||||
</mesh>
|
||||
{/* Top platform */}
|
||||
<mesh
|
||||
position={[0, h - platformThickness / 2, 0]}
|
||||
material={platformMat}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'tree') {
|
||||
// Multi-tier cat tree: 2–3 posts at offsets with platforms
|
||||
const tierCount = h > 1.2 ? 3 : 2;
|
||||
const baseR = platformRadius * 1.1;
|
||||
const offsets = [
|
||||
{ x: -w * 0.18, z: -d * 0.15, ph: h * 0.4, pr: platformRadius * 0.85 },
|
||||
{ x: w * 0.15, z: d * 0.12, ph: h * 0.7, pr: platformRadius * 0.9 },
|
||||
{ x: -w * 0.05, z: d * 0.05, ph: h, pr: platformRadius },
|
||||
].slice(0, tierCount);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Base */}
|
||||
<mesh position={[0, platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[baseR, baseR, platformThickness, 24]} />
|
||||
</mesh>
|
||||
{offsets.map((tier, i) => (
|
||||
<group key={i}>
|
||||
{/* Post */}
|
||||
<mesh
|
||||
position={[tier.x, platformThickness + (tier.ph - platformThickness) / 2, tier.z]}
|
||||
material={sisalMat}
|
||||
castShadow
|
||||
>
|
||||
<cylinderGeometry args={[postRadius, postRadius, tier.ph - platformThickness, 12]} />
|
||||
</mesh>
|
||||
{/* Platform */}
|
||||
<mesh
|
||||
position={[tier.x, tier.ph - platformThickness / 2, tier.z]}
|
||||
material={platformMat}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<cylinderGeometry args={[tier.pr, tier.pr, platformThickness, 24]} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: simple — single post on a base with a platform on top
|
||||
return (
|
||||
<group>
|
||||
{/* Base */}
|
||||
<mesh position={[0, platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||||
</mesh>
|
||||
{/* Post */}
|
||||
<mesh position={[0, h / 2, 0]} material={sisalMat} castShadow>
|
||||
<cylinderGeometry args={[postRadius, postRadius, h - platformThickness * 2, 12]} />
|
||||
</mesh>
|
||||
{/* Top platform */}
|
||||
<mesh position={[0, h - platformThickness / 2, 0]} material={platformMat} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[platformRadius, platformRadius, platformThickness, 24]} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getFurnitureComponent(type: FurnitureType) {
|
||||
switch (type) {
|
||||
case 'BED': return BedMesh;
|
||||
@@ -1637,6 +1783,7 @@ function getFurnitureComponent(type: FurnitureType) {
|
||||
case 'MIRROR': return MirrorMesh;
|
||||
case 'DIGITAL_PIANO': return DigitalPianoMesh;
|
||||
case 'SPEAKER': return SpeakerMesh;
|
||||
case 'SCRATCHING_POST': return ScratchingPostMesh;
|
||||
case 'NIGHTSTAND': return NightstandMesh;
|
||||
case 'SHELF':
|
||||
case 'DRESSER':
|
||||
|
||||
@@ -348,6 +348,7 @@ export const FURNITURE_TYPES = [
|
||||
'MIRROR',
|
||||
'DIGITAL_PIANO',
|
||||
'SPEAKER',
|
||||
'SCRATCHING_POST',
|
||||
'OTHER',
|
||||
] as const;
|
||||
export type FurnitureType = (typeof FURNITURE_TYPES)[number];
|
||||
|
||||
Reference in New Issue
Block a user