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
@@ -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':