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:
2026-04-16 14:59:04 +03:00
parent c808bf1add
commit 8c61cd182e
6 changed files with 165 additions and 0 deletions
@@ -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 23 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 (23 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: 23 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':
+1
View File
@@ -348,6 +348,7 @@ export const FURNITURE_TYPES = [
'MIRROR',
'DIGITAL_PIANO',
'SPEAKER',
'SCRATCHING_POST',
'OTHER',
] as const;
export type FurnitureType = (typeof FURNITURE_TYPES)[number];