feat: localize Pascal editor in Chinese
This commit is contained in:
parent
8bd0308164
commit
312064e6e8
@ -29,7 +29,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}
|
||||
lang="en"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<head>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
|
||||
@ -21,7 +21,7 @@ function EditorItemsPanel() {
|
||||
const SIDEBAR_TABS = [
|
||||
{
|
||||
id: 'site',
|
||||
label: 'Scene',
|
||||
label: '场景',
|
||||
component: () => null,
|
||||
mobileDefaultSnap: 0.5,
|
||||
mobileIcon: <Layers className="h-5 w-5" />,
|
||||
@ -37,7 +37,7 @@ const SIDEBAR_TABS = [
|
||||
},
|
||||
{
|
||||
id: 'build',
|
||||
label: 'Build',
|
||||
label: '搭建',
|
||||
component: BuildTab,
|
||||
mobileDefaultSnap: 0.5,
|
||||
mobileIcon: <Hammer className="h-5 w-5" />,
|
||||
@ -53,7 +53,7 @@ const SIDEBAR_TABS = [
|
||||
},
|
||||
{
|
||||
id: 'items',
|
||||
label: 'Items',
|
||||
label: '物品',
|
||||
component: EditorItemsPanel,
|
||||
mobileDefaultSnap: 0.5,
|
||||
mobileIcon: <Package className="h-5 w-5" />,
|
||||
@ -69,7 +69,7 @@ const SIDEBAR_TABS = [
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
label: '设置',
|
||||
component: () => null,
|
||||
mobileDefaultSnap: 0.5,
|
||||
mobileIcon: <Settings className="h-5 w-5" />,
|
||||
@ -93,15 +93,15 @@ export default function Home() {
|
||||
{PROJECT_ID === 'local-editor' && (
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
|
||||
<span className="text-muted-foreground">Local editor — scenes are not saved.</span>
|
||||
<span className="text-muted-foreground">本地编辑器中的场景不会自动保存。</span>
|
||||
<Link className="font-medium text-foreground hover:underline" href="/scenes">
|
||||
Open recent scenes
|
||||
打开最近场景
|
||||
</Link>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<Link className="font-medium text-foreground hover:underline" href="/scenes">
|
||||
Create new
|
||||
新建场景
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -29,22 +29,22 @@ export default async function ScenePage({ params }: { params: Promise<{ id: stri
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 text-center shadow-xl">
|
||||
<p className="font-mono text-muted-foreground text-xs uppercase tracking-wide">404</p>
|
||||
<h1 className="mt-2 font-semibold text-lg">Scene not found</h1>
|
||||
<h1 className="mt-2 font-semibold text-lg">未找到场景</h1>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
We couldn't find a scene with id <code className="font-mono">{id}</code>.
|
||||
无法找到 ID 为 <code className="font-mono">{id}</code> 的场景。
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Link
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
|
||||
href="/scenes"
|
||||
>
|
||||
Browse scenes
|
||||
查看场景
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
|
||||
href="/"
|
||||
>
|
||||
Back to editor
|
||||
返回编辑器
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@ async function fetchScenes(): Promise<SceneMeta[]> {
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
@ -34,26 +34,24 @@ export default async function ScenesPage() {
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
首页
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="font-medium text-foreground">Scenes</span>
|
||||
<span className="font-medium text-foreground">场景</span>
|
||||
</nav>
|
||||
<CreateSceneButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-5xl px-6 py-12">
|
||||
<h1 className="mb-2 font-bold text-3xl">Your scenes</h1>
|
||||
<h1 className="mb-2 font-bold text-3xl">我的场景</h1>
|
||||
<p className="mb-8 text-muted-foreground text-sm">
|
||||
{scenes.length === 0
|
||||
? 'No scenes yet. Create one to get started.'
|
||||
: `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`}
|
||||
{scenes.length === 0 ? '还没有场景,创建一个开始设计吧。' : `共 ${scenes.length} 个场景`}
|
||||
</p>
|
||||
|
||||
{scenes.length === 0 ? (
|
||||
<div className="rounded-xl border border-border/60 border-dashed bg-background p-12 text-center">
|
||||
<p className="text-muted-foreground text-sm">You haven't saved any scenes yet.</p>
|
||||
<p className="text-muted-foreground text-sm">尚未保存任何场景。</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<CreateSceneButton />
|
||||
</div>
|
||||
@ -75,7 +73,7 @@ export default async function ScenesPage() {
|
||||
src={scene.thumbnailUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">No thumbnail</span>
|
||||
<span className="text-muted-foreground text-xs">暂无缩略图</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
@ -83,7 +81,7 @@ export default async function ScenesPage() {
|
||||
{scene.name}
|
||||
</h2>
|
||||
<div className="mt-1 flex items-center justify-between text-muted-foreground text-xs">
|
||||
<span>{scene.nodeCount} nodes</span>
|
||||
<span>{scene.nodeCount} 个元素</span>
|
||||
<time dateTime={scene.updatedAt}>{formatDate(scene.updatedAt)}</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,53 +69,53 @@ type MepItem = {
|
||||
|
||||
// Same icons + ordering as the community Build sidebar, minus presets.
|
||||
const BUILD_TYPES: BuildType[] = [
|
||||
{ id: 'wall', label: 'Wall', iconSrc: withPascalBasePath('/icons/wall.webp'), kind: 'wall' },
|
||||
{ id: 'fence', label: 'Fence', iconSrc: withPascalBasePath('/icons/fence.webp'), kind: 'fence' },
|
||||
{ id: 'slab', label: 'Slab', iconSrc: withPascalBasePath('/icons/floor.webp'), kind: 'slab' },
|
||||
{ id: 'wall', label: '墙体', iconSrc: withPascalBasePath('/icons/wall.webp'), kind: 'wall' },
|
||||
{ id: 'fence', label: '围栏', iconSrc: withPascalBasePath('/icons/fence.webp'), kind: 'fence' },
|
||||
{ id: 'slab', label: '楼板', iconSrc: withPascalBasePath('/icons/floor.webp'), kind: 'slab' },
|
||||
{
|
||||
id: 'ceiling',
|
||||
label: 'Ceiling',
|
||||
label: '天花板',
|
||||
iconSrc: withPascalBasePath('/icons/ceiling.webp'),
|
||||
kind: 'ceiling',
|
||||
},
|
||||
{ id: 'roof', label: 'Roof', iconSrc: withPascalBasePath('/icons/roof.webp'), kind: 'roof' },
|
||||
{ id: 'roof', label: '屋顶', iconSrc: withPascalBasePath('/icons/roof.webp'), kind: 'roof' },
|
||||
{
|
||||
id: 'stair',
|
||||
label: 'Stairs',
|
||||
label: '楼梯',
|
||||
iconSrc: withPascalBasePath('/icons/stairs.webp'),
|
||||
kind: 'stair',
|
||||
},
|
||||
{
|
||||
id: 'elevator',
|
||||
label: 'Elevator',
|
||||
label: '电梯',
|
||||
iconSrc: withPascalBasePath('/icons/elevator.webp'),
|
||||
kind: 'elevator',
|
||||
},
|
||||
{ id: 'door', label: 'Door', iconSrc: withPascalBasePath('/icons/door.webp'), kind: 'door' },
|
||||
{ id: 'door', label: '门', iconSrc: withPascalBasePath('/icons/door.webp'), kind: 'door' },
|
||||
{
|
||||
id: 'window',
|
||||
label: 'Window',
|
||||
label: '窗户',
|
||||
iconSrc: withPascalBasePath('/icons/window.webp'),
|
||||
kind: 'window',
|
||||
},
|
||||
{
|
||||
id: 'column',
|
||||
label: 'Column',
|
||||
label: '立柱',
|
||||
iconSrc: withPascalBasePath('/icons/column.webp'),
|
||||
kind: 'column',
|
||||
},
|
||||
{ id: 'shelf', label: 'Shelf', iconSrc: withPascalBasePath('/icons/shelf.webp'), kind: 'shelf' },
|
||||
{ id: 'shelf', label: '置物架', iconSrc: withPascalBasePath('/icons/shelf.webp'), kind: 'shelf' },
|
||||
{
|
||||
id: 'spawn',
|
||||
label: 'Spawn Point',
|
||||
label: '出生点',
|
||||
iconSrc: withPascalBasePath('/icons/spawn-point.webp'),
|
||||
kind: 'spawn',
|
||||
},
|
||||
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
|
||||
{ id: 'mep', label: 'MEP', iconSrc: withPascalBasePath('/icons/HVAC.webp') },
|
||||
{ id: 'mep', label: '机电管线', iconSrc: withPascalBasePath('/icons/HVAC.webp') },
|
||||
{
|
||||
id: 'painting',
|
||||
label: 'Painting',
|
||||
label: '材质涂装',
|
||||
iconSrc: withPascalBasePath('/icons/paint.webp'),
|
||||
mode: 'material-paint',
|
||||
},
|
||||
@ -126,37 +126,37 @@ const BUILD_TYPES: BuildType[] = [
|
||||
const MEP_ITEMS: MepItem[] = [
|
||||
{
|
||||
id: 'duct-segment',
|
||||
label: 'Duct',
|
||||
label: '风管',
|
||||
iconSrc: withPascalBasePath('/icons/duct.webp'),
|
||||
kind: 'duct-segment',
|
||||
},
|
||||
{
|
||||
id: 'duct-terminal',
|
||||
label: 'Register',
|
||||
label: '风口',
|
||||
iconSrc: withPascalBasePath('/icons/registers.webp'),
|
||||
kind: 'duct-terminal',
|
||||
},
|
||||
{
|
||||
id: 'hvac-equipment',
|
||||
label: 'HVAC Unit',
|
||||
label: '暖通设备',
|
||||
iconSrc: withPascalBasePath('/icons/HVAC.webp'),
|
||||
kind: 'hvac-equipment',
|
||||
},
|
||||
{
|
||||
id: 'lineset',
|
||||
label: 'Lineset',
|
||||
label: '冷媒管组',
|
||||
iconSrc: withPascalBasePath('/icons/lineset.webp'),
|
||||
kind: 'lineset',
|
||||
},
|
||||
{
|
||||
id: 'liquid-line',
|
||||
label: 'Liquid Line',
|
||||
label: '液管',
|
||||
iconSrc: withPascalBasePath('/icons/lineset.webp'),
|
||||
kind: 'liquid-line',
|
||||
},
|
||||
{
|
||||
id: 'pipe-segment',
|
||||
label: 'DWV Pipe',
|
||||
label: '排水通气管',
|
||||
iconSrc: withPascalBasePath('/icons/dwv-pipes.webp'),
|
||||
kind: 'pipe-segment',
|
||||
},
|
||||
@ -186,6 +186,20 @@ function activatePaintMode(): void {
|
||||
|
||||
type RoofFeature = { kind: string; label: string; iconSrc: string }
|
||||
|
||||
const ROOF_FEATURE_LABELS: Record<string, string> = {
|
||||
'box-vent': '箱式通风口',
|
||||
chimney: '烟囱',
|
||||
cupola: '屋顶钟楼',
|
||||
dormer: '老虎窗',
|
||||
downspout: '落水管',
|
||||
'eyebrow-vent': '眉形通风口',
|
||||
gutter: '天沟',
|
||||
'ridge-vent': '屋脊通风口',
|
||||
skylight: '天窗',
|
||||
'solar-panel': '太阳能板',
|
||||
'turbine-vent': '涡轮通风器',
|
||||
}
|
||||
|
||||
const ROOF_FEATURE_FALLBACK_ICON = withPascalBasePath('/icons/roof.webp')
|
||||
|
||||
/**
|
||||
@ -259,7 +273,7 @@ export function BuildTab() {
|
||||
const icon = def.presentation?.icon
|
||||
features.push({
|
||||
kind,
|
||||
label: def.presentation?.label ?? kind,
|
||||
label: ROOF_FEATURE_LABELS[kind] ?? def.presentation?.label ?? kind,
|
||||
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
|
||||
})
|
||||
}
|
||||
@ -362,7 +376,7 @@ export function BuildTab() {
|
||||
(activeTool === 'roof' || isRoofFeatureActive) &&
|
||||
roofFeatures.length > 0 ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
|
||||
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">屋顶构件</div>
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
@ -407,7 +421,7 @@ export function BuildTab() {
|
||||
</div>
|
||||
) : isMepActive ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
|
||||
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">机电管线</div>
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
<div
|
||||
className="grid gap-1.5 px-0.5"
|
||||
@ -452,7 +466,7 @@ export function BuildTab() {
|
||||
|
||||
{ductContext ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">Duct</span>
|
||||
<span className="text-muted-foreground text-xs">风管</span>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
|
||||
@ -475,14 +489,14 @@ export function BuildTab() {
|
||||
src={withPascalBasePath('/icons/duct-fitting.webp')}
|
||||
width={16}
|
||||
/>
|
||||
Add Fitting
|
||||
添加管件
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pipeContext ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">DWV Pipe</span>
|
||||
<span className="text-muted-foreground text-xs">排水通气管</span>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
|
||||
@ -505,7 +519,7 @@ export function BuildTab() {
|
||||
src={withPascalBasePath('/icons/duct-fitting.webp')}
|
||||
width={16}
|
||||
/>
|
||||
Add Fitting
|
||||
添加管件
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
@ -529,14 +543,14 @@ export function BuildTab() {
|
||||
src={withPascalBasePath('/icons/dwv-pipes.webp')}
|
||||
width={16}
|
||||
/>
|
||||
Add Trap
|
||||
添加存水弯
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{liquidLineContext ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">Liquid Line</span>
|
||||
<span className="text-muted-foreground text-xs">液管</span>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
|
||||
@ -549,13 +563,11 @@ export function BuildTab() {
|
||||
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||
type="button"
|
||||
>
|
||||
<span>Follow lineset</span>
|
||||
<span className="text-muted-foreground text-xs">{follow ? 'On' : 'Off'}</span>
|
||||
<span>跟随冷媒管组</span>
|
||||
<span className="text-muted-foreground text-xs">{follow ? '开' : '关'}</span>
|
||||
</button>
|
||||
<span className="px-1 text-[11px] text-muted-foreground">
|
||||
{follow
|
||||
? 'Click a lineset to lay the line beside it.'
|
||||
: 'Trace a line alongside an existing lineset (F).'}
|
||||
{follow ? '单击冷媒管组,在其旁边铺设液管。' : '沿现有冷媒管组绘制液管(F)。'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -20,7 +20,7 @@ interface SaveButtonProps {
|
||||
/**
|
||||
* Creates a new empty scene and navigates the user to it.
|
||||
*/
|
||||
export function CreateSceneButton({ label = 'Create new scene' }: { label?: string } = {}) {
|
||||
export function CreateSceneButton({ label = '新建场景' }: { label?: string } = {}) {
|
||||
const router = useRouter()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@ -32,16 +32,16 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri
|
||||
const response = await fetch(withPascalBasePath('/api/scenes'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Untitled scene', graph: EMPTY_GRAPH }),
|
||||
body: JSON.stringify({ name: '未命名场景', graph: EMPTY_GRAPH }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
setError(`Failed to create scene (${response.status})`)
|
||||
setError(`创建场景失败 (${response.status})`)
|
||||
return
|
||||
}
|
||||
const meta = (await response.json()) as { id: string }
|
||||
router.push(`/scene/${meta.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create scene')
|
||||
setError(err instanceof Error ? err.message : '创建场景失败')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
@ -56,7 +56,7 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri
|
||||
onClick={handleCreate}
|
||||
type="button"
|
||||
>
|
||||
{isCreating ? 'Creating…' : label}
|
||||
{isCreating ? '正在创建…' : label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@ -75,7 +75,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
|
||||
const handleSave = useCallback(async () => {
|
||||
const graph = getGraph()
|
||||
if (!graph) {
|
||||
setStatus('No scene to save')
|
||||
setStatus('没有可保存的场景')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
@ -90,16 +90,16 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
|
||||
body: JSON.stringify({ name, graph }),
|
||||
})
|
||||
if (response.status === 409) {
|
||||
setStatus('Conflict — reload to continue')
|
||||
setStatus('保存冲突,请刷新后继续')
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus(`Save failed (${response.status})`)
|
||||
setStatus(`保存失败 (${response.status})`)
|
||||
return
|
||||
}
|
||||
setStatus('Saved')
|
||||
setStatus('已保存')
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Save failed')
|
||||
setStatus(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@ -108,10 +108,10 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
|
||||
const handleSaveAs = useCallback(async () => {
|
||||
const graph = getGraph()
|
||||
if (!graph) {
|
||||
setStatus('No scene to save')
|
||||
setStatus('没有可保存的场景')
|
||||
return
|
||||
}
|
||||
const newName = typeof window !== 'undefined' ? window.prompt('New scene name', name) : null
|
||||
const newName = typeof window !== 'undefined' ? window.prompt('请输入新场景名称', name) : null
|
||||
if (!newName) return
|
||||
setIsSaving(true)
|
||||
setStatus(null)
|
||||
@ -122,13 +122,13 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
|
||||
body: JSON.stringify({ name: newName, graph }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
setStatus(`Save-as failed (${response.status})`)
|
||||
setStatus(`另存为失败 (${response.status})`)
|
||||
return
|
||||
}
|
||||
const meta = (await response.json()) as { id: string }
|
||||
router.push(`/scene/${meta.id}`)
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Save-as failed')
|
||||
setStatus(error instanceof Error ? error.message : '另存为失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@ -142,7 +142,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
{isSaving ? '正在保存…' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40 disabled:opacity-50"
|
||||
@ -150,7 +150,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
|
||||
onClick={handleSaveAs}
|
||||
type="button"
|
||||
>
|
||||
Save as…
|
||||
另存为…
|
||||
</button>
|
||||
{status && <span className="text-muted-foreground text-xs">{status}</span>}
|
||||
</div>
|
||||
|
||||
@ -35,7 +35,7 @@ export interface SceneMeta {
|
||||
const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
|
||||
{
|
||||
id: 'site',
|
||||
label: 'Scene',
|
||||
label: '场景',
|
||||
component: () => null, // Built-in SitePanel handles this
|
||||
mobileDefaultSnap: 0.5,
|
||||
mobileIcon: <Layers className="h-5 w-5" />,
|
||||
@ -51,7 +51,7 @@ const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
|
||||
},
|
||||
{
|
||||
id: 'build',
|
||||
label: 'Build',
|
||||
label: '搭建',
|
||||
component: BuildTab,
|
||||
mobileDefaultSnap: 0.5,
|
||||
mobileIcon: <Hammer className="h-5 w-5" />,
|
||||
@ -109,7 +109,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
...BASE_SIDEBAR_TABS,
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'AI Designer',
|
||||
label: 'AI 设计助手',
|
||||
component: SceneAgentPanel,
|
||||
mobileDefaultSnap: 0.72,
|
||||
mobileIcon: <Bot className="h-5 w-5" />,
|
||||
@ -152,7 +152,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setSaveError(`Save failed (${response.status})`)
|
||||
setSaveError(`保存失败 (${response.status})`)
|
||||
return
|
||||
}
|
||||
|
||||
@ -160,7 +160,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
versionRef.current = next.version
|
||||
setSaveError(null)
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : 'Save failed')
|
||||
setSaveError(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
},
|
||||
[meta.id, meta.name],
|
||||
@ -189,7 +189,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
|
||||
source.addEventListener('error', () => {
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
setSaveError('Live scene connection closed')
|
||||
setSaveError('场景实时连接已断开')
|
||||
}
|
||||
})
|
||||
|
||||
@ -214,9 +214,9 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
<div className="relative h-screen w-screen">
|
||||
{conflict && (
|
||||
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-xl">
|
||||
<h2 className="font-semibold text-sm">Another session saved first — refresh?</h2>
|
||||
<h2 className="font-semibold text-sm">其他会话已先保存,是否刷新?</h2>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
Your changes haven't been saved. Reload to pick up the latest version.
|
||||
当前更改尚未保存,请重新加载最新版本。
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
@ -224,14 +224,14 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
onClick={() => router.refresh()}
|
||||
type="button"
|
||||
>
|
||||
Reload
|
||||
重新加载
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40"
|
||||
onClick={() => setConflict(false)}
|
||||
type="button"
|
||||
>
|
||||
Dismiss
|
||||
暂不处理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -246,7 +246,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
className="pointer-events-auto rounded-md border border-border bg-background/90 px-3 py-1.5 font-medium text-xs shadow-sm backdrop-blur hover:bg-accent/40"
|
||||
href="/scenes"
|
||||
>
|
||||
All scenes
|
||||
全部场景
|
||||
</Link>
|
||||
</div>
|
||||
<Editor
|
||||
|
||||
@ -107,30 +107,30 @@ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
|
||||
},
|
||||
{
|
||||
id: 'split',
|
||||
label: 'Split',
|
||||
label: '分屏',
|
||||
icon: <Columns2 className="h-3 w-3" />,
|
||||
},
|
||||
]
|
||||
|
||||
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
|
||||
const levelModeLabels: Record<string, string> = {
|
||||
manual: 'Stack',
|
||||
stacked: 'Stack',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
manual: '堆叠',
|
||||
stacked: '堆叠',
|
||||
exploded: '分层',
|
||||
solo: '单层',
|
||||
}
|
||||
|
||||
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
|
||||
const wallModeConfig: Record<string, { icon: string; label: string }> = {
|
||||
up: { icon: withPascalBasePath('/icons/room.webp'), label: 'Full height' },
|
||||
cutaway: { icon: withPascalBasePath('/icons/wallcut.webp'), label: 'Cutaway' },
|
||||
down: { icon: withPascalBasePath('/icons/walllow.webp'), label: 'Low' },
|
||||
translucent: { icon: withPascalBasePath('/icons/wall.webp'), label: 'Translucent' },
|
||||
up: { icon: withPascalBasePath('/icons/room.webp'), label: '全高' },
|
||||
cutaway: { icon: withPascalBasePath('/icons/wallcut.webp'), label: '剖切' },
|
||||
down: { icon: withPascalBasePath('/icons/walllow.webp'), label: '低墙' },
|
||||
translucent: { icon: withPascalBasePath('/icons/wall.webp'), label: '半透明' },
|
||||
}
|
||||
|
||||
const SHADING_OPTIONS = [
|
||||
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
|
||||
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
|
||||
{ id: 'solid', name: '实体', detail: '快速平面显示,不含环境光遮蔽', icon: Box },
|
||||
{ id: 'rendered', name: '渲染', detail: '完整环境光遮蔽效果', icon: Sparkles },
|
||||
] as const
|
||||
|
||||
function ViewModeControl() {
|
||||
@ -175,9 +175,9 @@ function CollapseSidebarButton() {
|
||||
|
||||
return (
|
||||
<div className={TOOLBAR_CONTAINER}>
|
||||
<ToolbarTooltip label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
|
||||
<ToolbarTooltip label={isCollapsed ? '展开侧边栏' : '收起侧边栏'}>
|
||||
<button
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-label={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
className={TOOLBAR_BTN}
|
||||
onClick={toggle}
|
||||
type="button"
|
||||
@ -209,7 +209,7 @@ function LevelModeToggle() {
|
||||
if (next) setLevelMode(next)
|
||||
}
|
||||
|
||||
const label = `Levels: ${levelMode === 'manual' ? 'Manual' : (levelModeLabels[levelMode] ?? 'Stack')}`
|
||||
const label = `楼层:${levelMode === 'manual' ? '手动' : (levelModeLabels[levelMode] ?? '堆叠')}`
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={label}>
|
||||
@ -229,7 +229,7 @@ function LevelModeToggle() {
|
||||
) : (
|
||||
<IconifyIcon height={14} icon="charm:stack-push" width={14} />
|
||||
)}
|
||||
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? 'Stack'}</span>
|
||||
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? '堆叠'}</span>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
@ -247,7 +247,7 @@ function WallModeToggle() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={`Walls: ${config.label}`}>
|
||||
<ToolbarTooltip label={`墙体:${config.label}`}>
|
||||
<button
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
@ -270,11 +270,27 @@ function WallModeToggle() {
|
||||
// camera projection, units, render mode, edges and scene theme.
|
||||
|
||||
const EDGE_OPTIONS = [
|
||||
{ id: 'off', name: 'Off', detail: 'No edge lines' },
|
||||
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
|
||||
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
|
||||
{ id: 'off', name: '关闭', detail: '不显示边线' },
|
||||
{ id: 'soft', name: '柔和', detail: '淡化显示主要折线' },
|
||||
{ id: 'strong', name: '清晰', detail: '显示清晰不透明边线' },
|
||||
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
|
||||
|
||||
const SCENE_THEME_LABELS: Record<string, string> = {
|
||||
studio: '工作室',
|
||||
paper: '纸张',
|
||||
sunset: '日落',
|
||||
overcast: '阴天',
|
||||
blueprint: '蓝图',
|
||||
mediterranean: '地中海',
|
||||
twilight: '黄昏',
|
||||
night: '夜景',
|
||||
verdant: '绿意',
|
||||
}
|
||||
|
||||
function getSceneThemeLabel(id: string, fallback: string) {
|
||||
return SCENE_THEME_LABELS[id] ?? fallback
|
||||
}
|
||||
|
||||
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
|
||||
|
||||
function DisplayMenu() {
|
||||
@ -308,15 +324,15 @@ function DisplayMenu() {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<ToolbarTooltip label="Display settings">
|
||||
<ToolbarTooltip label="显示设置">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Display settings"
|
||||
aria-label="显示设置"
|
||||
className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5 text-foreground/90')}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="font-medium text-xs">Display</span>
|
||||
<span className="font-medium text-xs">显示</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</ToolbarTooltip>
|
||||
@ -328,7 +344,7 @@ function DisplayMenu() {
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShowGrid(!showGrid))}>
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
<span>Grid</span>
|
||||
<span>网格</span>
|
||||
{showGrid ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
@ -337,15 +353,15 @@ function DisplayMenu() {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
|
||||
<Magnet className="h-4 w-4" />
|
||||
<span>Magnetic snap</span>
|
||||
<span>磁吸捕捉</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{magneticSnap ? 'On' : 'Off'}
|
||||
{magneticSnap ? '开' : '关'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShadows(!shadows))}>
|
||||
<Contrast className="h-4 w-4" />
|
||||
<span>Shadows</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{shadows ? 'On' : 'Off'}</span>
|
||||
<span>阴影</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{shadows ? '开' : '关'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) =>
|
||||
@ -359,9 +375,9 @@ function DisplayMenu() {
|
||||
icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'}
|
||||
width={16}
|
||||
/>
|
||||
<span>Camera</span>
|
||||
<span>相机</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
||||
{cameraMode === 'perspective' ? '透视' : '正交'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@ -370,9 +386,9 @@ function DisplayMenu() {
|
||||
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
|
||||
{unit === 'metric' ? 'm' : 'ft'}
|
||||
</span>
|
||||
<span>Units</span>
|
||||
<span>单位</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{unit === 'metric' ? 'Metric' : 'Imperial'}
|
||||
{unit === 'metric' ? '公制' : '英制'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -381,7 +397,7 @@ function DisplayMenu() {
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<activeShading.icon className="h-4 w-4" />
|
||||
<span>Render</span>
|
||||
<span>渲染</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
@ -406,7 +422,7 @@ function DisplayMenu() {
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<PenLine className="h-4 w-4" />
|
||||
<span>Edges</span>
|
||||
<span>边线</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
@ -425,9 +441,9 @@ function DisplayMenu() {
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SwatchBook className="h-4 w-4" />
|
||||
<span>Theme</span>
|
||||
<span>主题</span>
|
||||
<span className="ml-auto truncate text-muted-foreground text-xs">
|
||||
{activeTheme.name}
|
||||
{getSceneThemeLabel(activeTheme.id, activeTheme.name)}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-48 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl">
|
||||
@ -445,7 +461,9 @@ function DisplayMenu() {
|
||||
<span key={`${theme.id}-${index}`} style={{ backgroundColor: color }} />
|
||||
))}
|
||||
</span>
|
||||
<span className="text-foreground">{theme.name}</span>
|
||||
<span className="text-foreground">
|
||||
{getSceneThemeLabel(theme.id, theme.name)}
|
||||
</span>
|
||||
{sceneTheme === theme.id ? (
|
||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : null}
|
||||
@ -473,7 +491,7 @@ function WalkthroughButton() {
|
||||
}, [isFirstPersonMode, setFirstPersonMode])
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label="Walkthrough">
|
||||
<ToolbarTooltip label="漫游模式">
|
||||
<button
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
@ -490,14 +508,14 @@ function WalkthroughButton() {
|
||||
|
||||
function PreviewButton() {
|
||||
return (
|
||||
<ToolbarTooltip label="Preview mode">
|
||||
<ToolbarTooltip label="预览模式">
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
|
||||
onClick={() => useEditor.getState().setPreviewMode(true)}
|
||||
type="button"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Preview</span>
|
||||
<span>预览</span>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import type { LevelNode } from '../schema'
|
||||
|
||||
export function getDefaultLevelName(level: number): string {
|
||||
if (level === 0) return 'Ground Floor'
|
||||
if (level > 0) return `Floor ${level}`
|
||||
return `Basement ${-level}`
|
||||
if (level === 0) return '首层'
|
||||
if (level > 0) return `${level + 1} 层`
|
||||
return `地下 ${-level} 层`
|
||||
}
|
||||
|
||||
export function getLevelDisplayName(level: Pick<LevelNode, 'name' | 'level'>): string {
|
||||
return level.name || getDefaultLevelName(level.level)
|
||||
const name = level.name?.trim()
|
||||
if (!name || name === 'Ground Floor') return getDefaultLevelName(level.level)
|
||||
|
||||
const floorMatch = name.match(/^Floor\s+(\d+)$/i)
|
||||
if (floorMatch) return `${Number(floorMatch[1]) + 1} 层`
|
||||
|
||||
const basementMatch = name.match(/^Basement\s+(\d+)$/i)
|
||||
if (basementMatch) return `地下 ${Number(basementMatch[1])} 层`
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
@ -473,7 +473,7 @@ function FloorplanCompassButton({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Align view to north"
|
||||
aria-label="将视图对齐到正北"
|
||||
className="group absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@ -358,40 +358,41 @@ type CameraControlHint = {
|
||||
|
||||
const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
|
||||
{
|
||||
action: 'Pan',
|
||||
action: '平移',
|
||||
keys: [{ value: 'Space' }, { value: 'Left click' }],
|
||||
alternativeKeys: [{ value: 'Middle click' }],
|
||||
},
|
||||
{ action: 'Rotate', keys: [{ value: 'Right click' }] },
|
||||
{ action: 'Zoom', keys: [{ value: 'Scroll' }] },
|
||||
{ action: '旋转', keys: [{ value: 'Right click' }] },
|
||||
{ action: '缩放', keys: [{ value: 'Scroll' }] },
|
||||
]
|
||||
|
||||
const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
|
||||
{ action: 'Pan', keys: [{ value: 'Left click' }] },
|
||||
{ action: 'Rotate', keys: [{ value: 'Right click' }] },
|
||||
{ action: 'Zoom', keys: [{ value: 'Scroll' }] },
|
||||
{ action: '平移', keys: [{ value: 'Left click' }] },
|
||||
{ action: '旋转', keys: [{ value: 'Right click' }] },
|
||||
{ action: '缩放', keys: [{ value: 'Scroll' }] },
|
||||
]
|
||||
|
||||
const CAMERA_SHORTCUT_KEY_META: Record<string, { icon?: string; label: string; text?: string }> = {
|
||||
'Left click': {
|
||||
icon: 'ph:mouse-left-click-fill',
|
||||
label: 'Left click',
|
||||
label: '鼠标左键',
|
||||
},
|
||||
'Middle click': {
|
||||
icon: 'qlementine-icons:mouse-middle-button-16',
|
||||
label: 'Middle click',
|
||||
label: '鼠标中键',
|
||||
},
|
||||
'Right click': {
|
||||
icon: 'ph:mouse-right-click-fill',
|
||||
label: 'Right click',
|
||||
label: '鼠标右键',
|
||||
},
|
||||
Scroll: {
|
||||
icon: 'qlementine-icons:mouse-middle-button-16',
|
||||
label: 'Scroll wheel',
|
||||
label: '鼠标滚轮',
|
||||
},
|
||||
Space: {
|
||||
icon: 'lucide:space',
|
||||
label: 'Space',
|
||||
label: '空格键',
|
||||
text: '空格',
|
||||
},
|
||||
}
|
||||
|
||||
@ -490,7 +491,7 @@ function ViewerCanvasControlsHint({
|
||||
return (
|
||||
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
|
||||
<section
|
||||
aria-label="Camera controls hint"
|
||||
aria-label="相机操作提示"
|
||||
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
|
||||
>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
|
||||
@ -501,7 +502,7 @@ function ViewerCanvasControlsHint({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Dismiss camera controls hint"
|
||||
aria-label="关闭相机操作提示"
|
||||
className="flex h-5 shrink-0 items-center justify-center self-center border-border/18 border-l pl-3 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
@ -516,7 +517,7 @@ function ViewerCanvasControlsHint({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
Dismiss
|
||||
关闭
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</section>
|
||||
|
||||
@ -39,10 +39,10 @@ export function NodeActionMenu({
|
||||
>
|
||||
{onFind && (
|
||||
<button
|
||||
aria-label="Find in catalog"
|
||||
aria-label="在素材库中查找"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onFind}
|
||||
title="Find in catalog"
|
||||
title="在素材库中查找"
|
||||
type="button"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
@ -50,10 +50,10 @@ export function NodeActionMenu({
|
||||
)}
|
||||
{onMove && (
|
||||
<button
|
||||
aria-label="Move"
|
||||
aria-label="移动"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onMove}
|
||||
title="Move"
|
||||
title="移动"
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
@ -61,10 +61,10 @@ export function NodeActionMenu({
|
||||
)}
|
||||
{onCurve && (
|
||||
<button
|
||||
aria-label="Curve"
|
||||
aria-label="曲线"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onCurve}
|
||||
title="Curve"
|
||||
title="曲线"
|
||||
type="button"
|
||||
>
|
||||
<Spline className="h-4 w-4" />
|
||||
@ -72,10 +72,10 @@ export function NodeActionMenu({
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<button
|
||||
aria-label="Duplicate"
|
||||
aria-label="复制"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onDuplicate}
|
||||
title="Duplicate"
|
||||
title="复制"
|
||||
type="button"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@ -83,10 +83,10 @@ export function NodeActionMenu({
|
||||
)}
|
||||
{onAddHole && (
|
||||
<button
|
||||
aria-label="Cut Out"
|
||||
aria-label="开孔"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onAddHole}
|
||||
title="Cut Out"
|
||||
title="开孔"
|
||||
type="button"
|
||||
>
|
||||
<Icon height={16} icon="carbon:cut-out" width={16} />
|
||||
@ -94,10 +94,10 @@ export function NodeActionMenu({
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
aria-label="Delete"
|
||||
aria-label="删除"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
title="删除"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@ -25,7 +25,7 @@ const controls: ControlConfig[] = [
|
||||
{
|
||||
id: 'select',
|
||||
imageSrc: '/icons/select.webp',
|
||||
label: 'Select',
|
||||
label: '选择',
|
||||
shortcut: 'V',
|
||||
color: 'hover:bg-blue-500/20 hover:text-blue-400',
|
||||
activeColor: 'bg-blue-500/20 text-blue-400',
|
||||
@ -33,7 +33,7 @@ const controls: ControlConfig[] = [
|
||||
{
|
||||
id: 'zone',
|
||||
imageSrc: '/icons/zone.webp',
|
||||
label: 'Zone',
|
||||
label: '区域',
|
||||
shortcut: 'Z',
|
||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||
activeColor: 'bg-green-500/20 text-green-400',
|
||||
@ -41,7 +41,7 @@ const controls: ControlConfig[] = [
|
||||
{
|
||||
id: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
label: '删除',
|
||||
shortcut: 'X',
|
||||
color: 'hover:bg-red-500/20 hover:text-red-400',
|
||||
activeColor: 'bg-red-500/20 text-red-400',
|
||||
|
||||
@ -8,9 +8,9 @@ export type FurnishToolConfig = {
|
||||
}
|
||||
|
||||
export const furnishTools: FurnishToolConfig[] = [
|
||||
{ id: 'item', iconSrc: '/icons/couch.webp', label: 'Furniture', catalogCategory: 'furniture' },
|
||||
{ id: 'item', iconSrc: '/icons/appliance.webp', label: 'Appliance', catalogCategory: 'appliance' },
|
||||
{ id: 'item', iconSrc: '/icons/kitchen.webp', label: 'Kitchen', catalogCategory: 'kitchen' },
|
||||
{ id: 'item', iconSrc: '/icons/bathroom.webp', label: 'Bathroom', catalogCategory: 'bathroom' },
|
||||
{ id: 'item', iconSrc: '/icons/tree.webp', label: 'Outdoor', catalogCategory: 'outdoor' },
|
||||
{ id: 'item', iconSrc: '/icons/couch.webp', label: '家具', catalogCategory: 'furniture' },
|
||||
{ id: 'item', iconSrc: '/icons/appliance.webp', label: '家电', catalogCategory: 'appliance' },
|
||||
{ id: 'item', iconSrc: '/icons/kitchen.webp', label: '厨房', catalogCategory: 'kitchen' },
|
||||
{ id: 'item', iconSrc: '/icons/bathroom.webp', label: '卫浴', catalogCategory: 'bathroom' },
|
||||
{ id: 'item', iconSrc: '/icons/tree.webp', label: '户外', catalogCategory: 'outdoor' },
|
||||
]
|
||||
|
||||
@ -24,7 +24,7 @@ import { ActionButton } from './action-button'
|
||||
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
||||
const REFERENCES_EMPTY_TEXT =
|
||||
'Upload GLB meshes as scan references or blueprint images as guide references.'
|
||||
'可上传 GLB 网格作为扫描参考,或上传蓝图图片作为导引参考。'
|
||||
|
||||
// ── Helper: get guide images for the current level ──────────────────────────
|
||||
|
||||
@ -100,7 +100,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
|
||||
onError(null)
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
onError('File is too large. Maximum size is 200 MB.')
|
||||
onError('文件过大,最大支持 200 MB。')
|
||||
return
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
|
||||
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!(isScan || isImage)) {
|
||||
onError('Upload a .glb/.gltf scan or an image.')
|
||||
onError('请上传 .glb/.gltf 扫描文件或图片。')
|
||||
return
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
|
||||
setSelectedReferenceId(guide.id)
|
||||
setSelection({ selectedIds: [], zoneId: null })
|
||||
} catch {
|
||||
onError('Could not add that guide image.')
|
||||
onError('无法添加该导引图片。')
|
||||
} finally {
|
||||
setIsAddingGuide(false)
|
||||
}
|
||||
@ -129,13 +129,13 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
|
||||
|
||||
const { uploadHandler } = useUploadStore.getState()
|
||||
if (!uploadHandler) {
|
||||
onError('Scan upload is unavailable.')
|
||||
onError('扫描文件上传暂不可用。')
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
|
||||
if (!projectId) {
|
||||
onError('Open a project before uploading a scan.')
|
||||
onError('请先打开项目,再上传扫描文件。')
|
||||
return
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label="Upload scan or guide image"
|
||||
aria-label="上传扫描或导引图片"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||
disabled={isAddingGuide}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@ -571,13 +571,12 @@ function ReferenceListSection({
|
||||
<p className="font-medium text-foreground text-sm">{title}</p>
|
||||
{hasItems && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{nodes.length} {noun}
|
||||
{nodes.length !== 1 ? 's' : ''} on this level
|
||||
本层共 {nodes.length} 个{noun}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={show ? `Hide ${title.toLowerCase()}` : `Show ${title.toLowerCase()}`}
|
||||
aria-label={show ? `隐藏${title}` : `显示${title}`}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||
onClick={() => setShow(!show)}
|
||||
type="button"
|
||||
@ -611,14 +610,14 @@ function ReferenceListSection({
|
||||
src={iconSrc}
|
||||
/>
|
||||
<p className="truncate font-medium text-foreground text-sm">
|
||||
{node.name || `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${index + 1}`}
|
||||
{node.name || `${noun} ${index + 1}`}
|
||||
</p>
|
||||
{selectedReferenceId === node.id && (
|
||||
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Delete ${noun}`}
|
||||
aria-label={`删除${noun}`}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
@ -633,7 +632,7 @@ function ReferenceListSection({
|
||||
</button>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Opacity"
|
||||
label="不透明度"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(value) =>
|
||||
@ -685,14 +684,14 @@ function ReferencesControl() {
|
||||
? 'bg-white/15'
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label={`References: ${anyVisible ? 'Visible' : 'Hidden'}`}
|
||||
label={`参考资源:${anyVisible ? '可见' : '隐藏'}`}
|
||||
onClick={toggleAll}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
alt="References"
|
||||
alt="参考资源"
|
||||
className="h-[28px] w-[28px] object-contain"
|
||||
src="/icons/floorplan.webp"
|
||||
/>
|
||||
@ -705,7 +704,7 @@ function ReferencesControl() {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
aria-label="Reference settings"
|
||||
aria-label="参考资源设置"
|
||||
className={cn(
|
||||
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
||||
anyVisible
|
||||
@ -739,22 +738,22 @@ function ReferencesControl() {
|
||||
emptyText={REFERENCES_EMPTY_TEXT}
|
||||
iconSrc="/icons/mesh.webp"
|
||||
nodes={scans}
|
||||
noun="scan"
|
||||
noun="扫描"
|
||||
onError={setUploadError}
|
||||
setShow={setShowScans}
|
||||
show={showScans}
|
||||
title="Scans"
|
||||
title="扫描"
|
||||
/>
|
||||
<div className="h-px bg-border/45" />
|
||||
<ReferenceListSection
|
||||
emptyText={REFERENCES_EMPTY_TEXT}
|
||||
iconSrc="/icons/floorplan.webp"
|
||||
nodes={guides}
|
||||
noun="guide image"
|
||||
noun="导引图片"
|
||||
onError={setUploadError}
|
||||
setShow={setShowGuides}
|
||||
show={showGuides}
|
||||
title="Guide images"
|
||||
title="导引图片"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@ -790,8 +789,8 @@ function ReferenceFloorControl() {
|
||||
disabled={!hasLowerLevels}
|
||||
label={
|
||||
selectedLevelName && showReferenceFloor
|
||||
? `Reference floor: ${selectedLevelName}`
|
||||
: 'Reference floor'
|
||||
? `参考楼层:${selectedLevelName}`
|
||||
: '参考楼层'
|
||||
}
|
||||
onClick={() => {
|
||||
if (hasLowerLevels) toggleReferenceFloor()
|
||||
@ -810,7 +809,7 @@ function ReferenceFloorControl() {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
aria-label="Reference floor settings"
|
||||
aria-label="参考楼层设置"
|
||||
className={cn(
|
||||
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
||||
showReferenceFloor && selectedLevel
|
||||
@ -841,13 +840,13 @@ function ReferenceFloorControl() {
|
||||
<Layers2 className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground text-sm">Reference floor</p>
|
||||
<p className="font-medium text-foreground text-sm">参考楼层</p>
|
||||
{selectedLevelName && (
|
||||
<p className="truncate text-muted-foreground text-xs">{selectedLevelName}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={showReferenceFloor ? 'Hide reference floor' : 'Show reference floor'}
|
||||
aria-label={showReferenceFloor ? '隐藏参考楼层' : '显示参考楼层'}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={!hasLowerLevels}
|
||||
onClick={toggleReferenceFloor}
|
||||
@ -891,14 +890,14 @@ function ReferenceFloorControl() {
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{levelName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{index + 1} below</span>
|
||||
<span className="text-[10px] text-muted-foreground">下方第 {index + 1} 层</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SliderControl
|
||||
label="Opacity"
|
||||
label="不透明度"
|
||||
max={0.8}
|
||||
min={0.1}
|
||||
onChange={setReferenceFloorOpacity}
|
||||
@ -909,7 +908,7 @@ function ReferenceFloorControl() {
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
||||
No lower floor available.
|
||||
暂无可用的下方楼层。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -166,7 +166,7 @@ function LevelRow({
|
||||
>
|
||||
<button
|
||||
{...dragHandleProps}
|
||||
aria-label={`Reorder ${getLevelDisplayName(level)}`}
|
||||
aria-label={`重新排序 ${getLevelDisplayName(level)}`}
|
||||
className={cn(
|
||||
'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
|
||||
isDragging && 'cursor-grabbing opacity-100',
|
||||
@ -517,7 +517,7 @@ export function FloatingLevelSelector() {
|
||||
<button
|
||||
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
|
||||
onClick={handleAddAbove}
|
||||
title="Add level above"
|
||||
title="在上方添加楼层"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
@ -529,7 +529,7 @@ export function FloatingLevelSelector() {
|
||||
<button
|
||||
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
|
||||
onClick={handleAddBelow}
|
||||
title="Add level below"
|
||||
title="在下方添加楼层"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
@ -574,7 +574,7 @@ export function FloatingLevelSelector() {
|
||||
<button
|
||||
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
|
||||
onClick={() => handleInsertBetween(sortedIndex - 1)}
|
||||
title="Insert level here"
|
||||
title="在此插入楼层"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
@ -593,11 +593,11 @@ export function FloatingLevelSelector() {
|
||||
<Dialog onOpenChange={(open) => !open && setDeletingLevel(null)} open={!!deletingLevel}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete level</DialogTitle>
|
||||
<DialogTitle>删除楼层</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>{deletingLevel ? getLevelDisplayName(deletingLevel) : ''}</strong>? All
|
||||
walls, floors, and objects on this level will be permanently removed.
|
||||
确定要删除{' '}
|
||||
<strong>{deletingLevel ? getLevelDisplayName(deletingLevel) : ''}</strong>
|
||||
?该楼层的墙体、地面和物件将被永久删除。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@ -606,14 +606,14 @@ export function FloatingLevelSelector() {
|
||||
onClick={() => setDeletingLevel(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
|
||||
onClick={handleConfirmDelete}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
删除
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -174,7 +174,7 @@ export function ParametricInspector({
|
||||
</Suspense>
|
||||
)}
|
||||
{(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
|
||||
<PanelSection title="Actions">
|
||||
<PanelSection title="操作">
|
||||
<ActionGroup className={isZone ? 'flex-col' : undefined}>
|
||||
{canMove && (
|
||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||
|
||||
@ -23,13 +23,13 @@ interface IconRailProps {
|
||||
const sitePanel: { id: PanelId; iconSrc: string; label: string } = {
|
||||
id: 'site',
|
||||
iconSrc: '/icons/level.webp',
|
||||
label: 'Site',
|
||||
label: '场景',
|
||||
}
|
||||
|
||||
const settingsPanel: { id: PanelId; iconSrc: string; label: string } = {
|
||||
id: 'settings',
|
||||
iconSrc: '/icons/settings.webp',
|
||||
label: 'Settings',
|
||||
label: '设置',
|
||||
}
|
||||
|
||||
const panels: { id: PanelId; iconSrc: string; label: string }[] = [sitePanel, settingsPanel]
|
||||
|
||||
@ -22,9 +22,9 @@ export type FunctionTreeNode = {
|
||||
}
|
||||
|
||||
const SOURCE_CHIPS: Array<{ id: NonNullable<AssetInput['source']>; label: string }> = [
|
||||
{ id: 'library', label: 'Library' },
|
||||
{ id: 'community', label: 'Community' },
|
||||
{ id: 'mine', label: 'Mine' },
|
||||
{ id: 'library', label: '素材库' },
|
||||
{ id: 'community', label: '社区' },
|
||||
{ id: 'mine', label: '我的' },
|
||||
]
|
||||
|
||||
/** Every slug at or below `node`, so a non-leaf selection matches descendants. */
|
||||
@ -173,7 +173,7 @@ export function FunctionTreePanel({
|
||||
setSearch(e.target.value)
|
||||
onSearchChange?.(e.target.value)
|
||||
}}
|
||||
placeholder="Search..."
|
||||
placeholder="搜索物品…"
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
@ -212,7 +212,7 @@ export function FunctionTreePanel({
|
||||
onClick={() => setActiveChildSlug(null)}
|
||||
type="button"
|
||||
>
|
||||
All
|
||||
全部
|
||||
</button>
|
||||
{activeRoot.children.map((child) => {
|
||||
const isActive = activeChildSlug === child.slug
|
||||
|
||||
@ -167,9 +167,9 @@ function LegacyItemsPanel({
|
||||
// filter even before they own any items. Selecting "Mine" with no
|
||||
// matching items falls through to the empty/no-results state.
|
||||
const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
|
||||
{ id: 'library', label: 'Library' },
|
||||
{ id: 'community', label: 'Community' },
|
||||
{ id: 'mine', label: 'Mine' },
|
||||
{ id: 'library', label: '素材库' },
|
||||
{ id: 'community', label: '社区' },
|
||||
{ id: 'mine', label: '我的' },
|
||||
]
|
||||
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
||||
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
||||
@ -242,7 +242,7 @@ function LegacyItemsPanel({
|
||||
setSearch(e.target.value)
|
||||
onSearchChange?.(e.target.value)
|
||||
}}
|
||||
placeholder="Search..."
|
||||
placeholder="搜索物品…"
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
|
||||
@ -28,19 +28,19 @@ export function AudioSettingsDialog() {
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full justify-start gap-2" variant="outline">
|
||||
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||
Audio Settings
|
||||
音频设置
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Audio Settings</DialogTitle>
|
||||
<DialogDescription>Adjust volume levels and mute settings</DialogDescription>
|
||||
<DialogTitle>音频设置</DialogTitle>
|
||||
<DialogDescription>调整音量和静音设置</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Master Volume */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-sm">Master Volume</label>
|
||||
<label className="font-medium text-sm">主音量</label>
|
||||
<span className="text-muted-foreground text-sm">{masterVolume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
@ -55,7 +55,7 @@ export function AudioSettingsDialog() {
|
||||
{/* Radio Volume */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-sm">Radio Volume</label>
|
||||
<label className="font-medium text-sm">背景音乐</label>
|
||||
<span className="text-muted-foreground text-sm">{radioVolume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
@ -70,7 +70,7 @@ export function AudioSettingsDialog() {
|
||||
{/* SFX Volume */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-sm">Sound Effects</label>
|
||||
<label className="font-medium text-sm">操作音效</label>
|
||||
<span className="text-muted-foreground text-sm">{sfxVolume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
@ -90,7 +90,7 @@ export function AudioSettingsDialog() {
|
||||
variant={muted ? 'default' : 'outline'}
|
||||
>
|
||||
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||
{muted ? 'Unmute All Sounds' : 'Mute All Sounds'}
|
||||
{muted ? '恢复全部声音' : '静音全部声音'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -298,12 +298,12 @@ export function SettingsPanel({
|
||||
{/* Visibility Section (only for cloud projects) */}
|
||||
{projectId && !isLocalProject && (
|
||||
<div className="space-y-3">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Visibility</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">可见性</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Public</div>
|
||||
<div className="font-medium text-sm">公开访问</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view
|
||||
{projectVisibility?.isPrivate ? '仅自己可见' : '所有人可见'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@ -313,8 +313,8 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Show 3D Scans</div>
|
||||
<div className="text-muted-foreground text-xs">Visible to public viewers</div>
|
||||
<div className="font-medium text-sm">显示 3D 扫描</div>
|
||||
<div className="text-muted-foreground text-xs">对公开访问者可见</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={projectVisibility?.showScansPublic ?? true}
|
||||
@ -323,8 +323,8 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Show Floorplans</div>
|
||||
<div className="text-muted-foreground text-xs">Visible to public viewers</div>
|
||||
<div className="font-medium text-sm">显示平面图</div>
|
||||
<div className="text-muted-foreground text-xs">对公开访问者可见</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={projectVisibility?.showGuidesPublic ?? true}
|
||||
@ -333,8 +333,8 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Shadows</div>
|
||||
<div className="text-muted-foreground text-xs">Cast shadows from lights</div>
|
||||
<div className="font-medium text-sm">阴影</div>
|
||||
<div className="text-muted-foreground text-xs">显示灯光投射阴影</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={shadows}
|
||||
@ -346,17 +346,17 @@ export function SettingsPanel({
|
||||
|
||||
{/* Export Section */}
|
||||
<div className="space-y-4">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">导出</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">3D model</div>
|
||||
<div className="font-medium text-muted-foreground text-xs">3D 模型</div>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('glb')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export GLB
|
||||
导出 GLB
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
@ -364,7 +364,7 @@ export function SettingsPanel({
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export STL
|
||||
导出 STL
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
@ -372,19 +372,19 @@ export function SettingsPanel({
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export OBJ
|
||||
导出 OBJ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">Floorplan</div>
|
||||
<div className="font-medium text-muted-foreground text-xs">平面图</div>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportFloorplanPdf('full')}
|
||||
variant="outline"
|
||||
>
|
||||
<MapIcon className="size-4" />
|
||||
Full floorplan
|
||||
完整平面图
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
@ -392,7 +392,7 @@ export function SettingsPanel({
|
||||
variant="outline"
|
||||
>
|
||||
<MapIcon className="size-4" />
|
||||
Structure only
|
||||
仅结构
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -400,7 +400,7 @@ export function SettingsPanel({
|
||||
{/* Thumbnail Section (only for cloud projects) */}
|
||||
{projectId && !isLocalProject && (
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Thumbnail</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">缩略图</label>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
disabled={isGeneratingThumbnail}
|
||||
@ -408,18 +408,18 @@ export function SettingsPanel({
|
||||
variant="outline"
|
||||
>
|
||||
<Camera className="size-4" />
|
||||
{isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}
|
||||
{isGeneratingThumbnail ? '正在生成…' : '生成缩略图'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save/Load Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Save & Load</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">保存与加载</label>
|
||||
|
||||
<Button className="w-full justify-start gap-2" onClick={handleSaveBuild} variant="outline">
|
||||
<Save className="size-4" />
|
||||
Save Build
|
||||
保存建筑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@ -428,7 +428,7 @@ export function SettingsPanel({
|
||||
variant="outline"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Load Build
|
||||
加载建筑
|
||||
</Button>
|
||||
|
||||
<input
|
||||
@ -448,27 +448,27 @@ export function SettingsPanel({
|
||||
|
||||
{/* Audio Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Audio</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">音频</label>
|
||||
<AudioSettingsDialog />
|
||||
</div>
|
||||
|
||||
{/* Keyboard Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Keyboard</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">键盘</label>
|
||||
<KeyboardShortcutsDialog />
|
||||
</div>
|
||||
|
||||
{/* Scene Graph */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Scene Graph</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">场景树</label>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="h-auto justify-start p-0 text-sm" variant="link">
|
||||
Explore scene graph
|
||||
查看场景树
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
|
||||
<DialogTitle className="sr-only">Scene Graph</DialogTitle>
|
||||
<DialogTitle className="sr-only">场景树</DialogTitle>
|
||||
<div
|
||||
className="flex h-full min-h-0 w-full min-w-0 *:h-full *:w-full *:overflow-y-auto"
|
||||
onContextMenuCapture={blockSceneGraphMutations}
|
||||
@ -486,7 +486,7 @@ export function SettingsPanel({
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-destructive text-xs uppercase">Danger Zone</label>
|
||||
<label className="font-medium text-destructive text-xs uppercase">危险操作</label>
|
||||
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
@ -494,7 +494,7 @@ export function SettingsPanel({
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Clear & Start New
|
||||
清空并重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -195,15 +195,15 @@ export function KeyboardShortcutsDialog() {
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full justify-start gap-2" variant="outline">
|
||||
<Keyboard className="size-4" />
|
||||
Keyboard Shortcuts
|
||||
键盘快捷键
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
<DialogTitle>键盘快捷键</DialogTitle>
|
||||
<DialogDescription>
|
||||
Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift
|
||||
during an active gesture to build freely.
|
||||
快捷键会根据当前操作场景生效。默认启用辅助约束,操作时按住 Shift
|
||||
可自由搭建。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@ -165,7 +165,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Pentagon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">Property Line</span>
|
||||
<span className="font-medium text-sm">地块边界</span>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
@ -278,7 +278,7 @@ const CameraPopover = memo(function CameraPopover({
|
||||
buttonClassName,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
title="相机快照"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
{hasCamera && (
|
||||
@ -567,7 +567,7 @@ const LevelReferences = memo(function LevelReferences({
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? `Uploading ${uploadingType}... ${progress}%` : 'Upload scan/floorplan'}
|
||||
{uploading ? `正在上传 ${uploadingType}… ${progress}%` : '上传扫描/平面图'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
@ -739,7 +739,7 @@ const LevelItem = memo(function LevelItem({
|
||||
|
||||
<div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-0.5 text-sm">
|
||||
<img
|
||||
alt="Level"
|
||||
alt="楼层"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 object-contain transition-all duration-200',
|
||||
!isSelected && 'opacity-60 grayscale',
|
||||
@ -765,7 +765,7 @@ const LevelItem = memo(function LevelItem({
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
title="相机快照"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
{level.camera && (
|
||||
@ -949,7 +949,7 @@ const LevelsSection = memo(function LevelsSection({
|
||||
<div className="relative z-10 flex items-center pr-1 pl-[38px]">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="truncate">Add level</span>
|
||||
<span className="truncate">添加楼层</span>
|
||||
</button>
|
||||
{levels.length === 0 && (
|
||||
<div className="relative flex h-8 select-none items-center border-border/50 border-b py-0 pr-2 pl-[38px] text-muted-foreground text-xs">
|
||||
@ -957,7 +957,7 @@ const LevelsSection = memo(function LevelsSection({
|
||||
<div className="pointer-events-none absolute top-0 bottom-1/2 left-[21px] w-px bg-border/50" />
|
||||
{/* Horizontal branch line */}
|
||||
<div className="pointer-events-none absolute top-1/2 left-[21px] h-px w-[11px] bg-border/50" />
|
||||
No levels yet
|
||||
暂无楼层
|
||||
</div>
|
||||
)}
|
||||
{[...levels].reverse().map((level, index) => (
|
||||
@ -1017,14 +1017,14 @@ const LayerToggle = memo(function LayerToggle() {
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<img
|
||||
alt="Structure"
|
||||
alt="结构"
|
||||
className={cn(
|
||||
'mb-1 h-6 w-6 transition-all',
|
||||
activeTab !== 'structure' && 'opacity-50 grayscale',
|
||||
)}
|
||||
src="/icons/room.webp"
|
||||
/>
|
||||
Structure
|
||||
结构
|
||||
</div>
|
||||
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
|
||||
@ -1053,14 +1053,14 @@ const LayerToggle = memo(function LayerToggle() {
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<img
|
||||
alt="Furnish"
|
||||
alt="陈设"
|
||||
className={cn(
|
||||
'mb-1 h-6 w-6 transition-all',
|
||||
activeTab !== 'furnish' && 'opacity-50 grayscale',
|
||||
)}
|
||||
src="/icons/couch.webp"
|
||||
/>
|
||||
Furnish
|
||||
陈设
|
||||
</div>
|
||||
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
|
||||
@ -1090,14 +1090,14 @@ const LayerToggle = memo(function LayerToggle() {
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<img
|
||||
alt="Zones"
|
||||
alt="区域"
|
||||
className={cn(
|
||||
'mb-1 h-6 w-6 transition-all',
|
||||
activeTab !== 'zones' && 'opacity-50 grayscale',
|
||||
)}
|
||||
src="/icons/kitchen.webp"
|
||||
/>
|
||||
Zones
|
||||
区域
|
||||
</div>
|
||||
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
|
||||
@ -1206,7 +1206,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
||||
<button
|
||||
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
title="相机快照"
|
||||
>
|
||||
<Camera className="h-3 w-3" />
|
||||
{zone.camera && (
|
||||
@ -1281,11 +1281,11 @@ const MultiSelectionBadge = memo(function MultiSelectionBadge() {
|
||||
return (
|
||||
<div className="pointer-events-none sticky top-4 z-50 flex h-0 w-full justify-center overflow-visible">
|
||||
<div className="pointer-events-auto flex items-center gap-2.5 rounded-full border border-primary/20 bg-primary px-0.5 py-4 pl-2 font-medium text-primary-foreground text-xs shadow-black/10 shadow-lg backdrop-blur-md">
|
||||
<span>{selectedIds.length} objects selected</span>
|
||||
<span>已选择 {selectedIds.length} 个对象</span>
|
||||
<button
|
||||
className="cursor-pointer rounded-full p-1.5 transition-colors hover:bg-primary-foreground/20"
|
||||
onClick={() => setSelection({ selectedIds: [] })}
|
||||
title="Clear selection"
|
||||
title="清除选择"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@ -1324,7 +1324,7 @@ const ContentSection = memo(function ContentSection() {
|
||||
|
||||
if (!level) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-muted-foreground text-sm">Select a level to view content</div>
|
||||
<div className="px-3 py-4 text-muted-foreground text-sm">请选择楼层以查看内容</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1338,9 +1338,9 @@ const ContentSection = memo(function ContentSection() {
|
||||
if (levelZones.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-muted-foreground text-sm">
|
||||
No zones on this level.{' '}
|
||||
该楼层暂无区域。{' '}
|
||||
<button className="cursor-pointer text-primary hover:underline" onClick={handleAddZone}>
|
||||
Add one
|
||||
添加区域
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@ -1356,7 +1356,7 @@ const ContentSection = memo(function ContentSection() {
|
||||
}
|
||||
|
||||
if (elementChildren.length === 0) {
|
||||
return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div>
|
||||
return <div className="px-3 py-4 text-muted-foreground text-sm">该楼层暂无元素</div>
|
||||
}
|
||||
return (
|
||||
<TreeNodeDragProvider>
|
||||
@ -1431,14 +1431,14 @@ const BuildingItem = memo(function BuildingItem({
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3">
|
||||
<img
|
||||
alt="Building"
|
||||
alt="建筑"
|
||||
className={cn(
|
||||
'h-5 w-5 object-contain transition-all',
|
||||
!isBuildingActive && 'opacity-60 grayscale',
|
||||
)}
|
||||
src="/icons/building.webp"
|
||||
/>
|
||||
<span className="truncate font-medium text-sm">{building.name || 'Building'}</span>
|
||||
<span className="truncate font-medium text-sm">{building.name || '建筑'}</span>
|
||||
</div>
|
||||
<Popover
|
||||
onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
|
||||
@ -1453,7 +1453,7 @@ const BuildingItem = memo(function BuildingItem({
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
title="相机快照"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
{building.camera && (
|
||||
@ -1587,14 +1587,14 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt="Site"
|
||||
alt="场地"
|
||||
className={cn(
|
||||
'h-5 w-5 object-contain transition-all',
|
||||
phase !== 'site' && 'opacity-60 grayscale',
|
||||
)}
|
||||
src="/icons/site-flag.webp"
|
||||
/>
|
||||
<span className="font-medium text-sm">{siteNode.name || 'Site'}</span>
|
||||
<span className="font-medium text-sm">{siteNode.name || '场地'}</span>
|
||||
</div>
|
||||
<CameraPopover
|
||||
buttonClassName={cn(
|
||||
|
||||
@ -67,7 +67,7 @@ export const TreeNodeActions = memo(function TreeNodeActions({ nodeId }: TreeNod
|
||||
<button
|
||||
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
title="相机快照"
|
||||
>
|
||||
<Camera className="h-3 w-3" />
|
||||
{hasCamera && (
|
||||
|
||||
@ -25,7 +25,7 @@ export const treesPlugin: Plugin = {
|
||||
panels: [
|
||||
{
|
||||
id: 'trees',
|
||||
label: 'Nature',
|
||||
label: '自然资产',
|
||||
icon: { kind: 'url', src: NATURE_ICON },
|
||||
component: () => import('./presets-panel'),
|
||||
},
|
||||
|
||||
@ -41,7 +41,7 @@ export default function TreesPanel() {
|
||||
<div className="flex flex-col gap-4 p-4 text-sidebar-foreground">
|
||||
<header className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-base">Nature</h2>
|
||||
<h2 className="font-semibold text-base">自然资产</h2>
|
||||
<span className="rounded-full bg-sidebar-accent px-2 py-0.5 text-sidebar-foreground/70 text-xs">
|
||||
{count} planted
|
||||
</span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user