fix(diy): keep wood grain continuous and compact inspector

(cherry picked from commit 9af20a14b07f6da46691a7356c5fd1d08aa48887)
This commit is contained in:
WuYongJie 2026-10-16 14:46:23 +08:00
parent d8e43e89ba
commit 4bd48d8bc0
4 changed files with 44 additions and 41 deletions

View File

@ -40,11 +40,11 @@ export default function DiyCabinetMesh({ catalog, item, doors, selected, mode, d
const rotateGrain=main?.name==='榆木黑色'||main?.name==='柚木色'
const loadedTexture=useTexture(main?.texture||'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')
const loadedShelfTexture=useTexture(shelf?.texture||'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')
const shelfTexture=useMemo(()=>{const value=loadedShelfTexture.clone();value.colorSpace=THREE.SRGBColorSpace;value.wrapS=value.wrapT=THREE.RepeatWrapping;value.needsUpdate=true;return value},[loadedShelfTexture])
const shelfTexture=useMemo(()=>{const value=loadedShelfTexture.clone();value.colorSpace=THREE.SRGBColorSpace;value.wrapS=value.wrapT=THREE.ClampToEdgeWrapping;value.needsUpdate=true;return value},[loadedShelfTexture])
useEffect(()=>()=>shelfTexture.dispose(),[shelfTexture])
const texture=useMemo(()=>{
const value=loadedTexture.clone();value.colorSpace=THREE.SRGBColorSpace
value.wrapS=value.wrapT=THREE.RepeatWrapping;value.anisotropy=8
value.wrapS=value.wrapT=THREE.ClampToEdgeWrapping;value.anisotropy=8
if(rotateGrain){value.center.set(.5,.5);value.rotation=Math.PI/2}
value.needsUpdate=true;return value
},[loadedTexture,rotateGrain])

View File

@ -1,3 +1,4 @@
import { Plus } from 'lucide-react'
import { resolveDiyMaterials } from '@/lib/diy/catalog'
import { findDiyColorForMaterial } from '@/lib/diy/materials'
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
@ -9,19 +10,25 @@ export default function DiyColorOptions({ controller: c, disabled, onAddColor }:
}) {
if (!c.selected) return null
const selected = c.selected, style = c.catalogAccess.getCatalogStyle(selected.code)
const selectedMaterials = resolveDiyMaterials(c.catalog, { code: style.code, colorName: selected.colorName })
const selectedName = `${selected.colorName}${selectedMaterials.main?.deletedAt || selectedMaterials.shelf?.deletedAt ? ' · 已删除' : ''}`
const available = c.catalog.materials.filter(material => material.kind === 'main' && !material.deletedAt && !findDiyColorForMaterial(c.catalog, style.code, material.itemCode))
return <div className="mt-3 max-h-60 space-y-1.5 overflow-y-auto" aria-label="柜体共用配色">
return <div className="mt-2 min-w-0" aria-label="柜体共用配色">
<div className="flex items-center gap-1.5 overflow-x-auto px-0.5 py-1">
{style.colors.filter(color=>selected.colorName===color.name||diyColorIsAvailable(c.catalog,style.code,color.name)).map(color => {
const { main, shelf } = resolveDiyMaterials(c.catalog, { code: style.code, colorName: color.name })
return <button type="button" key={color.itemCode} onClick={() => c.setColor(selected.instanceId, color.name)} disabled={disabled}
title={`${color.name}${main?.deletedAt || shelf?.deletedAt ? ' · 已删除' : ''}`}
aria-label={`柜体颜色 ${color.name}`} aria-pressed={selected.colorName === color.name}
className={`flex w-full items-center gap-2 rounded-md border p-2 text-left text-xs ${selected.colorName === color.name ? 'border-blue-400 bg-blue-50' : 'border-zinc-200'}`}>
<DiyMaterialSwatch main={main} shelf={shelf} className="h-8 w-8" /><span className="min-w-0 truncate">{color.name}{main?.deletedAt||shelf?.deletedAt?' · 已删除':''}</span>
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-md p-1 focus-visible:outline-2 focus-visible:outline-blue-500 disabled:opacity-50 ${selected.colorName === color.name ? 'bg-blue-50 ring-1 ring-inset ring-blue-500' : 'hover:bg-zinc-100'}`}>
<DiyMaterialSwatch main={main} shelf={shelf} className="h-8 w-8" />
</button>
})}
{available.map(material => <button type="button" key={material.itemCode} disabled={disabled} aria-label={`添加颜色款 ${style.code} ${material.itemCode}`}
onClick={() => onAddColor(style.code, material.itemCode)} className="flex w-full items-center gap-2 rounded-md border border-zinc-200 p-2 text-left text-xs hover:border-blue-300">
<DiyMaterialSwatch main={material} className="h-8 w-8" /><span className="min-w-0 flex-1 truncate">{material.name}</span><span className="shrink-0 text-[10px] text-blue-600">添加颜色款</span>
{available.map(material => <button type="button" key={material.itemCode} disabled={disabled} title={`${material.name} · 添加颜色款`} aria-label={`添加颜色款 ${material.name} ${style.code} ${material.itemCode}`}
onClick={() => onAddColor(style.code, material.itemCode)} className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-md p-1 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-blue-500 disabled:opacity-50">
<DiyMaterialSwatch main={material} className="h-8 w-8" /><Plus size={12} aria-hidden="true" className="absolute bottom-0.5 right-0.5 rounded-full bg-white text-blue-600 ring-1 ring-zinc-200" />
</button>)}
</div>
<p title={selectedName} className="mt-1 truncate text-[11px] leading-4 text-zinc-500">{selectedName}</p>
</div>
}

View File

@ -1,12 +1,14 @@
'use client'
import { RotateCw, Trash2, Layers } from 'lucide-react'
import { RotateCw, Trash2, Layers, ScanLine } from 'lucide-react'
import Button from '@/components/ui/Button'
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
import { doorPatternFits, getDiyDoorDimensions } from '@/lib/diy/catalog'
import type { CabinetCell } from '@/lib/diy/types'
import DiyColorOptions from './DiyColorOptions'
const actionClassName = 'min-w-0 px-1 py-0 text-[11px] focus:px-1 focus:py-0 lg:h-8 lg:min-h-8 [&>span]:gap-1.5'
export default function DiyInspector({ controller: c, disabled, onEditCells, onAddPattern,onAddColor }: {
controller: DiyWorkbenchController; disabled: boolean; onEditCells:(code:string)=>void; onAddPattern:(door:CabinetCell['door'])=>void
onAddColor:(styleCode:string,materialCode:string)=>void
@ -20,35 +22,37 @@ export default function DiyInspector({ controller: c, disabled, onEditCells, onA
return [`${door.width} × ${door.height} × ${door.thickness}`,door] as const
})).values()]
return <div className="flex h-full min-h-0 flex-col bg-white">
<section className="shrink-0 border-b border-zinc-200 p-4">
<h2 className="text-sm font-semibold text-zinc-800">当前柜体</h2>
<section className="shrink-0 border-b border-zinc-200 p-3">
<div className="flex min-w-0 items-center gap-2">
<h2 className="shrink-0 text-xs font-semibold text-zinc-800">当前柜体</h2>
{c.selected&&<p className="min-w-0 truncate text-[11px] text-zinc-500" title={`${getCatalogStyle(c.selected.code).name} · ${c.selected.code}`}>{getCatalogStyle(c.selected.code).name} · {c.selected.code}</p>}
</div>
{c.selected ? <>
<p className="mt-2 text-sm text-zinc-600">{getCatalogStyle(c.selected.code).name} · {c.selected.code}</p>
<div className="mt-3 flex flex-wrap gap-2">
{style?.modelUrl&&<Button size="small" variant="gray" disabled={disabled} onClick={()=>onEditCells(c.selected!.code)}>编辑格口</Button>}
<Button size="small" variant="gray" disabled={disabled} onClick={() => c.rotate(c.selectedId)}><RotateCw size={14} />横竖切换</Button>
<Button size="small" variant="gray" disabled={disabled} onClick={c.stackSelected}><Layers size={14} />叠放</Button>
<Button size="small" variant="plain" disabled={disabled} onClick={() => c.remove(c.selectedId)}><Trash2 size={14} />删除</Button>
<div className={`mt-2 grid items-center gap-1 ${style?.modelUrl?'grid-cols-[1.4fr_1fr_1fr_32px]':'grid-cols-[1.4fr_1fr_1fr]'}`}>
<Button size="small" variant="plain" className={actionClassName} disabled={disabled} onClick={() => c.rotate(c.selectedId)}><RotateCw size={13} />横竖切换</Button>
<Button size="small" variant="plain" className={actionClassName} disabled={disabled} onClick={c.stackSelected}><Layers size={13} />叠放</Button>
<Button size="small" variant="plain" className={actionClassName} disabled={disabled} onClick={() => c.remove(c.selectedId)}><Trash2 size={13} />删除</Button>
{style?.modelUrl&&<Button size="small" variant="plain" className={actionClassName} title="编辑格口" ariaLabel="编辑格口" disabled={disabled} onClick={()=>onEditCells(c.selected!.code)}><ScanLine size={13} /></Button>}
</div>
<DiyColorOptions controller={c} disabled={disabled} onAddColor={onAddColor}/>
</> : <p className="mt-2 text-xs leading-5 text-zinc-500">点击画面中的柜体,查看颜色与摆放操作。</p>}
</section>
<section className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 px-4 pb-2 pt-4"><h2 className="text-sm font-semibold text-zinc-800">柜门图案</h2>
<p className="mt-1 text-xs text-zinc-500">{style&&!style.cells.length?'当前柜体尚未配置格口,请点击“编辑格口”自动识别。':'选择图案后,切换贴门并点击格口。'}</p>
{doorSizes.length>0&&<div className="mt-2 max-h-28 space-y-2 overflow-y-auto">{doorSizes.map(door=><div key={`${door.width}-${door.height}-${door.thickness}`} className="flex flex-wrap items-center gap-1 text-xs text-zinc-500">
<div className="shrink-0 px-3 pb-1 pt-3"><h2 className="text-xs font-semibold text-zinc-800">柜门图案</h2>
<p className="mt-1 text-[11px] text-zinc-500">{style&&!style.cells.length?'点击“编辑格口”自动识别后贴门。':'选择图案,再点击画面中的格口。'}</p>
{doorSizes.length>0&&<div className="mt-1 max-h-20 space-y-1 overflow-y-auto">{doorSizes.map(door=><div key={`${door.width}-${door.height}-${door.thickness}`} className="flex items-center gap-1 text-[10px] text-zinc-500">
<span className="mr-auto">门片 {door.width} × {door.height} × {door.thickness} mm</span>
<button type="button" disabled={disabled} className="text-blue-600 hover:underline disabled:opacity-50" onClick={()=>onAddPattern(door)}>新增适配柜门</button>
<button type="button" disabled={disabled} title="新增适配柜门" aria-label={`新增适配柜门 ${door.width} × ${door.height} × ${door.thickness} mm`} className="shrink-0 py-1 text-blue-600 hover:underline disabled:opacity-50" onClick={()=>onAddPattern(door)}>新增图案</button>
</div>)}</div>}
</div>
<div className="grid min-h-0 grid-cols-4 gap-2 overflow-y-auto p-3">
<div className="grid min-h-0 flex-1 grid-cols-4 content-start gap-2 overflow-y-auto p-3 pt-2 [scrollbar-gutter:stable]">
{visiblePatterns.map(pattern => <button type="button" key={pattern.itemCode} disabled={disabled} title={`${pattern.name} · ${pattern.itemCode}`}
aria-label={`柜门图案 ${pattern.name},货号 ${pattern.itemCode}`} aria-pressed={c.patternCode === pattern.itemCode}
onClick={() => { c.setPatternCode(pattern.itemCode); c.setMode('doors') }}
className={`min-w-0 rounded-md border p-1 ${c.patternCode === pattern.itemCode ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-200' : 'border-zinc-200'}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={pattern.image} alt={pattern.name} className="aspect-square w-full rounded object-cover" />
<span className="mt-1 line-clamp-2 h-8 break-words text-[10px] leading-4 text-zinc-700">{pattern.name}</span>
<span className="mt-1 line-clamp-2 break-words text-[10px] leading-4 text-zinc-700">{pattern.name}</span>
<span className="block truncate text-[10px] text-zinc-400">{pattern.itemCode.slice(-3)}</span>
</button>)}
{!visiblePatterns.length&&Boolean(style?.cells.length)&&<p className="col-span-4 py-3 text-xs text-zinc-500">暂无与当前格口尺寸匹配的柜门。</p>}

View File

@ -10,8 +10,7 @@
*/
const MM_TO_UNIT = 0.01; // 1 单位 = 10mm,柜体约 65~130 单位
// 大板面每 720mm 一个贴图周期,并使用板件在模型里的真实坐标取样。
// 这样相邻板件不会每一块都从纹理左下角重新开始,能明显减轻重复拼接感。
// 木纹默认按 720mm 取样;较长板件整体适配一张贴图,避免板内循环接缝。
const UV_SCALE = 1 / 720;
/** 长方体 6 个面,每面 4 个顶点。返回值是相对中心的偏移与对应法向 */
@ -81,28 +80,21 @@ function buildBox(center, size, modelCenter, opts) {
const t = uAxis; uAxis = vAxis; vAxis = t;
}
}
// 木纹按真实尺寸平铺;柜门图案则必须把整张图片映射到整块门板。
// 若 upright 也沿用 UV_SCALE,300mm 门板只会采样纹理左下约 37.5%,
// 表现为图案被截断并偏到门板一角。
const uSize = uvMode === 'upright' || edgeBand ? 1 : size[uAxis] * UV_SCALE;
const vSize = uvMode === 'upright' || edgeBand ? 1 : size[vAxis] * UV_SCALE;
face.v.forEach((corner, i) => {
// 原木纹不是无缝贴图。每块板从原图中央连续取样,UV 保持在 0..1,
// 不再随模型绝对坐标跨入下一个贴图周期;长板等比缩放取样范围。
// 柜门图案和窄封边仍使用完整图片,保留原有朝向。
const grainScale = Math.min(UV_SCALE, 1 / Math.max(size[uAxis], size[vAxis]));
const uSize = uvMode === 'upright' || edgeBand ? 1 : size[uAxis] * grainScale;
const vSize = uvMode === 'upright' || edgeBand ? 1 : size[vAxis] * grainScale;
face.v.forEach((corner) => {
positions.push(
cx + corner[0] * half[0],
cy + corner[1] * half[1],
cz + corner[2] * half[2]
);
normals.push(face.n[0], face.n[1], face.n[2]);
// UV 必须按角点在 u/v 轴上的实际坐标算。
// 之前是按顶点顺序写死 0/uSize,导致声明的那两个轴根本没生效,
// 木纹方向和柜门图案朝向都会乱。
// 大板面使用模型内的连续绝对坐标,窄封边与柜门则完整映射 0..1。
const u = uvMode === 'grain' && !edgeBand
? (center[uAxis] + corner[uAxis] * size[uAxis] / 2) * UV_SCALE
: ((corner[uAxis] + 1) / 2) * uSize;
const v = uvMode === 'grain' && !edgeBand
? (center[vAxis] + corner[vAxis] * size[vAxis] / 2) * UV_SCALE
: ((corner[vAxis] + 1) / 2) * vSize;
const u = (1 + corner[uAxis] * uSize) / 2;
const v = (1 + corner[vAxis] * vSize) / 2;
uvs.push(u, v);
});
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);