From 23b1703a42f81d45b7db7459645dc0ad634b129b Mon Sep 17 00:00:00 2001 From: Daxiongzhang Date: Fri, 9 Oct 2026 09:44:45 +0800 Subject: [PATCH] feat(i18n): add Simplified Chinese localization --- apps/editor/app/client-bootstrap.tsx | 3 +- apps/editor/app/page.tsx | 17 +- apps/editor/app/scenes/page.tsx | 38 +- apps/editor/components/build-tab.tsx | 6 +- apps/editor/components/save-button.tsx | 10 +- apps/editor/components/viewer-toolbar.tsx | 170 ++- apps/editor/lib/build-tab-state.ts | 23 +- apps/editor/lib/i18n.test.ts | 29 + apps/editor/lib/i18n.tsx | 1359 +++++++++++++++++ .../editor/src/components/editor/index.tsx | 25 +- .../ui/action-menu/camera-actions.tsx | 14 +- .../ui/action-menu/measurement-control.tsx | 17 +- .../ui/action-menu/view-toggles.tsx | 60 +- .../components/ui/floating-level-selector.tsx | 22 +- .../ui/item-catalog/item-catalog.tsx | 6 +- .../ui/panels/mobile-panel-sheet.tsx | 4 +- .../src/components/ui/primitives/dialog.tsx | 4 +- .../src/components/ui/primitives/sheet.tsx | 4 +- .../ui/primitives/shortcut-token.tsx | 14 +- .../ui/sidebar/panels/items-panel/index.tsx | 10 +- .../settings-panel/audio-settings-dialog.tsx | 16 +- .../keyboard-shortcuts-dialog.tsx | 22 +- .../settings-panel/load-build-dialog.tsx | 16 +- .../settings-panel/print-export-button.tsx | 6 +- .../ui/sidebar/use-plugin-panels.tsx | 25 +- .../viewer/floorplan-compass-button.tsx | 6 +- .../components/viewer/viewer-controls-bar.tsx | 86 +- .../viewer/viewer-stage-switcher.tsx | 6 +- packages/editor/src/index.tsx | 1 + packages/editor/src/lib/display-text.test.tsx | 23 + packages/editor/src/lib/display-text.tsx | 22 + 31 files changed, 1817 insertions(+), 247 deletions(-) create mode 100644 apps/editor/lib/i18n.test.ts create mode 100644 apps/editor/lib/i18n.tsx create mode 100644 packages/editor/src/lib/display-text.test.tsx create mode 100644 packages/editor/src/lib/display-text.tsx diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index 821544fec6..724d6ac5ea 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -10,6 +10,7 @@ // idempotent under HMR. import '../lib/bootstrap' import { type ReactNode, useEffect } from 'react' +import { I18nProvider } from '@/lib/i18n' export function ClientBootstrap({ children, @@ -22,5 +23,5 @@ export function ClientBootstrap({ if (!enableDevDiagnostics) return import('react-scan').then(({ scan }) => scan({ enabled: true })) }, [enableDevDiagnostics]) - return children + return {children} } diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index c856db42d9..f427d6574f 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -16,6 +16,7 @@ import { WebXRFeatureConsumer, WebXRFeatureRuntime, } from '@/components/webxr-feature-gate' +import { useI18n } from '@/lib/i18n' // The open-source editor only ships the built-in catalog (no uploaded items), // so the Library/Community/Mine source chips and tag filters add nothing — @@ -111,6 +112,11 @@ const PROJECT_ID = 'local-editor' export default function Home() { const webXRInstalled = useWebXRInstalled() + const { t, translateDisplayText } = useI18n() + const sidebarTabs = SIDEBAR_TABS.map((tab) => ({ + ...tab, + label: translateDisplayText(tab.label), + })) return (
@@ -120,14 +126,12 @@ export default function Home() { {PROJECT_ID === 'local-editor' && (
- - Blank canvas — saved scenes are under Scenes (not this page). - + {t('app.blankCanvas')} - Open saved scenes + {t('app.openScenes')}
@@ -136,7 +140,8 @@ export default function Home() { immersive={vr?.session ? vr.immersive : undefined} layoutVersion="v2" projectId={PROJECT_ID} - sidebarTabs={SIDEBAR_TABS} + sidebarTabs={sidebarTabs} + translateDisplayText={translateDisplayText} viewerToolbarLeft={} viewerToolbarRight={ ) : null } - vrLabel="Enter VR" + vrLabel={translateDisplayText('Enter VR')} /> } /> diff --git a/apps/editor/app/scenes/page.tsx b/apps/editor/app/scenes/page.tsx index 39b8dbf07a..21b01ecaa5 100644 --- a/apps/editor/app/scenes/page.tsx +++ b/apps/editor/app/scenes/page.tsx @@ -1,4 +1,4 @@ -import { headers } from 'next/headers' +import { cookies, headers } from 'next/headers' import Link from 'next/link' import { CreateSceneButton } from '@/components/save-button' import type { SceneMeta } from '@/components/scene-loader' @@ -43,6 +43,8 @@ function formatDate(iso: string): string { export default async function ScenesPage() { const scenes = await fetchScenes() + const locale = (await cookies()).get('pascal.locale')?.value === 'zh-CN' ? 'zh-CN' : 'en' + const zh = locale === 'zh-CN' return (
@@ -53,28 +55,34 @@ export default async function ScenesPage() { className="text-muted-foreground transition-colors hover:text-foreground" href="/" > - Home + {zh ? '首页' : 'Home'} / - Scenes + {zh ? '场景' : 'Scenes'} - +
-

Your scenes

+

{zh ? '我的场景' : 'Your scenes'}

{scenes.length === 0 - ? 'No scenes yet. Create one to get started.' - : `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`} + ? zh + ? '还没有场景,可以先新建一个。' + : 'No scenes yet. Create one to get started.' + : zh + ? `共 ${scenes.length} 个场景。` + : `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`}

{scenes.length === 0 ? (
-

You haven't saved any scenes yet.

+

+ {zh ? '尚未保存任何场景。' : "You haven't saved any scenes yet."} +

- +
) : ( @@ -94,7 +102,9 @@ export default async function ScenesPage() { src={scene.thumbnailUrl} /> ) : ( - No thumbnail + + {zh ? '暂无缩略图' : 'No thumbnail'} + )}
@@ -102,8 +112,12 @@ export default async function ScenesPage() { {scene.name}
- {scene.nodeCount} nodes - + {zh ? `${scene.nodeCount} 个节点` : `${scene.nodeCount} nodes`} +
diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 545d19b5d7..542a6fc987 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -50,6 +50,7 @@ import { MODULAR_CABINET_ICON, } from '@/lib/build-palette' import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' +import { useI18n } from '@/lib/i18n' import { cn } from '@/lib/utils' const subscribeToClientMount = () => () => {} @@ -60,6 +61,7 @@ const subscribeToClientMount = () => () => {} * with the kind's own `def.defaults()`. Painting has its own rail panel. */ export function BuildTab() { + const { translateDisplayText } = useI18n() const [mepOpen, setMepOpen] = useState(false) const activeTool = useEditor((s) => s.tool) const selectedId = useViewer((s) => s.selection.selectedIds[0]) @@ -178,13 +180,13 @@ export function BuildTab() { data-build-tool={type.id} iconSrc={type.iconSrc} key={type.id} - label={type.label} + label={translateDisplayText(type.label)} onClick={() => { triggerSFX('sfx:menu-click') handleTypeClick(type) }} onMouseEnter={() => triggerSFX('sfx:menu-hover')} - title={type.label} + title={translateDisplayText(type.label)} /> ) const typesIn = (section: NonNullable) => diff --git a/apps/editor/components/save-button.tsx b/apps/editor/components/save-button.tsx index bd227b83a4..cb4f341b7e 100644 --- a/apps/editor/components/save-button.tsx +++ b/apps/editor/components/save-button.tsx @@ -19,7 +19,13 @@ 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 = 'Create new scene', + locale = 'en', +}: { + label?: string + locale?: 'en' | 'zh-CN' +} = {}) { const router = useRouter() const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -55,7 +61,7 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri onClick={handleCreate} type="button" > - {isCreating ? 'Creating…' : label} + {isCreating ? (locale === 'zh-CN' ? '正在创建…' : 'Creating…') : label} ) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 7822dcc800..a315c2770d 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -34,6 +34,7 @@ import { EyeOff, Footprints, Grid2X2, + Languages, Layers3, Magnet, PenLine, @@ -48,6 +49,7 @@ import { import Image from 'next/image' import { type ReactNode, useCallback } from 'react' import { flushSync } from 'react-dom' +import { type MessageKey, useI18n } from '@/lib/i18n' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip' @@ -84,7 +86,12 @@ function ToolbarTooltip({ children, label }: { children: ReactNode; label: strin ) } -const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ +const VIEW_MODES: { + id: ViewMode + label: string + labelKey?: MessageKey + icon: React.ReactNode +}[] = [ { id: '3d', label: '3D', @@ -114,24 +121,25 @@ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ { id: 'split', label: 'Split', + labelKey: 'view.split', icon: , }, ] const levelModeOrder = ['stacked', 'exploded', 'solo'] as const -const levelModeLabels: Record = { - manual: 'Stack', - stacked: 'Stack', - exploded: 'Exploded', - solo: 'Solo', +const levelModeLabelKeys: Record = { + manual: 'view.stack', + stacked: 'view.stack', + exploded: 'view.exploded', + solo: 'view.solo', } const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const -const wallModeConfig: Record = { - up: { icon: '/icons/room.webp', label: 'Full height' }, - cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' }, - down: { icon: '/icons/walllow.webp', label: 'Low' }, - translucent: { icon: '/icons/wall.webp', label: 'Translucent' }, +const wallModeConfig: Record = { + up: { icon: '/icons/room.webp', labelKey: 'view.fullHeight' }, + cutaway: { icon: '/icons/wallcut.webp', labelKey: 'view.cutaway' }, + down: { icon: '/icons/walllow.webp', labelKey: 'view.low' }, + translucent: { icon: '/icons/wall.webp', labelKey: 'view.translucent' }, } const SHADING_OPTIONS = [ @@ -169,6 +177,7 @@ const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [ ] as const function ViewModeControl() { + const { t } = useI18n() const viewMode = useEditor((state) => state.viewMode) const setViewMode = useEditor((state) => state.setViewMode) @@ -176,10 +185,11 @@ function ViewModeControl() {
{VIEW_MODES.map((mode) => { const isActive = viewMode === mode.id + const label = mode.labelKey ? t(mode.labelKey) : mode.label return ( - + ) @@ -201,6 +211,7 @@ function ViewModeControl() { } function CollapseSidebarButton() { + const { t } = useI18n() const isCollapsed = useSidebarStore((state) => state.isCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) @@ -210,9 +221,9 @@ function CollapseSidebarButton() { return (
- + ) } function WallModeToggle() { + const { t } = useI18n() const wallMode = useViewer((state) => state.wallMode) const setWallMode = useViewer((state) => state.setWallMode) const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway! @@ -282,7 +296,7 @@ function WallModeToggle() { } return ( - + ) @@ -313,6 +327,7 @@ const EDGE_OPTIONS = [ const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl' function DisplayMenu() { + const { t, translateDisplayText: td } = useI18n() const viewMode = useEditor((state) => state.viewMode) const showGrid = useViewer((state) => state.showGrid) const setShowGrid = useViewer((state) => state.setShowGrid) @@ -349,6 +364,9 @@ function DisplayMenu() { SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] const activeEdges = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0] const activeTheme = getSceneTheme(sceneTheme) + const activeWallDimension = FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( + (option) => option.id === wallDimensionReference, + ) // Keep the menu open when flipping a toggle. const keepOpen = (event: Event, fn: () => void) => { @@ -358,15 +376,15 @@ function DisplayMenu() { return ( - + @@ -378,7 +396,7 @@ function DisplayMenu() { > keepOpen(e, () => setShowGrid(!showGrid))}> - Grid + {td('Grid')} {showGrid ? ( ) : ( @@ -390,7 +408,7 @@ function DisplayMenu() { onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))} > - {viewMode === 'split' ? '3D measurements' : 'Measurements'} + {td(viewMode === 'split' ? '3D measurements' : 'Measurements')} {showMeasurements ? ( ) : ( @@ -403,17 +421,17 @@ function DisplayMenu() { - Floor plan mode + {td('Floor plan mode')} - {floorplanMode === 'default' ? 'Default' : 'Expert'} + {td(floorplanMode === 'default' ? 'Default' : 'Expert')} {FLOORPLAN_MODE_OPTIONS.map((option) => ( setFloorplanMode(option.id)}>
- {option.name} - {option.detail} + {td(option.name)} + {td(option.detail)}
{floorplanMode === option.id ? ( @@ -427,7 +445,7 @@ function DisplayMenu() { - Floor plan annotations + {td('Floor plan annotations')} {FLOORPLAN_ANNOTATION_OPTIONS.map((option) => { @@ -441,7 +459,7 @@ function DisplayMenu() { } > - {option.name} + {td(option.name)} {visible ? ( ) : ( @@ -455,13 +473,9 @@ function DisplayMenu() { - Wall dimensions + {td('Wall dimensions')} - { - FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( - (option) => option.id === wallDimensionReference, - )?.name - } + {activeWallDimension ? td(activeWallDimension.name) : null} @@ -473,8 +487,8 @@ function DisplayMenu() { } >
- {option.name} - {option.detail} + {td(option.name)} + {td(option.detail)}
{wallDimensionReference === option.id ? ( @@ -489,15 +503,17 @@ function DisplayMenu() { ) : null} keepOpen(e, () => setMagneticSnap(!magneticSnap))}> - Magnetic snap + {td('Magnetic snap')} - {magneticSnap ? 'On' : 'Off'} + {td(magneticSnap ? 'On' : 'Off')} keepOpen(e, () => setShadows(!shadows))}> - Shadows - {shadows ? 'On' : 'Off'} + {td('Shadows')} + + {td(shadows ? 'On' : 'Off')} + @@ -511,9 +527,9 @@ function DisplayMenu() { icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'} width={16} /> - Camera + {td('Camera')} - {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + {td(cameraMode === 'perspective' ? 'Perspective' : 'Orthographic')} @@ -521,13 +537,13 @@ function DisplayMenu() { {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'} - Units + {td('Units')} {unit === 'imperial' - ? 'Feet & inches' + ? td('Feet & inches') : metricNotation === 'millimeters' - ? 'Millimeters' - : 'Meters'} + ? td('Millimeters') + : td('Meters')} @@ -535,7 +551,7 @@ function DisplayMenu() { m - Meters + {td('Meters')} {unit === 'metric' && metricNotation === 'meters' ? ( ) : null} @@ -544,7 +560,7 @@ function DisplayMenu() { mm - Millimeters + {td('Millimeters')} {unit === 'metric' && metricNotation === 'millimeters' ? ( ) : null} @@ -553,7 +569,7 @@ function DisplayMenu() { ft - Feet & inches + {td('Feet & inches')} {unit === 'imperial' ? : null}
@@ -564,8 +580,8 @@ function DisplayMenu() { - Render - {activeShading.name} + {td('Render')} + {td(activeShading.name)} {SHADING_OPTIONS.map((option) => { @@ -574,8 +590,8 @@ function DisplayMenu() { setShading(option.id)}>
- {option.name} - {option.detail} + {td(option.name)} + {td(option.detail)}
{shading === option.id ? ( @@ -589,15 +605,15 @@ function DisplayMenu() { - Edges - {activeEdges.name} + {td('Edges')} + {td(activeEdges.name)} {EDGE_OPTIONS.map((option) => ( setEdges(option.id)}>
- {option.name} - {option.detail} + {td(option.name)} + {td(option.detail)}
{edges === option.id ? : null}
@@ -608,9 +624,9 @@ function DisplayMenu() { - Theme + {td('Theme')} - {activeTheme.name} + {td(activeTheme.name)} @@ -628,7 +644,7 @@ function DisplayMenu() { ))} - {theme.name} + {td(theme.name)} {sceneTheme === theme.id ? ( ) : null} @@ -643,6 +659,7 @@ function DisplayMenu() { } function WalkthroughButton() { + const { t } = useI18n() const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode) const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode) const handleClick = useCallback(() => { @@ -656,7 +673,7 @@ function WalkthroughButton() { }, [isFirstPersonMode, setFirstPersonMode]) return ( - + + + ) +} + +function LocaleButton() { + const { locale, setLocale, t } = useI18n() + const nextLocale = locale === 'zh-CN' ? 'en' : 'zh-CN' + const label = locale === 'zh-CN' ? t('locale.switchToEnglish') : t('locale.switchToChinese') + + return ( + + ) @@ -745,6 +782,7 @@ export function CommunityViewerToolbarRight({
+ {onVRToggle || vrButton ? (vrButton ?? diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts index 812a60b5ec..42cfaeb426 100644 --- a/apps/editor/lib/build-tab-state.ts +++ b/apps/editor/lib/build-tab-state.ts @@ -1,4 +1,5 @@ import type { RoofType } from '@pascal-app/core' +import type { MessageKey } from '@/lib/i18n' export type RoofFeatureIdentity = { id: string @@ -30,15 +31,19 @@ export function getRoofFootprintSource(roofType: RoofType, value: unknown): Roof : sources[0].value } -export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ label: string; value: RoofType }> = [ - { label: 'Hip', value: 'hip' }, - { label: 'Gable', value: 'gable' }, - { label: 'Shed', value: 'shed' }, - { label: 'Flat', value: 'flat' }, - { label: 'Gambrel', value: 'gambrel' }, - { label: 'Dutch', value: 'dutch' }, - { label: 'Mansard', value: 'mansard' }, - { label: 'Conical', value: 'conical' }, +export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ + label: string + labelKey: MessageKey + value: RoofType +}> = [ + { label: 'Hip', labelKey: 'roof.hip', value: 'hip' }, + { label: 'Gable', labelKey: 'roof.gable', value: 'gable' }, + { label: 'Shed', labelKey: 'roof.shed', value: 'shed' }, + { label: 'Flat', labelKey: 'roof.flat', value: 'flat' }, + { label: 'Gambrel', labelKey: 'roof.gambrel', value: 'gambrel' }, + { label: 'Dutch', labelKey: 'roof.dutch', value: 'dutch' }, + { label: 'Mansard', labelKey: 'roof.mansard', value: 'mansard' }, + { label: 'Conical', labelKey: 'roof.conical', value: 'conical' }, ] export function getActiveRoofFeatureId( diff --git a/apps/editor/lib/i18n.test.ts b/apps/editor/lib/i18n.test.ts new file mode 100644 index 0000000000..438bd0c98f --- /dev/null +++ b/apps/editor/lib/i18n.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { messages, translate, translateDisplayText } from './i18n' + +describe('editor translations', () => { + test('keeps locale catalogs aligned', () => { + expect(Object.keys(messages['zh-CN']).sort()).toEqual(Object.keys(messages.en).sort()) + }) + + test('translates display text without translating domain values', () => { + expect(translate('zh-CN', 'build.wall')).toBe('墙体') + expect(translateDisplayText('zh-CN', 'Nature')).toBe('自然') + expect(translateDisplayText('zh-CN', 'Floor 3')).toBe('3 层') + expect(translateDisplayText('zh-CN', 'Basement 2')).toBe('地下 2 层') + expect( + translateDisplayText( + 'zh-CN', + 'Drainage reserves 0.80 m gutters; hydrants reserve a 1.20 m roadside verge.', + ), + ).toBe('排水设施预留 0.80 m 路缘排水带;消防栓预留 1.20 m 路侧绿化带。') + expect(translateDisplayText('zh-CN', 'Merge node node-2 into node-1')).toBe( + '将节点 node-2 合并到 node-1', + ) + expect(translateDisplayText('zh-CN', 'Daisy')).toBe('雏菊') + expect(translateDisplayText('zh-CN', 'Fescue')).toBe('羊茅') + expect(translateDisplayText('zh-CN', 'unknown-node-kind')).toBe('unknown-node-kind') + expect(translateDisplayText('en', 'Nature')).toBe('Nature') + expect(JSON.stringify(messages['zh-CN'])).not.toContain('"Repeat"') + }) +}) diff --git a/apps/editor/lib/i18n.tsx b/apps/editor/lib/i18n.tsx new file mode 100644 index 0000000000..6345aee244 --- /dev/null +++ b/apps/editor/lib/i18n.tsx @@ -0,0 +1,1359 @@ +'use client' + +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react' + +export const messages = { + en: { + 'app.blankCanvas': 'Blank canvas — saved scenes are under Scenes (not this page).', + 'app.openScenes': 'Open saved scenes', + 'sidebar.scene': 'Scene', + 'sidebar.build': 'Build', + 'sidebar.items': 'Items', + 'sidebar.settings': 'Settings', + 'build.wall': 'Wall', + 'build.road': 'Road', + 'build.block': 'Block', + 'build.fence': 'Fence', + 'build.slab': 'Slab', + 'build.ceiling': 'Ceiling', + 'build.roof': 'Roof', + 'build.stairs': 'Stairs', + 'build.elevator': 'Elevator', + 'build.door': 'Door', + 'build.window': 'Window', + 'build.column': 'Column', + 'build.shelf': 'Shelf', + 'build.spawnPoint': 'Spawn Point', + 'build.kitchen': 'Kitchen', + 'build.mep': 'MEP', + 'build.painting': 'Painting', + 'build.terrain': 'Terrain', + 'build.duct': 'Duct', + 'build.register': 'Register', + 'build.hvacUnit': 'HVAC Unit', + 'build.lineset': 'Lineset', + 'build.liquidLine': 'Liquid Line', + 'build.dwvPipe': 'DWV Pipe', + 'build.modularCabinet': 'Modular Cabinet', + 'build.roofType': 'Roof type', + 'build.roofFeatures': 'Features & extensions', + 'build.followLineset': 'Follow lineset', + 'build.followLinesetOn': 'Click a lineset to lay the line beside it.', + 'build.followLinesetOff': 'Trace a line alongside an existing lineset (F).', + 'roof.hip': 'Hip', + 'roof.gable': 'Gable', + 'roof.shed': 'Shed', + 'roof.flat': 'Flat', + 'roof.gambrel': 'Gambrel', + 'roof.dutch': 'Dutch', + 'roof.mansard': 'Mansard', + 'roof.conical': 'Conical', + 'roof.conicalHint': 'Select a curved wall to match its radius and arc.', + 'common.on': 'On', + 'common.off': 'Off', + 'view.split': 'Split', + 'view.stack': 'Stack', + 'view.exploded': 'Exploded', + 'view.solo': 'Solo', + 'view.manual': 'Manual', + 'view.fullHeight': 'Full height', + 'view.cutaway': 'Cutaway', + 'view.low': 'Low', + 'view.translucent': 'Translucent', + 'view.expandSidebar': 'Expand sidebar', + 'view.collapseSidebar': 'Collapse sidebar', + 'view.levels': 'Levels', + 'view.walls': 'Walls', + 'view.display': 'Display', + 'view.displaySettings': 'Display settings', + 'view.walkthrough': 'Walkthrough', + 'view.preview': 'Preview', + 'locale.switchToChinese': '切换为中文', + 'locale.switchToEnglish': 'Switch to English', + }, + 'zh-CN': { + 'app.blankCanvas': '当前为空白画布,已保存的场景请前往“场景”查看。', + 'app.openScenes': '打开已保存场景', + 'sidebar.scene': '场景', + 'sidebar.build': '建模', + 'sidebar.items': '物件', + 'sidebar.settings': '设置', + 'build.wall': '墙体', + 'build.road': '道路', + 'build.block': '体块', + 'build.fence': '围栏', + 'build.slab': '楼板', + 'build.ceiling': '吊顶', + 'build.roof': '屋顶', + 'build.stairs': '楼梯', + 'build.elevator': '电梯', + 'build.door': '门', + 'build.window': '窗', + 'build.column': '柱', + 'build.shelf': '搁架', + 'build.spawnPoint': '出生点', + 'build.kitchen': '厨房', + 'build.mep': '机电', + 'build.painting': '材质涂装', + 'build.terrain': '地形', + 'build.duct': '风管', + 'build.register': '风口', + 'build.hvacUnit': '暖通设备', + 'build.lineset': '冷媒管组', + 'build.liquidLine': '液管', + 'build.dwvPipe': '排水通气管', + 'build.modularCabinet': '组合橱柜', + 'build.roofType': '屋顶类型', + 'build.roofFeatures': '构件与扩展', + 'build.followLineset': '跟随冷媒管组', + 'build.followLinesetOn': '点击冷媒管组,在其旁侧铺设液管。', + 'build.followLinesetOff': '沿现有冷媒管组绘制液管(F)。', + 'roof.hip': '四坡顶', + 'roof.gable': '双坡顶', + 'roof.shed': '单坡顶', + 'roof.flat': '平屋顶', + 'roof.gambrel': '折线双坡顶', + 'roof.dutch': '荷兰式屋顶', + 'roof.mansard': '孟莎式屋顶', + 'roof.conical': '锥形屋顶', + 'roof.conicalHint': '请选择曲墙,以匹配其半径和圆弧。', + 'common.on': '开', + 'common.off': '关', + 'view.split': '分屏', + 'view.stack': '叠层', + 'view.exploded': '分解', + 'view.solo': '单层', + 'view.manual': '手动', + 'view.fullHeight': '全高墙', + 'view.cutaway': '剖切墙', + 'view.low': '矮墙', + 'view.translucent': '半透明墙', + 'view.expandSidebar': '展开侧栏', + 'view.collapseSidebar': '收起侧栏', + 'view.levels': '楼层', + 'view.walls': '墙体', + 'view.display': '显示', + 'view.displaySettings': '显示设置', + 'view.walkthrough': '漫游', + 'view.preview': '预览', + 'locale.switchToChinese': '切换为中文', + 'locale.switchToEnglish': 'Switch to English', + }, +} as const + +export type Locale = keyof typeof messages +export type MessageKey = keyof (typeof messages)['en'] + +const displayMessages: Record = { + Nature: '自然', + Environment: '环境', + Pools: '泳池', + Streetscape: '街景', + Plugins: '插件', + Floors: '楼层', + 'Add floor': '添加楼层', + 'Add basement': '添加地下层', + 'No floors yet': '暂无楼层', + Remove: '移除', + 'Remove floor': '移除楼层', + 'The ground floor cannot be removed': '首层不能移除', + 'Select a floor to make it active. The ground floor is always kept as the building datum.': + '选择一个楼层作为当前层。首层始终作为建筑标高基准。', + Viewer: '视图', + 'Rendering options for this editor session.': '当前编辑会话的渲染选项。', + 'Cast shadows from lights.': '显示灯光投射的阴影。', + Export: '导出', + Exporting: '正在导出', + 'Download the scene or hand it to another app.': '下载场景,或发送到其他应用。', + '3D model': '3D 模型', + 'Export options': '导出选项', + 'Visible nodes only': '仅导出可见节点', + 'Exclude hidden furniture and other hidden scene nodes': '排除隐藏的家具及其他场景节点', + 'Export visible nodes only': '仅导出可见节点', + 'Include in file': '包含在文件中', + 'Viewer surroundings': '视图环境', + 'Viewer surroundings are excluded unless selected separately below.': + '视图环境默认不导出;如需导出,请在下方单独选择。', + 'Optional static surroundings are included only in GLB and USDZ.': + '可选静态环境仅支持导出到 GLB 和 USDZ。', + 'Send to Blender': '发送到 Blender', + 'Needs the': '请确保', + 'Pascal add-on for Blender': 'Pascal Blender 插件', + Basin: '盆地', + 'Water level': '水位', + 'Spill level': '溢流标高', + 'Max depth': '最大深度', + Empty: '空置', + 'Water quality': '水质', + Pure: '纯净', + Clear: '清澈', + Deep: '深水', + Swampy: '沼泽', + 'Bright, pale blue water.': '明亮的浅蓝色水体。', + 'Natural transparent blue-green water.': '自然透明的蓝绿色水体。', + 'Darker blue water with stronger depth.': '颜色更深、层次更明显的蓝色水体。', + 'Muted green-brown pond water.': '低饱和的绿褐色池水。', + Shoreline: '岸线', + 'Soft bank': '柔和岸坡', + 'Rocky bank': '岩石岸坡', + 'Water props': '水体组件', + 'Clear props': '清除组件', + 'Sculpt Terrain': '雕刻地形', + 'Canvas action': '画布操作', + Select: '选择', + Lilies: '睡莲', + Koi: '锦鲤', + 'Contours are {step} m apart. Water cannot rise above the spill level.': + '等高线间距为 {step} m,水位不能高于溢流标高。', + 'This preset will be used when the basin first receives water.': '盆地首次加水时将使用此预设。', + 'Add water to choose a shoreline treatment.': '加水后才能选择岸线处理方式。', + 'Natural stones follow the waterline and the sculpted terrain.': + '天然石材会沿水线和雕刻后的地形铺设。', + 'A gentle shallow-water transition blends into the bank.': '柔和的浅水过渡会自然融入岸坡。', + 'Up to 128 props per pond. Dry props are retained and reappear when water returns.': + '每个池塘最多可放置 128 个组件;干涸时组件会保留,水位恢复后重新出现。', + 'Need a deeper basin?': '需要更深的盆地?', + 'Use Pascal Terrain to lower the centre and keep a higher rim around it.': + '使用 Pascal 地形降低中心,并在周围保留更高的边缘。', + 'Select a depression, then raise one contour interval or fill it to the spill level.': + '选择一个凹地,然后抬高一个等高线间隔,或将其填充至溢流标高。', + 'Select a contained terrain depression first.': '请先选择一个封闭的地形凹地。', + 'The selected pond is empty.': '当前池塘为空。', + 'Add water to this basin first.': '请先向此盆地加水。', + 'Click inside an enclosed low point. A click on its slope selects the connected depression below.': + '点击封闭低点内部;点击坡面会选择下方相连的凹地。', + 'Water needs editable terrain with a contained depression': '水体需要可编辑地形中的封闭凹地。', + 'Fill to spill': '填充至溢流标高', + Path: '路径', + Channel: '河道', + Flow: '流向', + Appearance: '外观', + Width: '宽度', + Depth: '深度', + Speed: '速度', + Direction: '方向', + 'First endpoint': '起点端', + 'Last endpoint': '终点端', + Rounded: '圆头', + 'Mountain source': '山地源头', + 'Sea outlet': '入海口', + Forward: '正向', + Reverse: '反向', + 'Finish river': '完成河流', + 'Remove last point': '移除上一个点', + 'Done editing path': '完成路径编辑', + 'Edit path': '编辑路径', + 'Draw new river': '绘制新河流', + 'Delete river': '删除河流', + 'Place at least two river points.': '请至少放置两个河流点。', + 'Place a river point before removing one.': '请先放置河流点,再移除点。', + 'Add or select an editable Site first.': '请先添加或选择可编辑的场地。', + 'Points set the river centerline; width and depth turn that path into a carved channel.': + '这些点定义河流中心线;宽度和深度会将路径雕刻为河道。', + 'Direction follows the first point toward the last; reverse flips the visible current.': + '流向默认从起点指向终点;反向会翻转可见水流。', + 'Mountain and sea connections continue procedurally beyond the Site; the authored river and its exports stay inside the Site.': + '山地源头和入海连接会在场地之外继续生成;绘制的河流及其导出内容仍保留在场地内。', + 'Sea outlet is unavailable because the current Surroundings seed has no coast.': + '当前环境种子没有海岸线,因此无法使用入海口。', + 'Atmosphere settings': '大气设置', + 'Adjust the rain and snow amounts separately in Fine-tune.': '可在“微调”中分别调整雨量和雪量。', + 'Sun control': '太阳控制', + 'Manual angles': '手动角度', + 'Sky output': '天空输出', + 'Composite HDR': '合成 HDR', + 'World direction': '世界方向', + 'Rayleigh only': '仅 Rayleigh', + 'Mie only': '仅 Mie', + 'Horizon haze': '地平线雾霾', + 'Sun mask': '太阳遮罩', + 'Cloud density': '云密度', + 'Log luminance': '对数亮度', + 'Time shortcuts': '时间快捷操作', + 'Switch to daytime counterpart': '切换到对应的白天时刻', + 'Daytime counterpart · 12 hours apart': '对应的白天时刻,相隔 12 小时', + 'Switch to nighttime counterpart': '切换到对应的夜间时刻', + 'Nighttime counterpart · 12 hours apart': '对应的夜间时刻,相隔 12 小时', + 'Continuous 24-hour time disk': '连续 24 小时时间盘', + 'Drag to change time.': '拖动以调整时间。', + 'All 24 hours are continuous. Arrow keys adjust five minutes; Shift adjusts thirty. Escape cancels a drag.': + '24 小时连续循环。方向键每次调整 5 分钟;按住 Shift 调整 30 分钟;Esc 取消拖动。', + 'Manual sun angles are available in Advanced.': '可在“高级”中手动设置太阳角度。', + 'Project appearance is saved in this browser, not in exported geometry. It stays active when you return to other tools.': + '项目外观保存在当前浏览器中,不会写入导出的几何模型;切换到其他工具后仍保持生效。', + 'Artistic 24-hour orbit; playback takes two minutes. It is not a geolocated solar study.': + '艺术化 24 小时昼夜轨道,完整播放约需两分钟;这不是基于地理位置的日照分析。', + 'Enable the environment sky to use day playback.': '启用环境天空后才能播放昼夜变化。', + 'Exact amounts and mixed weather.': '精确设置各项强度及混合天气。', + 'Cloud cover is in Advanced → Clouds.': '云量设置位于“高级 → 云层”。', + 'Rain thickens and darkens the base clouds; snow cools the sky. Rain wets surfaces; snow covers ground and roof-facing surfaces. Indoor shelter is not detected.': + '雨会加厚并压暗基础云层,雪会使天空色调变冷。雨会润湿表面;雪会覆盖地面和朝上的屋面。系统暂不识别室内遮蔽。', + 'Aim the sun by time or manual angles.': '按时间或手动角度确定太阳方向。', + '0° north · 90° east · 180° south · 270° west.': '0° 北 · 90° 东 · 180° 南 · 270° 西。', + 'Shape haze, fog depth, and scene exposure.': '调整雾霾、雾效深度和场景曝光。', + 'Adjust cloud coverage, motion, and appearance.': '调整云量、运动和外观。', + 'Choose rendering and tune celestial light.': '选择渲染方式并调整天体光照。', + 'Atmosphere · Rayleigh + Mie': '大气 · Rayleigh + Mie', + 'Gradient · low cost': '渐变 · 低性能开销', + 'Specialist tuning; defaults usually suffice.': '专业参数,通常保留默认值即可。', + 'Artistic phase: 0% new, 100% full.': '艺术化月相:0% 为新月,100% 为满月。', + 'Inspect individual sky rendering passes.': '检查各个天空渲染通道。', + 'Background only. Reflections, fog, and lighting keep the full sky.': + '仅影响背景;反射、雾效和光照仍使用完整天空。', + 'Landscape preset': '景观预设', + Regional: '区域环境', + 'Open Meadow': '开阔草甸', + 'Woodland Edge': '林地边缘', + 'Roads, homes and landscape generated from the current regional setting.': + '根据当前区域设置生成道路、住宅和景观。', + 'A broad flowered meadow with occasional copses and a long open horizon.': + '开阔的花卉草甸,点缀零散林丛,拥有延伸的开阔地平线。', + 'A dense, layered woodland with shaded understory and irregular clearings.': + '层次丰富的密林,林下有阴影,并分布不规则空地。', + 'Show surroundings': '显示周边环境', + 'Surroundings are hidden. Turn them on to preview your changes.': + '周边环境已隐藏。打开后可预览更改。', + 'Road layout': '道路布局', + 'Use Regional roads': '使用区域道路', + 'Variation & birds': '变化与鸟类', + 'Landscape seed': '景观种子', + 'Show distant birds': '显示远处鸟类', + 'Animate distant birds': '播放远处鸟类动画', + 'Surroundings are saved per project in this browser and excluded from geometry exports.': + '周边环境按项目保存在此浏览器中,并排除在几何导出之外。', + 'Camera aligned': '相机对齐', + 'Select an edge below': '请在下方选择一条边', + 'Edge {index}': '边 {index}', + 'Edge {index} road type': '边 {index} 道路类型', + 'Property edges': '地块边界', + 'Edge {index}: {type}': '边 {index}:{type}', + 'Edge {index} · {type}': '边 {index} · {type}', + 'Water type': '水体类型', + Pond: '池塘', + River: '河流', + 'Fill terrain depressions in contour steps, then dress the water.': + '按等高线间隔填充地形凹地,再布置水体。', + 'Draw a watercourse and carve its channel into the terrain.': '绘制水道,并在地形中雕刻河道。', + 'Pond tool active': '池塘工具已启用', + 'River tool active': '河流工具已启用', + 'Resume selected pond': '继续编辑所选池塘', + 'Resume pond tool': '继续使用池塘工具', + 'Resume selected river': '继续编辑所选河流', + 'Resume river tool': '继续使用河流工具', + 'Add a Site before creating water.': '请先添加场地,再创建水体。', + 'Raise {distance} m': '抬高 {distance} m', + 'Lower {distance} m': '降低 {distance} m', + '{lilies} lilies · {koi}/32 koi': '{lilies} 株睡莲 · {koi}/32 条锦鲤', + 'No road': '无道路', + 'Secondary road': '次要道路', + 'Primary road': '主要道路', + Secondary: '次要', + Primary: '主要', + '{preset} has no roads or homes. Switch to Regional to edit property-edge roads. Your road settings are kept.': + '{preset} 没有道路或住宅。切换到区域环境后可编辑地块边缘道路;当前道路设置会保留。', + 'Editing points': '正在编辑点', + '2-point minimum': '至少 2 个点', + 'Choose Draw new river, then click along the terrain. Each point bends the channel through it.': + '选择“绘制新河流”,然后沿地形依次点击;每个点都会引导河道转向。', + 'Click terrain to place the first point. This sets where the river begins.': + '点击地形放置第一个点,确定河流起点。', + 'First point set. Click farther along the terrain to create the first channel segment.': + '已设置第一个点。继续点击更远处的地形,创建第一段河道。', + 'Two points set—the minimum. Finish a simple channel now, or keep clicking to shape bends.': + '已设置最低要求的两个点。现在可完成简单河道,也可继续点击塑造弯道。', + 'Keep clicking to extend the channel, or finish when its course has the shape you want.': + '继续点击以延伸河道;达到所需走向后即可完成。', + 'Drag any visible control point to reshape the selected river. Release to apply each change.': + '拖动任一可见控制点以重塑所选河流;松开后应用更改。', + 'River drawing needs an editable Site. Add or select a Site before starting a path.': + '绘制河流需要可编辑场地。开始绘制前请添加或选择场地。', + 'Choose Edit path, then drag its visible control points to reshape the channel.': + '选择“编辑路径”,然后拖动可见控制点重塑河道。', + 'Waiting for the first point.': '等待第一个点。', + 'Path editing is active.': '路径编辑已启用。', + 'River drawing is unavailable because no Site is active.': '当前没有活动场地,无法绘制河流。', + 'River selected and ready to edit.': '已选择河流,可以编辑。', + 'Ready to draw a river.': '可以绘制河流。', + 'running in an open Blender.': '已在打开的 Blender 中运行。', + 'Export 3D print 3MF': '导出 3D 打印 3MF', + 'Export 3D print STL': '导出 3D 打印 STL', + 'Default mode': '默认模式', + 'Expert mode': '专业模式', + 'Full floor plan': '完整平面图', + 'Structure only': '仅结构', + 'Save and load': '保存与加载', + 'Keep a JSON copy of the scene, or restore one.': '保存场景的 JSON 副本,或从副本恢复。', + 'Save build': '保存模型', + 'Load build': '加载模型', + Preferences: '偏好设置', + 'Sound levels and keyboard shortcuts.': '声音级别和键盘快捷键。', + 'Audio settings': '音频设置', + 'Adjust volume levels and mute settings': '调整音量级别和静音设置', + 'Master Volume': '主音量', + 'Radio Volume': '广播音量', + 'Sound Effects': '音效', + 'Mute All Sounds': '全部静音', + 'Unmute All Sounds': '取消全部静音', + 'Keyboard shortcuts': '键盘快捷键', + 'Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift during an active gesture to build freely.': + '快捷键会随当前上下文生效。默认启用引导约束;操作时按住 Shift 可自由建模。', + 'Editor Navigation': '编辑器导航', + 'Modes & History': '模式与历史', + Selection: '选择', + 'Direct Manipulation': '直接操作', + 'Drawing Tools': '绘图工具', + 'Item Placement': '物件放置', + 'Switch to Site phase': '切换到场地阶段', + 'Switch to Structure phase': '切换到结构阶段', + 'Switch to Furnish phase': '切换到陈设阶段', + 'Switch to Furnish layer': '切换到陈设图层', + 'Switch to Zones layer': '切换到分区图层', + 'Select next level in the active building': '选择当前建筑的上一层', + 'Select previous level in the active building': '选择当前建筑的下一层', + 'Toggle sidebar': '展开或收起侧栏', + 'Switch to Select mode': '切换到选择模式', + 'Switch to Build mode': '切换到建模模式', + 'Activate the last measurement tool': '启用上次使用的测量工具', + 'Switch to Delete mode': '切换到删除模式', + 'Cancel the active tool and return to Select mode': '取消当前工具并返回选择模式', + 'Mid-draw it cancels only the chain in progress and keeps the tool armed; press it again to leave the tool.': + '绘制过程中第一次按下仅取消当前连续段并保留工具;再次按下退出工具。', + 'Delete selected objects': '删除选中对象', + Undo: '撤销', + Redo: '重做', + Save: '保存', + 'Copy the selected objects': '复制选中对象', + 'The copied selection can be pasted into another level, project, or browser tab.': + '复制的对象可粘贴到其他楼层、项目或浏览器标签页。', + 'Cut the selected objects': '剪切选中对象', + 'Copies the selection to the clipboard, then removes it from this scene.': + '将选中对象复制到剪贴板,然后从当前场景移除。', + 'Paste and place copied objects': '粘贴并放置复制的对象', + 'Carries a preview under the cursor. Click to place it, or press Escape to cancel.': + '光标下会显示预览;点击放置,或按 Escape 取消。', + 'Add or remove an object from multi-selection': '在多选中加入或移除对象', + 'Works in Select mode on the 3D canvas, the 2D floor plan, and the scene graph.': + '适用于选择模式下的 3D 画布、2D 平面图和场景图。', + 'Add or remove an object from canvas multi-selection': '在画布多选中加入或移除对象', + 'In the scene graph, Shift-click selects the visible range like a file browser.': + '在场景图中,Shift+单击会像文件浏览器一样选择可见范围。', + 'Move the whole multi-selection': '整体移动多选对象', + 'With 2+ objects selected, in 2D and 3D alike: drag the selection (or its dashed box) to slide it; click it to pick it up and place with the next click.': + '选中至少两个对象时,可在 2D 或 3D 中拖动选区(或虚线框);也可单击拾取,再次单击放置。', + 'Rotate a multi-selection ±45° around its center': '将多选对象绕中心旋转 ±45°', + 'Also works mid-move while carrying the selection.': '移动选区时同样可用。', + 'Group the multi-selection (session only)': '临时编组多选对象', + 'Editor-only. Plain click a member later to reselect the whole group. Not saved with the project.': + '仅当前编辑会话有效;之后单击任一成员可重选整组,不会随项目保存。', + 'Ungroup the session selection': '取消临时编组', + 'Keeps the current selection; only dissolves the session group.': + '保留当前选择,仅解除临时编组。', + 'Clicking empty space does the same.': '单击空白处也可清除选择。', + 'Move the selected movable object under the cursor': '移动光标下的可移动选中对象', + 'Drag in Select mode with a single object selected. Guided snapping and guides are enabled by default.': + '在选择模式中选中单个对象后拖动;默认启用引导吸附和参考线。', + 'Rotate the selected object under the cursor': '旋转光标下的选中对象', + 'Drag left or right in Select mode with a single object selected. Rotation snaps to 15° increments by default.': + '在选择模式中选中单个对象后向左或向右拖动;默认按 15° 增量吸附。', + 'Rotate freely': '自由旋转', + 'Hold Shift during the drag to bypass the 15° rotation increment.': + '拖动时按住 Shift 可绕过 15° 旋转增量。', + 'Cycle the snapping mode': '循环切换吸附模式', + 'Tap and release without pressing anything else, while a drawing or move gesture is available.': + '可绘制或移动时,单独点按并松开该键。', + 'Cycle the grid step: 0.5 m → 0.25 m → 0.1 m → 0.05 m': + '循环切换网格步长:0.5 m → 0.25 m → 0.1 m → 0.05 m', + 'Tap and release on its own. Use it when the default half-metre grid is too coarse — placing a window, for instance.': + '单独点按并松开;默认半米网格过粗时使用,例如放置窗户。', + 'Bypass guided snapping and angle constraints': '临时绕过引导吸附和角度约束', + 'Hold during the active gesture. Passive guide or measurement feedback may stay visible.': + '操作期间按住;被动参考线或测量反馈可能仍会显示。', + 'Rotate freely, bypassing the default 15° rotation snap': '自由旋转,绕过默认 15° 吸附', + 'Hold while dragging a rotate handle or direct-rotation gesture.': + '拖动旋转手柄或直接旋转时按住。', + 'Rotate item; with a door selected, R toggles open/closed and T closes': + '旋转物件;选中门时,R 切换开合,T 关闭', + 'Operate the selected node — doors, windows, and cabinet doors/drawers animate open/closed': + '操作选中节点:门、窗及柜门/抽屉以动画方式开合', + 'Temporarily bypass placement validation constraints': '临时绕过放置校验约束', + 'Hold while placing.': '放置时按住。', + 'Pan camera': '平移相机', + 'Moves in screen space, similar to dragging the camera view.': + '在屏幕平面内移动,效果类似拖动相机视图。', + 'Drag with the middle mouse button, or hold Space while dragging with the left mouse button.': + '按住鼠标中键拖动,或按住空格并用左键拖动。', + 'Orbit camera': '环绕相机', + 'Drag with the right mouse button.': '按住鼠标右键拖动。', + 'Scene graph': '场景图', + 'Inspect how nodes are nested in this scene.': '检查当前场景中的节点层级。', + 'Explore scene graph': '查看场景图', + 'Danger zone': '危险操作', + 'Removes every node from this scene. This cannot be undone.': + '删除当前场景中的全部节点。此操作无法撤销。', + 'Clear and start new': '清空并新建', + 'Loading…': '正在加载…', + Close: '关闭', + Open: '打开', + 'Expand panel': '展开面板', + 'Collapse panel': '收起面板', + 'Ready to import': '可以导入', + 'Cannot import this file': '无法导入此文件', + Sites: '场地', + Buildings: '建筑', + Doors: '门', + Windows: '窗', + Items: '物件', + Slabs: '楼板', + Ceilings: '吊顶', + 'Floor area': '建筑面积', + 'The file contains no recognised nodes.': '文件中没有可识别的节点。', + 'Replace current scene': '替换当前场景', + Furniture: '家具', + Appliance: '家电', + Kitchen: '厨房', + Bathroom: '卫浴', + Outdoor: '户外', + 'Search...': '搜索...', + Cactus: '仙人掌', + 'TV Stand': '电视柜', + 'Dining table': '餐桌', + 'Single Bed': '单人床', + 'Livingroom Chair': '客厅椅', + 'Small Plant': '小型绿植', + 'Modular Cabinet': '组合橱柜', + 'My leather couch': '我的皮沙发', + Threadmill: '跑步机', + Column: '柱体', + Barbell: '杠铃', + Toy: '玩具', + 'Barbell Stand': '杠铃架', + Books: '书籍', + Stool: '凳子', + 'Pool table': '台球桌', + Bookshelf: '书架', + 'Coat Rack': '衣帽架', + 'Car Toy': '玩具车', + 'Bedside Table': '床头柜', + Sofa: '沙发', + Bunkbed: '双层床', + 'Office Table': '办公桌', + 'Dining Table': '餐桌', + Guitar: '吉他', + 'Trash Bin': '垃圾桶', + 'Recessed Light': '嵌入式灯', + Shelf: '搁架', + 'Table Lamp': '台灯', + 'Ceiling Lamp': '吸顶灯', + Closet: '衣柜', + 'Coffee Table': '茶几', + 'Dining Chair': '餐椅', + Dresser: '斗柜', + 'Indoor Plant': '室内绿植', + 'Ironing Board': '熨衣板', + 'Double Bed': '双人床', + 'Floor Lamp': '落地灯', + Easel: '画架', + 'Lounge Chair': '休闲椅', + 'Office Chair': '办公椅', + Piano: '钢琴', + Picture: '装饰画', + 'Rectangular Carpet': '长方形地毯', + 'Rounded Mirror': '圆角镜', + 'Round Carpet': '圆形地毯', + 'Standing Desk': '升降桌', + Sprinkler: '喷淋头', + 'Exit Sign': '出口标志', + 'Sewing Machine': '缝纫机', + Television: '电视', + 'Power Outlet': '电源插座', + Thermostat: '温控器', + Toaster: '烤面包机', + Kettle: '水壶', + Hydrant: '消火栓', + 'Fire Detector': '火灾探测器', + 'Smoke Detector': '烟雾探测器', + 'Coffee Machine': '咖啡机', + 'Ceiling fan': '吊扇', + Iron: '熨斗', + 'Air Conditioning': '空调', + Computer: '电脑', + 'Electric Panel': '配电箱', + 'Bathroom Sink': '浴室洗手盆', + 'Angle Shower': '转角淋浴间', + 'Toilet Paper': '卫生纸', + 'Washing Machine': '洗衣机', + Bathtub: '浴缸', + Toilet: '马桶', + Microwave: '微波炉', + 'Cutting Board': '砧板', + 'Kitchen Utensils': '厨具', + 'Kitchen Counter': '厨房操作台', + Hood: '抽油烟机', + 'Kitchen Bar': '厨房吧台', + 'Kitchen Cabinet': '橱柜', + 'Frying Pan': '煎锅', + Fruits: '水果', + Fridge: '冰箱', + 'Kitchen Shelf': '厨房搁架', + 'Wine Bottle': '酒瓶', + Stove: '炉灶', + Ball: '球', + Bush: '灌木', + 'Basket Hoop': '篮球架', + 'Parking Spot': '停车位', + Palm: '棕榈树', + Sunbed: '躺椅', + Fir: '冷杉', + Tree: '树木', + Skate: '滑板', + 'Patio Umbrella': '庭院伞', + Pillar: '立柱', + Scooter: '踏板车', + Dishwasher: '洗碗机', + Fireplace: '壁炉', + planted: '株已种植', + Trees: '树木', + Flowers: '花卉', + Grass: '草地', + 'Pick a tree, then click the ground.': '选择树木,然后点击地面放置。', + 'Pick a flower, then click the ground.': '选择花卉,然后点击地面放置。', + 'Pick a grass, then click the ground.': '选择草地预设,然后点击地面放置。', + 'Click the ground to plant. Press Esc to stop.': '点击地面种植;按 Esc 结束。', + Oak: '橡树', + Pine: '松树', + Aspen: '白杨', + Ash: '白蜡树', + Trellis: '棚架', + Height: '高度', + Foliage: '枝叶密度', + Trunk: '树干粗细', + 'Bare (leafless)': '无叶', + 'Trees generated with': '基于', + by: '生成,作者', + 'Choose an area to shape your scene.': '选择一个环境区域进行设置。', + 'Ground Cover': '地被', + Drag: '拖动', + 'Paint grass': '绘制草地', + 'Cancel stroke or stop': '取消笔触或停止', + Coverage: '覆盖范围', + Paint: '绘制', + Erase: '擦除', + Smooth: '柔化', + 'Local height': '局部高度', + Raise: '抬高', + Lower: '降低', + Brush: '画笔', + Radius: '半径', + Strength: '强度', + 'Edge softness': '边缘柔度', + 'Paint result': '绘制结果', + 'Target coverage': '目标覆盖率', + 'Grass color': '草地颜色', + Hex: '十六进制', + 'Natural palette': '自然色板', + Meadow: '草甸', + Daisy: '雏菊', + Tulip: '郁金香', + Lavender: '薰衣草', + Fescue: '羊茅', + Reed: '芦苇', + Woodland: '林地', + Sage: '鼠尾草绿', + 'Dry straw': '枯草色', + 'Height adjustment': '高度调整', + 'Height change': '高度变化', + 'Brush details': '画笔细节', + Shape: '形状', + Round: '圆形', + Square: '方形', + 'Stroke variation': '笔触变化', + 'Whole site': '整个场地', + 'Fill site': '填满场地', + 'Clear grass': '清除草地', + 'Reset height': '重置高度', + Confirm: '确认', + value: '数值', + 'Changes applied.': '更改已应用。', + 'Drag the arc or use arrow keys.': '拖动画弧,或使用方向键。', + 'Drag on the ground to paint grass. Press Esc to stop.': '在地面拖动以绘制草地,按 Esc 停止。', + 'Drag on the ground to erase grass. Press Esc to stop.': '在地面拖动以擦除草地,按 Esc 停止。', + 'Drag on the ground to smooth density. Press Esc to stop.': '在地面拖动以柔化密度,按 Esc 停止。', + 'Drag to raise grass locally. Press Esc to stop.': '拖动以局部抬高草地,按 Esc 停止。', + 'Drag to lower grass locally. Press Esc to stop.': '拖动以局部降低草地,按 Esc 停止。', + 'Drag to smooth local grass height. Press Esc to stop.': '拖动以柔化局部草高,按 Esc 停止。', + 'Coverage controls where grass grows. Blade size and overall density are in the Grass Field inspector.': + '覆盖率控制草地生长范围;叶片尺寸和整体密度可在“草地区域”检查器中调整。', + 'Changes grass height under the brush, not the terrain.': '调整画笔范围内的草高,不改变地形。', + 'Higher variation breaks up the stroke for a less uniform result.': + '提高变化值可打散笔触,使结果更自然。', + 'Applies across the Site, not just under the brush.': '应用于整个场地,而不只是画笔范围。', + 'Replace all grass coverage with full coverage in the current grass color.': + '使用当前草地颜色,将整个场地设为完全覆盖。', + 'Remove all painted grass from this Site. Local height adjustments are kept.': + '清除场地内所有已绘制草地,并保留局部高度调整。', + 'Reset all locally painted grass heights across the Site.': '重置场地内所有局部草高调整。', + 'Use three or six hexadecimal digits, for example #527A3B.': + '请输入 3 位或 6 位十六进制数,例如 #527A3B。', + Alley: '巷道', + Collector: '次干路', + 'Divided arterial': '分隔式主干路', + Highway: '高速公路', + 'Road preset': '道路预设', + 'Click or drag to start drawing a connected road network': '点击或拖动以开始绘制连续道路网', + 'Set a width to zero to remove that component from this side.': + '将宽度设为 0,可从该侧移除对应构件。', + 'Parking lane': '停车带', + 'Bike lane': '自行车道', + Gutter: '排水沟', + Curb: '路缘石', + Verge: '绿化带', + Sidewalk: '人行道', + Atmosphere: '大气', + Surroundings: '周边环境', + Build: '建筑', + Terrain: '地形', + Surface: '地表材质', + Water: '水体', + 'Paint grass, flowers, and low vegetation.': '绘制草地、花卉和低矮植被。', + 'Adjust the sky, lighting, and atmosphere.': '调整天空、光照和大气。', + 'Configure roads and the neighborhood beyond the site.': '设置场地以外的道路和周边环境。', + 'Open Pascal building tools.': '打开 Pascal 建筑工具。', + 'Raise, lower, flatten, and smooth the terrain.': '抬高、降低、整平和柔化地形。', + 'Paint terrain-conforming surface materials.': '绘制贴合地形的表面材质。', + 'Create and adjust ponds and water levels.': '创建并调整池塘和水位。', + 'Coming soon': '即将推出', + 'Paint coverage, then shape the grass.': '绘制覆盖范围,再塑造草地。', + 'Paint materials that follow your terrain.': '绘制随地形贴合的材质。', + 'Sky & atmosphere': '天空与大气', + Sky: '天空', + Weather: '天气', + Advanced: '高级', + Rain: '雨', + Snow: '雪', + Storm: '暴风雨', + Light: '轻度', + Steady: '稳定', + Heavy: '强烈', + Calm: '平静', + Breeze: '微风', + 'Atmosphere status': '大气状态', + 'Environment sky': '环境天空', + 'Environment sky active': '环境天空已启用', + 'Weather sky active': '天气天空已启用', + 'Scene theme active': '场景主题已启用', + 'Sky controls': '天空控制', + 'Sun & time': '日光与时间', + 'Sky mood': '天空氛围', + 'Custom sky': '自定义天空', + 'Clear day': '晴天', + Cloudy: '多云', + 'Golden hour': '黄金时刻', + 'Blue hour': '蓝调时刻', + Moonlit: '月夜', + 'Time of day': '时间', + Daylight: '日光', + Pause: '暂停', + 'Play day': '播放白天', + 'Weather type': '天气类型', + 'Clear weather': '晴朗', + 'Strong wind': '强风', + Reset: '重置', + 'Rain strength': '降雨强度', + 'Snow strength': '降雪强度', + Wind: '风力', + 'Thunder sound': '雷声', + 'Fine-tune': '精细调整', + 'Rain intensity': '降雨强度', + 'Snow intensity': '降雪强度', + 'Storm lightning': '暴风雨闪电', + 'Sun direction': '太阳方向', + 'Advanced sky controls': '高级天空控制', + 'North offset': '北向偏移', + 'Sun elevation': '太阳高度角', + 'Sun azimuth': '太阳方位角', + 'Adds rain when needed': '必要时自动增加降雨', + 'Light & visibility': '光照与可见性', + Haze: '雾霾', + 'Fog starts': '起雾距离', + 'Fog distance': '雾效距离', + Exposure: '曝光', + Clouds: '云层', + 'Base cloud cover': '基础云量', + 'Animate clouds': '云层动画', + 'Cloud scale': '云层比例', + 'Cloud softness': '云层柔和度', + 'Cloud drift': '云层漂移', + 'Sky model': '天空模型', + 'Physical scattering': '物理散射', + 'Rayleigh scattering': 'Rayleigh 散射', + 'Mie scattering': 'Mie 散射', + 'Mie anisotropy': 'Mie 异性', + 'Sun angular radius': '太阳角半径', + 'Sun radiance': '太阳辐亮度', + 'Moon phase': '月相', + Diagnostics: '诊断', + 'Reset sky': '重置天空', + 'Shape the daylight, sky, and weather.': '调整日光、天空和天气。', + 'Set roads and landscape beyond your property.': '设置用地之外的道路与景观。', + Paused: '已暂停', + 'Brush active': '画笔已启用', + 'Resume painting': '继续绘制', + 'to stop': '停止', + 'Enable Environment': '启用环境', + 'Sky, weather, and surroundings. You can also enable or disable individual features in their sections.': + '控制天空、天气和周边环境;也可在各分区中单独启用或关闭。', + 'Site View': '场地视图', + Catalogue: '目录', + 'Swimming pool': '泳池', + 'Build the pool, then add its systems and details.': '先创建泳池,再添加系统和细部。', + 'Pool stairs': '泳池扶梯', + 'Water features': '水景设施', + 'Add movement and visual interest to the pool.': '为泳池增加水流动感和视觉层次。', + Waterfall: '瀑布', + 'Pool spillover': '泳池溢流', + 'Circulation equipment': '循环设备', + 'Place the equipment that keeps the water clean and comfortable.': '放置保持水质清洁舒适的设备。', + 'Pool pump': '泳池水泵', + 'Pool filter': '泳池过滤器', + 'Pool heater': '泳池加热器', + 'Pool fittings': '泳池管件', + 'Connect the pool to its suction, drain, and return lines.': '连接泳池的吸水、排水和回水管路。', + 'Pool skimmer': '泳池撇渣器', + 'Pool return inlet': '泳池回水口', + 'Pool drain': '泳池排水口', + 'Suction valve': '吸水阀', + placed: '个已放置', + 'Back to pool catalog': '返回泳池目录', + 'Pool catalog': '泳池目录', + Roads: '道路', + Road: '道路', + Lights: '照明', + Signs: '标志', + Utilities: '市政设施', + 'Click or drag Road into the scene, then set two or more points.': + '点击或拖入“道路”,然后设置两个或更多点。', + 'Click the ground to set road points. Enter or double-click finishes the path.': + '点击地面设置道路点;按 Enter 或双击结束绘制。', + 'Drag or click to draw straight, spline and connected roads': + '拖动或点击以绘制直线、曲线及连续道路', + segment: '段道路', + segments: '段道路', + 'Road cross-section': '道路横断面', + lane: '车道', + lanes: '车道', + overall: '总宽', + Preset: '预设', + 'Local street': '支路', + Roadway: '车行道', + Left: '左侧', + Right: '右侧', + 'Lane count': '车道数', + 'Lane width': '车道宽度', + Shoulder: '路肩', + Median: '中央分隔带', + Alignment: '线形', + Straight: '直线', + Spline: '曲线', + 'Bend radius': '转弯半径', + Elevation: '高程', + Ground: '地面', + Bridge: '桥梁', + Crossings: '交叉连接', + 'Auto join': '自动连接', + 'No join': '不连接', + 'Automatic infrastructure': '自动市政设施', + 'Add editable utility assets when each road segment is committed.': + '每段道路完成后,添加可编辑的市政设施。', + 'Add automatically': '自动添加', + 'Items stay independent after placement, so moving or rotating them will not snap them back.': + '设施放置后保持独立,移动或旋转时不会自动复位。', + 'Road graph data': '道路图数据', + 'Import creates a new road; select one to replace or export it.': + '导入会新建道路;选择已有道路可替换或导出。', + 'Copy JSON': '复制 JSON', + 'Import JSON': '导入 JSON', + 'Review cleanup': '检查清理结果', + 'Roadway and area heads': '道路与区域灯具', + 'Street light': '道路灯', + 'Post-top': '柱顶灯', + "Bishop's Crook": '弯臂古典灯', + 'Cobra-head': '眼镜蛇头路灯', + 'Twin-arm median': '中央双臂灯', + 'Area pole': '区域高杆灯', + 'Truss roadway': '桁架道路灯', + 'roadway styles': '道路照明样式', + 'pedestrian styles': '步行区照明样式', + 'structure styles': '建筑结构照明样式', + 'High-mast lowering crown': '升降式高杆灯盘', + 'LED area pole': 'LED 区域高杆灯', + 'Floodlight pole': '投光灯杆', + 'Solar street light': '太阳能路灯', + 'Traditional lantern': '传统灯笼', + 'Globe / acorn': '球形 / 橡果灯', + 'Decorative candelabra': '装饰烛台灯', + 'Path / garden': '步道 / 庭院灯', + Bollard: '柱式灯', + 'Catenary / suspended': '悬索 / 吊装灯', + 'Architectural wall-arm': '建筑壁臂灯', + 'Wall-pack / bulkhead': '壁装 / 舱壁灯', + 'Tunnel / underpass': '隧道 / 地道灯', + 'Canopy / soffit': '雨棚 / 檐底灯', + 'Utility network': '市政管网', + 'Utility pole': '电线杆', + 'Traffic control': '交通控制', + 'Traffic signal': '交通信号灯', + 'Traffic signals': '交通信号灯', + Drainage: '排水设施', + 'Drainage grate': '排水篦子', + 'Drainage grates': '排水篦子', + 'Manhole cover': '井盖', + 'Manhole covers': '井盖', + 'Fire safety': '消防安全', + 'Fire hydrant': '消防栓', + 'Fire hydrants': '消防栓', + 'Traffic safety': '交通安全', + 'Traffic bollard': '防撞柱', + 'Traffic bollards': '防撞柱', + 'Road barrier': '道路护栏', + 'Road barriers': '道路护栏', + 'Cleanup review': '清理复核', + 'This road graph is already clean at the shown tolerances.': '当前道路图在所示容差下无需清理。', + 'Apply changes': '应用变更', + 'Select one road network first.': '请先选择一个道路网。', + 'Clipboard access was not available.': '无法访问剪贴板。', + 'No cleanup changes are needed.': '无需执行清理变更。', + 'The reviewed road is no longer selected.': '已复核的道路当前未被选中。', + 'Open a level before importing roads.': '导入道路前请先打开一个楼层。', + 'Road import failed.': '道路导入失败。', + 'Restore default road styles': '恢复默认道路样式', + 'The graph had no usable style presets, so the built-in road styles will be restored.': + '道路图中没有可用的样式预设,将恢复内置道路样式。', + 'Repair active road style': '修复当前道路样式', + 'One or both endpoint nodes are missing.': '一个或两个端点节点缺失。', + 'The edge loops back to the same node.': '该边回连到同一节点。', + 'Regenerate editable junction classification, primary-road candidates, and curb corners from the cleaned topology.': + '根据清理后的拓扑重新生成可编辑的交叉口分类、主路候选项和路缘转角。', + 'Remove the obsolete junction record because this node is no longer a multi-approach junction.': + '该节点已不是多进口交叉口,将删除过期的交叉口记录。', + 'Residential frontage': '住宅临街设施', + Driveway: '车道出入口', + Mailbox: '信箱', + 'Parcel box': '快递箱', + 'Recycling bin': '回收箱', + 'Driveway gate': '车道门', + 'Traffic calming': '交通稳静化', + 'Speed hump': '减速带', + 'Common signs': '常用标志', + Stop: '停车让行', + Yield: '减速让行', + 'Speed limit': '限速', + 'No entry': '禁止驶入', + 'No parking': '禁止停车', + 'Pedestrian crossing': '人行横道', + 'General warning': '一般警告', + Directional: '指路标志', + 'Choose a lamp, then click the ground to place it.': '选择灯具,然后点击地面放置。', + 'Choose a sign, then click the ground to place it.': '选择标志,然后点击地面放置。', + 'Choose a utility asset, then click the ground to place it.': '选择市政设施,然后点击地面放置。', + WebXR: 'WebXR', + 'VR session active': 'VR 会话已启用', + 'Starting VR…': '正在启动 VR…', + 'Immersive VR is not supported on this device': '此设备不支持沉浸式 VR', + 'Could not start VR': '无法启动 VR', + 'Ready to enter VR': '可以进入 VR', + 'Preparing VR…': '正在准备 VR…', + 'VR unavailable': 'VR 不可用', + 'Entering VR…': '正在进入 VR…', + 'Starting view': '初始视角', + God: '俯瞰', + Human: '人视', + 'Start above the model. Move, rotate, and scale it with your controllers.': + '从模型上方开始,用控制器移动、旋转和缩放模型。', + 'Choose a clear floor in VR, then confirm your target to explore at full scale.': + '在 VR 中选择无障碍地面,确认目标后以真实尺度漫游。', + 'Change the current mode from VR Settings.': '可在 VR 设置中切换当前模式。', + 'Controls guide': '操作指南', + 'Select and place': '选择与放置', + 'Point at an item or control, then press the trigger. With hand tracking, pinch to select.': + '指向物件或控件并扣动扳机;使用手部追踪时,捏合即可选择。', + 'Move the panel': '移动面板', + 'Grab the panel’s drag handle with the trigger or a pinch, then move it.': + '用扳机或捏合抓住面板拖柄,然后移动。', + 'Scroll settings': '滚动设置', + 'Hold the trigger or pinch over a list and drag. Tap a section heading to collapse it.': + '在列表上按住扳机或捏合并拖动;点击分区标题可折叠。', + 'God mode': '俯瞰模式', + 'Hold one controller grip to move the model. Hold both grips and move your hands to scale and rotate it.': + '按住单侧握把移动模型;按住两侧握把并移动双手,可缩放和旋转模型。', + 'Human mode': '人视模式', + 'Use the left thumbstick to move and the right thumbstick to turn.': + '使用左摇杆移动,右摇杆转向。', + 'Switch modes': '切换模式', + 'Use the XR scale control in VR Settings, or press X on a supported left controller. To enter Human mode, place a target on a clear floor, then confirm Enter.': + '使用 VR 设置中的 XR 比例控件,或按受支持左控制器上的 X 键。进入人视模式时,请先在无障碍地面放置目标,再确认进入。', + 'Connect a compatible headset and use a WebXR-enabled browser over HTTPS.': + '请连接兼容头显,并通过 HTTPS 使用支持 WebXR 的浏览器。', + 'All plugins': '全部插件', + Installed: '已安装', + 'Not installed': '未安装', + 'Add focused tools and content to this project.': '为当前项目添加专业工具和内容。', + 'Plugin ID': '插件 ID', + Creator: '开发者', + Plugin: '插件', + 'View plugin': '查看插件', + Install: '安装', + Uninstall: '卸载', + 'Create a Pascal plugin': '创建 Pascal 插件', + 'Adds a new tool panel to the editor.': '向编辑器添加新的工具面板。', + 'Procedural trees, flowers, and grasses for outdoor scenes.': + '用于户外场景的程序化树木、花卉和草地。', + 'Terrain surfaces, ground cover, ponds, and terrain-carved rivers.': + '地表、地被、池塘及地形切割河流工具。', + 'Design and place swimming pools with animated water.': '设计并放置带动态水面的泳池。', + 'Procedural systems and assets for building complete outdoor streetscapes.': + '用于构建完整户外街景的程序化系统与资产。', + 'Enter an immersive WebXR session from the editor.': '从编辑器进入沉浸式 WebXR 会话。', + Bones: '建筑透视', + 'Alpha access — new and evolving fast. The engineering X-ray for Pascal: see through the finishes to the actual construction — wall framing, floors, roof, foundation, and electrical, derived from your model and sized to your jurisdiction.': + 'Alpha 预览功能,仍在快速迭代。用于透视饰面并查看模型推导的墙体骨架、楼板、屋顶、基础和电气系统。', + 'Generate, browse and add Mint 3D assets.': '生成、浏览并添加 Mint 3D 资产。', + Snapping: '吸附', + Grid: '网格', + Lines: '参考线', + Angles: '角度', + On: '开启', + Off: '关闭', + 'Grid step': '网格步长', + Continuation: '连续绘制', + 'Room (auto-close)': '房间(自动闭合)', + 'Single wall': '单段墙', + 'Shape: Line': '形状:直线', + 'Shape: Rectangle': '形状:矩形', + 'Set wall start / end': '设置墙体起点/终点', + Cancel: '取消', + Pan: '平移', + Rotate: '旋转', + Zoom: '缩放', + 'Left click': '左键', + 'Middle click': '中键', + 'Right click': '右键', + Scroll: '滚轮', + 'Scroll wheel': '滚轮', + Space: '空格', + Dismiss: '关闭', + 'Camera controls hint': '相机操作提示', + 'Dismiss camera controls hint': '关闭相机操作提示', + 'Ground Floor': '首层', + 'Add or remove objects from the selection': '将对象加入选择或从选择中移除', + 'Click or drag the selection to move it as one': '点击或拖动选中对象以整体移动', + 'Rotate the selection ±45°': '将选中对象旋转 ±45°', + 'Group selection (session only)': '临时编组选中对象', + 'Ungroup session selection': '取消临时编组', + 'Clear the selection (or click outside)': '清除选择(或点击空白处)', + 'Clear the selection': '清除选择', + 'Level height': '层高', + 'Add level above': '在上方添加楼层', + 'Add level below': '在下方添加楼层', + 'Insert level here': '在此插入楼层', + 'Drag to reorder': '拖动调整顺序', + 'Align view to north': '视图对齐北向', + Zone: '区域', + Delete: '删除', + Measure: '测量', + Distance: '距离', + Angle: '角度', + Area: '面积', + Perimeter: '周长', + Volume: '体积', + Smart: '智能', + 'Measurement options': '测量选项', + 'Measurement type': '测量类型', + 'Floor plan': '平面图', + 'Linear dimension': '线性标注', + 'Continuous dimension': '连续标注', + 'Radius dimension': '半径标注', + 'Diameter dimension': '直径标注', + 'Center mark': '圆心标记', + 'Chord dimension': '弦长标注', + 'Arc length': '弧长标注', + 'Angular dimension': '角度标注', + 'Coordinate dimensions': '坐标标注', + 'Orbit Left': '向左旋转', + 'Orbit Right': '向右旋转', + 'Top View': '俯视图', + References: '参考图', + Visible: '显示', + Hidden: '隐藏', + 'Reference settings': '参考图设置', + Measurements: '测量标注', + '3D measurements': '3D 测量标注', + 'Floor plan mode': '平面图模式', + Default: '默认', + Expert: '专业', + 'Clean plan; dimensions appear with selection': '简洁平面图;选中对象时显示尺寸', + 'Full documentation and annotation controls': '完整显示制图与标注控件', + 'Floor plan annotations': '平面图标注', + 'Automatic dimensions': '自动尺寸', + 'Manual dimensions': '手动尺寸', + 'Door/window marks': '门窗标记', + 'Structural grids & column centers': '结构轴网与柱中心', + 'Room labels': '房间标签', + 'Stair annotations': '楼梯标注', + 'Wall dimensions': '墙体尺寸基准', + 'Finished faces': '完成面', + 'Full wall thickness': '墙体完整厚度', + 'Wall centerline': '墙体中心线', + 'Single wall axis': '单一墙体轴线', + 'Face of stud': '龙骨表面', + 'Structural core face': '结构芯层表面', + 'Magnetic snap': '磁性吸附', + Shadows: '阴影', + Camera: '相机', + Perspective: '透视', + Orthographic: '正交', + Units: '单位', + Meters: '米', + Millimeters: '毫米', + 'Feet & inches': '英尺与英寸', + Render: '渲染', + Solid: '实体', + Rendered: '渲染效果', + 'Flat and fast — no ambient occlusion': '平整快速,不启用环境光遮蔽', + 'Full ambient occlusion': '完整环境光遮蔽', + Edges: '边线', + Soft: '柔和', + Strong: '清晰', + 'No edge lines': '不显示边线', + 'Faint outline of major creases': '淡化显示主要折线', + 'Crisp, opaque edge lines': '清晰、不透明的边线', + Theme: '主题', + Studio: '影棚', + Paper: '纸面', + Sunset: '日落', + Overcast: '阴天', + Blueprint: '蓝图', + 'Enter VR': '进入 VR', + 'Exit VR': '退出 VR', + 'Entering VR': '正在进入 VR', + 'Preparing VR': '正在准备 VR', + 'Immersive VR is unavailable. Connect a headset and open the editor over HTTPS.': + '沉浸式 VR 当前不可用。请连接头显,并通过 HTTPS 打开编辑器。', + Back: '返回', + Untitled: '未命名', + Site: '场地', + Building: '建筑', + 'Property Line': '用地边界', + 'Add point': '添加顶点', + 'Camera snapshot': '相机快照', + 'View snapshot': '查看快照', + 'Update snapshot': '更新快照', + 'Take snapshot': '创建快照', + 'Clear snapshot': '清除快照', + 'Upload scan/floorplan': '上传扫描模型/平面图', + Uploading: '正在上传', + scan: '扫描模型', + guide: '参考图', + 'Base elevation': '基准标高', + 'Add level': '添加楼层', + 'No levels yet': '暂无楼层', + 'New unit': '新建单元', + 'Building units': '单元', + Structure: '结构', + Furnish: '陈设', + Zones: '分区', + 'objects selected': '个对象已选中', + 'Clear selection': '清除选择', + 'Select a level to view content': '请选择楼层以查看内容', + 'No zones on this level.': '本层暂无分区。', + 'Add one': '添加分区', + 'No elements on this level': '本层暂无构件', + 'No buildings yet': '暂无建筑', + Levels: '楼层', + Visibility: '可见性', + Scans: '扫描模型', + Guides: '参考图', + 'Guide images': '参考图像', + 'guide image': '参考图像', + 'This level': '本层', + Hide: '隐藏', + Show: '显示', + Opacity: '不透明度', + 'Upload scan or guide image': '上传扫描模型或参考图像', + 'Upload GLB meshes as scan references or blueprint images as guide references.': + '上传 GLB 网格作为扫描参考,或上传蓝图图像作为参考底图。', + 'File is too large. Maximum size is 200 MB.': '文件过大,最大支持 200 MB。', + 'Upload a .glb/.gltf scan or an image.': '请上传 .glb/.gltf 扫描模型或图像。', + 'Could not add that guide image.': '无法添加该参考图像。', + 'Scan upload is unavailable.': '当前无法上传扫描模型。', + 'Open a project before uploading a scan.': '请先打开项目,再上传扫描模型。', + 'Reference floor': '参考楼层', + 'Reference floor settings': '参考楼层设置', + 'Hide reference floor': '隐藏参考楼层', + 'Show reference floor': '显示参考楼层', + 'level below': '层(下方)', + 'No lower floor available.': '没有可用的下层楼层。', + Colors: '颜色', + Colored: '彩色', + Monochrome: '单色', + Stacked: '叠层', + Exploded: '分解', + Solo: '单层', + Manual: '手动', + Walls: '墙体', + Walkthrough: '漫游', + 'Orbit left': '向左旋转', + 'Orbit right': '向右旋转', + 'Top view': '俯视图', + 'Display settings': '显示设置', + 'Full height': '全高墙', + Cutaway: '剖切墙', + Low: '矮墙', + Wall: '墙体', + Fence: '围栏', + Item: '物件', + Slab: '楼板', + Ceiling: '吊顶', + Roof: '屋顶', + 'Roof Segment': '屋顶分段', + 'Viewer layout': '视图布局', + Split: '分屏', + 'Snapping mode — click or press Shift to cycle': '吸附模式:点击或按 Shift 切换', + 'Grid step — click or tap Ctrl to cycle': '网格步长:点击或按 Ctrl 切换', + 'Continuation — click or press C to cycle': '连续绘制:点击或按 C 切换', +} + +const STORAGE_KEY = 'pascal.locale' + +export function translate(locale: Locale, key: MessageKey): string { + return messages[locale][key] +} + +export function translateDisplayText(locale: Locale, text: string): string { + if (locale !== 'zh-CN') return text + const floor = text.match(/^Floor (\d+)$/) + if (floor) return `${floor[1]} 层` + const basement = text.match(/^Basement (\d+)$/) + if (basement) return `地下 ${basement[1]} 层` + const pointCount = text.match(/^(\d+) \/ 128 points$/) + if (pointCount) return `${pointCount[1]} / 128 个点` + const selectedPointCount = text.match(/^(\d+) points$/) + if (selectedPointCount) return `${selectedPointCount[1]} 个点` + const registeredPointCount = text.match( + /^(\d+) points registered\. The river is ready to finish\.$/, + ) + if (registeredPointCount) return `已设置 ${registeredPointCount[1]} 个点,可以完成河流。` + let match = text.match( + /^Drainage reserves ([\d.]+) m gutters; hydrants reserve a ([\d.]+) m roadside verge\.$/, + ) + if (match) return `排水设施预留 ${match[1]} m 路缘排水带;消防栓预留 ${match[2]} m 路侧绿化带。` + match = text.match(/^([\d.]+) m plan · ([\d.]+) m vertical$/) + if (match) return `平面容差 ${match[1]} m · 竖向容差 ${match[2]} m` + match = text.match(/^(\d+) changes$/) + if (match) return `${match[1]} 项变更` + match = text.match(/^(\d+) validation issues? will remain after cleanup\.$/) + if (match) return `清理后仍将保留 ${match[1]} 项验证问题。` + match = text.match(/^(\d+) road segments? selected$/) + if (match) return `已选择 ${match[1]} 个道路分段` + match = text.match(/^Copied (\d+) road segments? as JSON\.$/) + if (match) return `已将 ${match[1]} 个道路分段复制为 JSON。` + match = text.match(/^Review (\d+) proposed cleanup changes? before applying\.$/) + if (match) return `应用前请复核 ${match[1]} 项清理变更。` + match = text.match(/^Applied (\d+) reviewed cleanup changes?\.$/) + if (match) return `已应用 ${match[1]} 项经复核的清理变更。` + match = text.match(/^(?:Replaced the selected road with|Imported) (\d+) road segments?\.$/) + if (match) + return `${text.startsWith('Replaced') ? '已替换选中道路,共' : '已导入'} ${match[1]} 个道路分段。` + match = text.match(/^Remove edge (.+)$/) + if (match) return `移除边 ${match[1]}` + match = text.match(/^Repair style on (.+)$/) + if (match) return `修复 ${match[1]} 的样式` + match = text.match(/^Merge node (.+) into (.+)$/) + if (match) return `将节点 ${match[1]} 合并到 ${match[2]}` + match = text.match(/^Snap (.+) to (.+)$/) + if (match) return `将 ${match[1]} 吸附到 ${match[2]}` + match = text.match(/^Create crossing at (.+)$/) + if (match) return `在 ${match[1]} 创建交叉口` + match = text.match(/^Remove isolated node (.+)$/) + if (match) return `移除孤立节点 ${match[1]}` + match = text.match(/^Rebuild junction (.+)$/) + if (match) return `重建交叉口 ${match[1]}` + match = text.match(/^Its plan length is below ([\d.]+) m\.$/) + if (match) return `其平面长度小于 ${match[1]} m。` + match = text.match(/^It duplicates edge (.+)\.$/) + if (match) return `它与边 ${match[1]} 重复。` + match = text.match(/^Replace missing active style (.+) with (.+)\.$/) + if (match) return `将缺失的当前样式 ${match[1]} 替换为 ${match[2]}。` + match = text.match(/^Replace missing style (.+) with (.+)\.$/) + if (match) return `将缺失的样式 ${match[1]} 替换为 ${match[2]}。` + match = text.match( + /^The nodes are ([\d.]+) m apart at a compatible level\. Incident edges and junction state will use (.+)\.$/, + ) + if (match) return `节点在兼容标高下相距 ${match[1]} m;关联边和交叉口状态将使用 ${match[2]}。` + match = text.match( + /^Move the dangling endpoint ([\d.]+) m to the compatible road centerline and split that edge at the new T-junction\.$/, + ) + if (match) return `将悬空端点移动 ${match[1]} m 至兼容的道路中心线,并在新 T 形交叉口处分割该边。` + match = text.match( + /^Split (.+) and (.+) at their same-level centerline crossing and connect all four approaches\.$/, + ) + if (match) return `在 ${match[1]} 与 ${match[2]} 的同标高中心线交点处分割,并连接四个进口。` + match = text.match( + /^No road edge references this node(; its stale junction record is removed too)?\.$/, + ) + if (match) + return match[1] + ? '没有道路边引用此节点;其过期的交叉口记录也将删除。' + : '没有道路边引用此节点。' + match = text.match(/^(.*) Also removes (\d+) attached asset references?\.$/) + if (match) + return `${translateDisplayText(locale, match[1]!)}同时移除 ${match[2]} 个关联资产引用。` + return displayMessages[text] ?? text +} + +type I18nValue = { + locale: Locale + setLocale: (locale: Locale) => void + t: (key: MessageKey) => string + translateDisplayText: (text: string) => string +} + +const I18nContext = createContext(null) + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState('en') + + useEffect(() => { + const saved = localStorage.getItem(STORAGE_KEY) + const next = + saved === 'en' || saved === 'zh-CN' + ? saved + : navigator.language.startsWith('zh') + ? 'zh-CN' + : 'en' + setLocale(next) + }, []) + + useEffect(() => { + document.documentElement.lang = locale + localStorage.setItem(STORAGE_KEY, locale) + document.cookie = `${STORAGE_KEY}=${locale}; path=/; max-age=31536000; samesite=lax` + }, [locale]) + + const value = useMemo( + () => ({ + locale, + setLocale, + t: (key) => translate(locale, key), + translateDisplayText: (text) => translateDisplayText(locale, text), + }), + [locale], + ) + + return {children} +} + +export function useI18n(): I18nValue { + const value = useContext(I18nContext) + if (!value) throw new Error('useI18n must be used inside I18nProvider') + return value +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index c001f82113..3cefe9b7f6 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -39,6 +39,7 @@ import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' import { useSaveShortcut } from '../../hooks/use-save-shortcut' import { useCeilingEditSessionOwner } from '../../lib/ceiling-edit-session' +import { DisplayTextProvider, useDisplayText } from '../../lib/display-text' import { showsWholeBuilding, useEditorLevelDisplay } from '../../lib/editor-level-display' import { useGestureLifecycleOwner } from '../../lib/gesture-lifecycle' import { @@ -196,6 +197,8 @@ export interface EditorProps { sidebarTabs?: (SidebarTab & { component: React.ComponentType })[] viewerToolbarLeft?: ReactNode viewerToolbarRight?: ReactNode + /** Translates display-only labels without changing scene or registry values. */ + translateDisplayText?: (text: string) => string /** * Full-bleed surface swapped in over the 3D canvas (v2) — e.g. the studio * gallery. The canvas stays mounted underneath (no WebGL re-init) and the @@ -506,18 +509,20 @@ function writeCameraControlsHintDismissed(dismissed: boolean) { } function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) { + const t = useDisplayText() const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value] + const label = t(meta?.label ?? shortcutKey.value) if (meta?.icon) { return ( ) } @@ -543,10 +548,11 @@ function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) { } function CameraControlHintItem({ hint }: { hint: CameraControlHint }) { + const t = useDisplayText() return (
- {hint.action} + {t(hint.action)}
@@ -562,6 +568,7 @@ function CameraControlHintItem({ hint }: { hint: CameraControlHint }) { } function ViewerCanvasControlsHint({ onDismiss }: { onDismiss: () => void }) { + const t = useDisplayText() const all = CAMERA_CONTROL_HINTS // A host teaching one gesture at a time narrows this to the one it is asking // for, and to nothing once it is done. Null — the default — is all of them. @@ -575,7 +582,7 @@ function ViewerCanvasControlsHint({ onDismiss }: { onDismiss: () => void }) { return (
void }) { - Dismiss + {t('Dismiss')}
@@ -1812,7 +1819,9 @@ function EditorContent({ export default function Editor(props: EditorProps) { return ( - + + + ) } diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx index 5455095f82..f8ffeccc20 100644 --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -2,10 +2,12 @@ import { emitter } from '@pascal-app/core' import Image from 'next/image' +import { useDisplayText } from '../../../lib/display-text' import useEditor from '../../../store/use-editor' import { ActionButton } from './action-button' export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { + const t = useDisplayText() // Orbit stays useful in 2D-only (it spins the synced floorplan view), but // top view only tilts the hidden 3D camera — pointless without the canvas. const is2dOnly = useEditor((s) => s.viewMode === '2d') @@ -29,13 +31,13 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { {/* Orbit CCW */} Orbit Left Orbit Right Top View state.mode) const tool = useEditor((state) => state.tool) @@ -100,6 +102,7 @@ export function MeasurementControl() { : isSmartActive ? 'Smart' : selectedOption.label + const measureLabel = `${t('Measure')}: ${t(selectedLabel)}` const activateMeasurement = (kind: CreatableMeasurementKind) => { setLastMeasurementKind(kind) @@ -140,7 +143,7 @@ export function MeasurementControl() {
-
+
{measurementMenuOptions.map((option) => { const OptionIcon = option.icon const isSmart = option.kind === 'smart' @@ -210,7 +213,7 @@ export function MeasurementControl() { type="button" >
) : (
- {emptyText} + {t(emptyText)}
)}
@@ -657,6 +657,7 @@ function ReferenceListSection({ } function ReferencesControl() { + const t = useDisplayText() const showScans = useViewer((state) => state.showScans) const setShowScans = useViewer((state) => state.setShowScans) const showGuides = useViewer((state) => state.showGuides) @@ -685,14 +686,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={`${t('References')}: ${t(anyVisible ? 'Visible' : 'Hidden')}`} onClick={toggleAll} size="icon" variant="ghost" >
References @@ -705,7 +706,7 @@ function ReferencesControl() { ) })}
) : (
- No lower floor available. + {t('No lower floor available.')}
)}
diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 905dc8ca63..51feb53719 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -47,6 +47,7 @@ import { } from 'react' import { useShallow } from 'zustand/react/shallow' import { pasteSelectionAndPickUp } from '../editor/group-actions' +import { useDisplayText } from '../../lib/display-text' import { buildLevelDuplicateCreateOps, type LevelDuplicatePreset, @@ -154,6 +155,7 @@ function LevelRow({ onPaste?: () => void onRequestDelete: () => void }) { + const t = useDisplayText() const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) const updateNode = useScene((s) => s.updateNode) @@ -168,6 +170,7 @@ function LevelRow({ // Same rule as the site panel and command palette: the ordinal-0 ground // floor is the vertical model's zero anchor and must never be deletable. const canDeleteLevel = level.level !== 0 + const levelDisplayName = t(getLevelDisplayName(level)) // Clean preset values per display system; imperial stores exact meters // for whole-foot storey heights. @@ -203,7 +206,7 @@ function LevelRow({ > ) diff --git a/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx index bcad6df89e..8b5bca69c6 100644 --- a/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx +++ b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx @@ -4,6 +4,7 @@ import { X } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { type ReactNode, useEffect, useState } from 'react' import { createPortal } from 'react-dom' +import { useDisplayText } from '../../../lib/display-text' import useEditor from '../../../store/use-editor' import { NodeIconImage } from '../node-icon-image' @@ -19,6 +20,7 @@ const HEIGHT_VH = 50 const DRAG_CLOSE_THRESHOLD_PX = 120 export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) { + const t = useDisplayText() const [mounted, setMounted] = useState(false) const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight) @@ -88,7 +90,7 @@ export function MobilePanelSheet({ open, onClose, icon, title, children }: Mobil
) })} @@ -242,7 +244,7 @@ function LegacyItemsPanel({ setSearch(e.target.value) onSearchChange?.(e.target.value) }} - placeholder="Search..." + placeholder={t('Search...')} type="text" value={search} /> @@ -262,7 +264,7 @@ function LegacyItemsPanel({ onClick={() => setActiveSource(isActive ? null : chip.id)} type="button" > - {chip.label} + {t(chip.label)} ) })} diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx index ccf085908d..8a4c5a1aa0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx @@ -10,8 +10,10 @@ import { } from '../../../../../components/ui/primitives/dialog' import { Slider } from '../../../../../components/ui/slider' import useAudio from '../../../../../store/use-audio' +import { useDisplayText } from '../../../../../lib/display-text' export function AudioSettingsDialog() { + const t = useDisplayText() const { masterVolume, sfxVolume, @@ -28,19 +30,19 @@ export function AudioSettingsDialog() { - Audio settings - Adjust volume levels and mute settings + {t('Audio settings')} + {t('Adjust volume levels and mute settings')}
{/* Master Volume */}
- + {masterVolume}%
- + {radioVolume}%
- + {sfxVolume}%
{muted ? : } - {muted ? 'Unmute All Sounds' : 'Mute All Sounds'} + {t(muted ? 'Unmute All Sounds' : 'Mute All Sounds')}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index adf768d9be..fbc86ae312 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -12,6 +12,7 @@ import { ShortcutToken, shortcutDisplayValue, } from './../../../../../components/ui/primitives/shortcut-token' +import { useDisplayText } from './../../../../../lib/display-text' type Shortcut = { keys: string[] @@ -208,12 +209,17 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ ] function ShortcutKeys({ keys }: { keys: string[] }) { + const t = useDisplayText() return (
{keys.map((key, index) => (
{index > 0 ? + : null} - +
))}
@@ -221,27 +227,27 @@ function ShortcutKeys({ keys }: { keys: string[] }) { } export function KeyboardShortcutsDialog() { + const t = useDisplayText() return ( - Keyboard shortcuts + {t('Keyboard shortcuts')} - Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift - during an active gesture to build freely. + {t('Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift during an active gesture to build freely.')}
{SHORTCUT_CATEGORIES.map((category) => (
-

{category.title}

+

{t(category.title)}

{category.shortcuts.map((shortcut, index) => (
-

{shortcut.action}

+

{t(shortcut.action)}

{shortcut.note ? ( -

{shortcut.note}

+

{t(shortcut.note)}

) : null}
{index < category.shortcuts.length - 1 ? ( diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx index 1b5f7172ba..3ee9ae08e7 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx @@ -28,6 +28,7 @@ import { type LinearUnit, squareMetersToAreaUnit, } from '../../../../../lib/measurements' +import { useDisplayText } from '../../../../../lib/display-text' export type PendingImport = { fileName: string @@ -94,6 +95,7 @@ function formatFloorArea(m2: number, unit: LinearUnit): string { } export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { + const t = useDisplayText() const [showAllWarnings, setShowAllWarnings] = useState(false) const [showSchemaIssues, setShowSchemaIssues] = useState(false) const unit = useViewer((state) => state.unit) @@ -122,7 +124,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { ) : ( )} - {ok ? 'Ready to import' : 'Cannot import this file'} + {t(ok ? 'Ready to import' : 'Cannot import this file')} {fileName} · {formatFileSize(fileSizeBytes)} · {stats.total} node @@ -148,7 +150,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { {stats.total > 0 && (
- Structure + {t('Structure')}
{rows.length > 0 ? (
@@ -163,7 +165,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { >
- {row.label} + {t(row.label)}
{row.count}
@@ -171,7 +173,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { })} {stats.floorAreaM2 > 0 && (
- Floor area + {t('Floor area')} {formatFloorArea(stats.floorAreaM2, unit)} @@ -180,7 +182,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
) : (
- The file contains no recognised nodes. + {t('The file contains no recognised nodes.')}
)}
@@ -250,7 +252,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx index 32ab8ad7f7..3ed2687fa1 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx @@ -15,6 +15,7 @@ import { type PrintExportReport, } from '../../../../../lib/print-export' import useEditor from '../../../../../store/use-editor' +import { useDisplayText } from '../../../../../lib/display-text' type PreparedPrintExport = { artifact: ModelExportArtifact @@ -74,6 +75,7 @@ export async function preparePrintExport( } export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) { + const t = useDisplayText() const modelExport = useEditor((state) => state.modelExport) const [exportingFormat, setExportingFormat] = useState(null) const [error, setError] = useState(null) @@ -105,7 +107,7 @@ export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) { variant="outline" > - Export 3D print 3MF + {t('Export 3D print 3MF')} {error && (
diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx index 08aeb8284d..0f5ca300c3 100644 --- a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -12,6 +12,7 @@ import { useSyncExternalStore, } from 'react' import useEditor from '../../../store/use-editor' +import { useDisplayText } from '../../../lib/display-text' import { editorHostPanelRegistry, type EditorHostPanel, @@ -75,13 +76,18 @@ function resolvePanelComponent(panel: EditorHostPanel): ComponentType { const cached = wrappedPanelCache.get(panel.component) if (cached) return cached const Lazy = lazy(panel.component) - const Wrapped: ComponentType = () => ( - }> - Loading…
}> - - - - ) + const Wrapped: ComponentType = () => { + const t = useDisplayText() + return ( + }> + {t('Loading…')}
} + > + + + + ) + } Wrapped.displayName = `PluginPanel(${panel.id})` wrappedPanelCache.set(panel.component, Wrapped) return Wrapped @@ -99,6 +105,7 @@ function resolvePanelComponent(panel: EditorHostPanel): ComponentType { * authoring panel like Nature doesn't ride into the studio rail. */ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { + const t = useDisplayText() const registered = useSyncExternalStore( editorHostPanelRegistry.subscribe, editorHostPanelRegistry.getSnapshot, @@ -127,7 +134,7 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { .map( (p): ExtraPanel => ({ id: p.id, - label: p.label, + label: t(p.label), icon: renderIconRef(p.icon), component: resolvePanelComponent(p), pluginId: p.pluginId, @@ -142,7 +149,7 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { readOnly, workspaceMode, }) - ? [pluginsManagerPanel] + ? [{ ...pluginsManagerPanel, label: t(pluginsManagerPanel.label) }] : [] return [...(hostPanels ?? []), ...fromRegistry, ...manager] } diff --git a/packages/editor/src/components/viewer/floorplan-compass-button.tsx b/packages/editor/src/components/viewer/floorplan-compass-button.tsx index eef109049d..3816e31445 100644 --- a/packages/editor/src/components/viewer/floorplan-compass-button.tsx +++ b/packages/editor/src/components/viewer/floorplan-compass-button.tsx @@ -1,6 +1,7 @@ 'use client' import type React from 'react' +import { useDisplayText } from '../../lib/display-text' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' export type FloorplanCompassButtonProps = { @@ -14,11 +15,12 @@ export function FloorplanCompassButton({ onAlignNorth, needleRef, }: FloorplanCompassButtonProps) { + const t = useDisplayText() return ( - Align view to north + {t('Align view to north')} ) } diff --git a/packages/editor/src/components/viewer/viewer-controls-bar.tsx b/packages/editor/src/components/viewer/viewer-controls-bar.tsx index 929dbd4a99..6241bcac8c 100644 --- a/packages/editor/src/components/viewer/viewer-controls-bar.tsx +++ b/packages/editor/src/components/viewer/viewer-controls-bar.tsx @@ -26,6 +26,7 @@ import { Square, SwatchBook, } from 'lucide-react' +import { useDisplayText } from '../../lib/display-text' import { cn } from '../../lib/utils' import { ActionButton } from '../ui/action-menu/action-button' import { @@ -48,20 +49,18 @@ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { const wallModeConfig = { up: { - icon: (props: any) => ( - Full height - ), + icon: (props: any) => , label: 'Full height', }, cutaway: { icon: (props: any) => ( - Cutaway + ), label: 'Cutaway', }, down: { icon: (props: any) => ( - Low + ), label: 'Low', }, @@ -94,6 +93,7 @@ function VisibilityMenu({ canShowScans: boolean canShowGuides: boolean }) { + const t = useDisplayText() const showScans = useViewer((s) => s.showScans) const showGuides = useViewer((s) => s.showGuides) return ( @@ -101,7 +101,7 @@ function VisibilityMenu({ keepOpen(e, () => useViewer.getState().setShowScans(!showScans))} > - Scans + {t('Scans')} {showScans ? ( ) : ( @@ -128,7 +128,7 @@ function VisibilityMenu({ onSelect={(e) => keepOpen(e, () => useViewer.getState().setShowGuides(!showGuides))} > - Guides + {t('Guides')} {showGuides ? ( ) : ( @@ -144,6 +144,7 @@ function VisibilityMenu({ // One "Display" button gathering shadows, camera projection, colors, render // mode, scene theme and edges. function DisplayMenu() { + const t = useDisplayText() const cameraMode = useViewer((s) => s.cameraMode) const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) @@ -158,7 +159,7 @@ function DisplayMenu() { keepOpen(e, () => useViewer.getState().setShadows(!shadows))} > - Shadows - {shadows ? 'On' : 'Off'} + {t('Shadows')} + {t(shadows ? 'On' : 'Off')} @@ -184,18 +185,18 @@ function DisplayMenu() { } > - Camera + {t('Camera')} - {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + {t(cameraMode === 'perspective' ? 'Perspective' : 'Orthographic')} keepOpen(e, () => useViewer.getState().setTextures(!textures))} > {textures ? : } - Colors + {t('Colors')} - {textures ? 'Colored' : 'Monochrome'} + {t(textures ? 'Colored' : 'Monochrome')} @@ -204,8 +205,8 @@ function DisplayMenu() { - Render - {activeShading.name} + {t('Render')} + {t(activeShading.name)} {SHADING_OPTIONS.map((option) => { @@ -217,8 +218,8 @@ function DisplayMenu() { >
- {option.name} - {option.detail} + {t(option.name)} + {t(option.detail)}
{shading === option.id ? ( @@ -232,32 +233,32 @@ function DisplayMenu() { - Theme + {t('Theme')} - {activeTheme.name} + {t(activeTheme.name)} - {SCENE_THEMES.map((t) => { + {SCENE_THEMES.map((theme) => { const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( - (role) => t.clayTints?.[role] ?? CLAY_PALETTE[role], + (role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role], ) return ( useViewer.getState().setSceneTheme(t.id)} + key={theme.id} + onSelect={() => useViewer.getState().setSceneTheme(theme.id)} > {swatches.map((color, index) => ( - + ))} - {t.name} - {sceneTheme === t.id ? : null} + {t(theme.name)} + {sceneTheme === theme.id ? : null} ) })} @@ -267,8 +268,8 @@ function DisplayMenu() { - Edges - {activeEdges.name} + {t('Edges')} + {t(activeEdges.name)} {EDGE_OPTIONS.map((option) => ( @@ -277,8 +278,8 @@ function DisplayMenu() { onSelect={() => useViewer.getState().setEdges(option.id)} >
- {option.name} - {option.detail} + {t(option.name)} + {t(option.detail)}
{edges === option.id ? : null} @@ -316,6 +317,7 @@ export const ViewerControlsBar = ({ onWalkthroughToggle, className, }: ViewerControlsBarProps) => { + const t = useDisplayText() const levelMode = useViewer((s) => s.levelMode) const wallMode = useViewer((s) => s.wallMode) // Sessions may carry a stale mode outside the cycle (e.g. the retired @@ -352,7 +354,7 @@ export const ViewerControlsBar = ({ ? 'hover:bg-white/5 hover:text-amber-400' : 'bg-amber-500/20 text-amber-400' } - label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`} + label={`${t('Levels')}: ${t(levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels])}`} onClick={() => { if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] @@ -376,7 +378,7 @@ export const ViewerControlsBar = ({ ? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0' : 'bg-white/10' } - label={`Walls: ${wallModeConfig[safeWallMode].label}`} + label={`${t('Walls')}: ${t(wallModeConfig[safeWallMode].label)}`} onClick={() => { const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] const nextIndex = (modes.indexOf(safeWallMode) + 1) % modes.length @@ -403,7 +405,7 @@ export const ViewerControlsBar = ({ ? 'bg-emerald-500/20 text-emerald-400' : 'hover:bg-white/5 hover:text-emerald-400' } - label={`Walkthrough: ${walkthroughActive ? 'On' : 'Off'}`} + label={`${t('Walkthrough')}: ${t(walkthroughActive ? 'On' : 'Off')}`} onClick={onWalkthroughToggle} size="icon" tooltipSide="top" @@ -417,14 +419,14 @@ export const ViewerControlsBar = ({ {/* Camera actions */} emitter.emit('camera-controls:orbit-ccw')} size="icon" tooltipSide="top" variant="ghost" > Orbit left @@ -432,14 +434,14 @@ export const ViewerControlsBar = ({ emitter.emit('camera-controls:orbit-cw')} size="icon" tooltipSide="top" variant="ghost" > Orbit right @@ -447,14 +449,14 @@ export const ViewerControlsBar = ({ emitter.emit('camera-controls:top-view')} size="icon" tooltipSide="top" variant="ghost" > Top view diff --git a/packages/editor/src/components/viewer/viewer-stage-switcher.tsx b/packages/editor/src/components/viewer/viewer-stage-switcher.tsx index ea137c6556..6f0daf2123 100644 --- a/packages/editor/src/components/viewer/viewer-stage-switcher.tsx +++ b/packages/editor/src/components/viewer/viewer-stage-switcher.tsx @@ -2,6 +2,7 @@ import { Box, Columns2, Map as MapIcon } from 'lucide-react' import type { ReactNode } from 'react' +import { useDisplayText } from '../../lib/display-text' import { cn } from '../../lib/utils' import { normalizeViewerStageModes, type ViewerStageMode } from './viewer-stage-modes' @@ -22,11 +23,12 @@ export function ViewerStageSwitcher({ modes, onChange, }: ViewerStageSwitcherProps) { + const t = useDisplayText() const enabledModes = normalizeViewerStageModes(modes) return (
1 ? 'hidden md:flex' : undefined} icon={} - label="Split" + label={t('Split')} onClick={() => onChange('split')} /> ) : null} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 774f2c6a3d..0cb6bd9dcd 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -406,6 +406,7 @@ export { nextContinuation, } from './lib/continuation' export { canDirectMoveNode } from './lib/direct-manipulation' +export { DisplayTextProvider, useDisplayText } from './lib/display-text' export { closeDoorOpenState, getDisplayedDoorValue, diff --git a/packages/editor/src/lib/display-text.test.tsx b/packages/editor/src/lib/display-text.test.tsx new file mode 100644 index 0000000000..f79ee3d2bb --- /dev/null +++ b/packages/editor/src/lib/display-text.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { DisplayTextProvider, useDisplayText } from './display-text' + +function Probe() { + const t = useDisplayText() + return {t('Nature')} +} + +describe('DisplayTextProvider', () => { + test('keeps text unchanged by default', () => { + expect(renderToStaticMarkup()).toContain('Nature') + }) + + test('translates presentation text at the render boundary', () => { + const html = renderToStaticMarkup( + (text === 'Nature' ? '自然' : text)}> + + , + ) + expect(html).toContain('自然') + }) +}) diff --git a/packages/editor/src/lib/display-text.tsx b/packages/editor/src/lib/display-text.tsx new file mode 100644 index 0000000000..e4be1620e2 --- /dev/null +++ b/packages/editor/src/lib/display-text.tsx @@ -0,0 +1,22 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' + +export type DisplayTextTranslator = (text: string) => string + +const identity: DisplayTextTranslator = (text) => text +const DisplayTextContext = createContext(identity) + +export function DisplayTextProvider({ + children, + translate = identity, +}: { + children: ReactNode + translate?: DisplayTextTranslator +}) { + return {children} +} + +export function useDisplayText(): DisplayTextTranslator { + return useContext(DisplayTextContext) +}