diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 61f2d36f64..f5802f1704 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -170,6 +170,25 @@ scrollbar-width: none; /* Firefox */ } +/* Custom scrollbar — thin, dark-themed */ +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-track { + background: #2c2c2e; +} + +::-webkit-scrollbar-thumb { + background: oklch(1 0 0 / 30%); + border-radius: 9999px; +} + +::-webkit-scrollbar-thumb:hover { + background: oklch(1 0 0 / 50%); +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 9361f3696f..95c4ed7e6d 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,9 +1,10 @@ 'use client' -import { Editor, ItemsPanel } from '@pascal-app/editor' +import { Editor, ItemsPanel, useTranslations } from '@pascal-app/editor' import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' +import { useMemo } from 'react' import { BuildTab } from '@/components/build-tab' import { CommunityViewerToolbarLeft, @@ -17,89 +18,91 @@ function EditorItemsPanel() { return } -const SIDEBAR_TABS = [ - { - id: 'site', - label: 'Scene', - component: () => null, - mobileDefaultSnap: 0.5, - mobileIcon: , - icon: ( - - ), - }, - { - id: 'build', - label: 'Build', - component: BuildTab, - mobileDefaultSnap: 0.5, - mobileIcon: , - icon: ( - - ), - }, - { - id: 'items', - label: 'Items', - component: EditorItemsPanel, - mobileDefaultSnap: 0.5, - mobileIcon: , - icon: ( - - ), - }, - { - id: 'settings', - label: 'Settings', - component: () => null, - mobileDefaultSnap: 0.5, - mobileIcon: , - icon: ( - - ), - }, -] - const PROJECT_ID = 'local-editor' export default function Home() { + const t = useTranslations() + const SIDEBAR_TABS = useMemo( + () => [ + { + id: 'site', + label: t('sidebar.scene'), + component: () => null, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, + { + id: 'build', + label: t('sidebar.build'), + component: BuildTab, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, + { + id: 'items', + label: t('sidebar.items'), + component: EditorItemsPanel, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, + { + id: 'settings', + label: t('sidebar.settings'), + component: () => null, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, + ], + [t], + ) + return (
{PROJECT_ID === 'local-editor' && (
- - Blank canvas — saved scenes are under Scenes (not this page). - + {t('editor.localWarning')} - Open saved scenes + {t('editor.openSavedScenes')}
diff --git a/apps/editor/app/scenes/layout.tsx b/apps/editor/app/scenes/layout.tsx new file mode 100644 index 0000000000..ccd6d13c92 --- /dev/null +++ b/apps/editor/app/scenes/layout.tsx @@ -0,0 +1,7 @@ +export default function ScenesLayout({ + children, +}: { + children: React.ReactNode +}) { + return
{children}
+} diff --git a/apps/editor/app/scenes/page.tsx b/apps/editor/app/scenes/page.tsx index 39b8dbf07a..616ce7b7cb 100644 --- a/apps/editor/app/scenes/page.tsx +++ b/apps/editor/app/scenes/page.tsx @@ -2,6 +2,7 @@ import { headers } from 'next/headers' import Link from 'next/link' import { CreateSceneButton } from '@/components/save-button' import type { SceneMeta } from '@/components/scene-loader' +import { ScenesList } from './scenes-list' export const dynamic = 'force-dynamic' @@ -20,98 +21,25 @@ async function resolveBaseUrl(): Promise { async function fetchScenes(): Promise { const base = await resolveBaseUrl() - const response = await fetch(`${base}/api/scenes?limit=50`, { - cache: 'no-store', - }) - if (!response.ok) { - return [] - } - const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[] - if (Array.isArray(payload)) { - return payload - } - return payload.scenes ?? [] -} - -function formatDate(iso: string): string { try { - return new Date(iso).toLocaleString() + const response = await fetch(`${base}/api/scenes?limit=50`, { + cache: 'no-store', + }) + if (!response.ok) { + return [] + } + const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[] + if (Array.isArray(payload)) { + return payload + } + return payload.scenes ?? [] } catch { - return iso + return [] } } export default async function ScenesPage() { const scenes = await fetchScenes() - return ( -
-
-
- - -
-
- -
-

Your scenes

-

- {scenes.length === 0 - ? 'No scenes yet. Create one to get started.' - : `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`} -

- - {scenes.length === 0 ? ( -
-

You haven't saved any scenes yet.

-
- -
-
- ) : ( -
    - {scenes.map((scene) => ( -
  • - -
    - {scene.thumbnailUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {scene.name} - ) : ( - No thumbnail - )} -
    -
    -

    - {scene.name} -

    -
    - {scene.nodeCount} nodes - -
    -
    - -
  • - ))} -
- )} -
-
- ) + return } diff --git a/apps/editor/app/scenes/scenes-list.tsx b/apps/editor/app/scenes/scenes-list.tsx new file mode 100644 index 0000000000..ce48447a28 --- /dev/null +++ b/apps/editor/app/scenes/scenes-list.tsx @@ -0,0 +1,97 @@ +'use client' + +import Link from 'next/link' +import { CreateSceneButton } from '@/components/save-button' +import type { SceneMeta } from '@/components/scene-loader' +import { useLocale, messages } from '../../../../packages/editor/src/lib/i18n' + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString() + } catch { + return iso + } +} + +export function ScenesList({ scenes }: { scenes: SceneMeta[] }) { + const { locale } = useLocale() + const t = (key: string, params?: Record) => { + const text = (messages[locale] as Record)[key] || key + if (!params) return text + return text.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`)) + } + + return ( +
+
+
+ + +
+
+ +
+

{t('scenes.title')}

+

+ {scenes.length === 0 + ? t('scenes.noScenes') + : t( + scenes.length === 1 ? 'scenes.sceneCount' : 'scenes.sceneCount_plural', + { count: scenes.length }, + )} +

+ + {scenes.length === 0 ? ( +
+

{t('scenes.noScenesSaved')}

+
+ +
+
+ ) : ( +
    + {scenes.map((scene) => ( +
  • + +
    + {scene.thumbnailUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {scene.name} + ) : ( + {t('scenes.noThumbnail')} + )} +
    +
    +

    + {scene.name} +

    +
    + {t('scenes.nodes', { count: scene.nodeCount })} + +
    +
    + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 9317ae12b7..ea2b97f862 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -17,6 +17,7 @@ import { triggerSFX, useEditor, useFloorplanMode, + useTranslations, } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import { useViewer } from '@pascal-app/viewer' @@ -49,7 +50,10 @@ type MepToolKind = type BuildType = { /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */ id: string - label: string + /** i18n key for the visible label. Resolved at render time via `t(labelKey)`. */ + labelKey: string + /** Fallback string for registry-driven kinds (their `presentation.label`); used when labelKey is empty. */ + label?: string /** Raster asset tile (legacy Build sidebar artwork). */ iconSrc: string /** Present for structure-tool types (absent for paint mode and the MEP group). */ @@ -62,30 +66,30 @@ type BuildType = { type MepItem = { /** Selection id — equals `kind`. */ id: string - label: string + labelKey: string iconSrc: string kind: MepToolKind } // Same icons + ordering as the community Build sidebar, minus presets. const BASE_BUILD_TYPES: BuildType[] = [ - { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, - { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, - { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, - { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, - { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, - { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, - { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, - { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, - { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, - { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, - { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, - { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, - { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, + { id: 'wall', labelKey: 'buildTab.tile.wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, + { id: 'fence', labelKey: 'buildTab.tile.fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, + { id: 'slab', labelKey: 'buildTab.tile.slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, + { id: 'ceiling', labelKey: 'buildTab.tile.ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, + { id: 'roof', labelKey: 'buildTab.tile.roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, + { id: 'stair', labelKey: 'buildTab.tile.stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, + { id: 'elevator', labelKey: 'buildTab.tile.elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, + { id: 'door', labelKey: 'buildTab.tile.door', iconSrc: '/icons/door.webp', kind: 'door' }, + { id: 'window', labelKey: 'buildTab.tile.window', iconSrc: '/icons/window.webp', kind: 'window' }, + { id: 'column', labelKey: 'buildTab.tile.column', iconSrc: '/icons/column.webp', kind: 'column' }, + { id: 'shelf', labelKey: 'buildTab.tile.shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, + { id: 'spawn', labelKey: 'buildTab.tile.spawn', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, + { id: 'kitchen', labelKey: 'buildTab.tile.kitchen', iconSrc: '/icons/kitchen.webp' }, // Group tile — no tool of its own; opens the MEP sub-grid below (like Roof). - { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, - { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, - { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, + { id: 'mep', labelKey: 'buildTab.tile.mep', iconSrc: '/icons/HVAC.webp' }, + { id: 'painting', labelKey: 'buildTab.tile.painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, + { id: 'terrain', labelKey: 'buildTab.tile.terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, ] const subscribeToClientMount = () => () => {} @@ -114,6 +118,11 @@ function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { tools.push({ id: kind, kind, + // Prefer the i18n key the definition ships (`presentation.labelKey`), + // falling back to the hardcoded `presentation.label` for kinds that + // don't carry one yet. The render path picks `labelKey` first and only + // falls back to `label` when the key is empty. + labelKey: presentation.labelKey ?? '', label: presentation.label, iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, @@ -126,17 +135,17 @@ function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { // MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP // tools had in the community Build sidebar. const MEP_ITEMS: MepItem[] = [ - { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, + { id: 'duct-segment', labelKey: 'buildTab.mep.duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, { id: 'duct-terminal', - label: 'Register', + labelKey: 'buildTab.mep.register', iconSrc: '/icons/registers.webp', kind: 'duct-terminal', }, - { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, - { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, - { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, - { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, + { id: 'hvac-equipment', labelKey: 'buildTab.mep.hvacUnit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, + { id: 'lineset', labelKey: 'buildTab.mep.lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, + { id: 'liquid-line', labelKey: 'buildTab.mep.liquidLine', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, + { id: 'pipe-segment', labelKey: 'buildTab.mep.dwvPipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, ] const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') @@ -262,6 +271,7 @@ const MEP_TOOL_KINDS = new Set([ ]) export function BuildTab() { + const t = useTranslations() const activeTool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) const roofDefaults = useEditor((s) => s.toolDefaults.roof) @@ -360,6 +370,7 @@ export function BuildTab() { > {buildTypes.map((type) => { const active = isTypeActive(type) + const label = type.labelKey ? t(type.labelKey) : (type.label ?? type.id) return ( @@ -378,7 +389,7 @@ export function BuildTab() { type="button" > {type.label} - {type.label} + {label} ) @@ -406,7 +417,9 @@ export function BuildTab() { ) : mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) ? (
-
Roof type
+
+ {t('buildTab.section.roofType')} +
{ROOF_TYPE_OPTIONS.map((roofType) => { const active = activeTool === 'roof' && activeRoofType === roofType.value @@ -427,7 +440,7 @@ export function BuildTab() { onMouseEnter={() => triggerSFX('sfx:menu-hover')} type="button" > - {roofType.label} + {t(roofType.labelKey)} ) })} @@ -444,14 +457,14 @@ export function BuildTab() { /> {activeRoofType === 'conical' && (

- Select a curved wall to match its radius and arc. + {t('buildTab.roofSource.conicalHint')}

)} {roofFeatures.length > 0 ? (
- Features & extensions + {t('buildTab.section.featuresAndExtensions')}
) : isKitchenActive ? (
-
Kitchen
+
+ {t('buildTab.tile.kitchen')} +
Modular Cabinet - Modular Cabinet + {t('buildTab.mep.modularCabinet')}
@@ -535,7 +550,9 @@ export function BuildTab() {
) : isMepActive ? (
-
MEP
+
+ {t('buildTab.tile.mep')} +
{MEP_ITEMS.map((item) => { const active = isMepItemActive(item) + const label = t(item.labelKey) return ( @@ -561,7 +579,7 @@ export function BuildTab() { type="button" > {item.label} - {item.label} + {label} ) @@ -580,7 +598,7 @@ export function BuildTab() { {ductContext ? (
- Duct + {t('buildTab.mep.duct')}
) : null} {pipeContext ? (
- DWV Pipe + {t('buildTab.mep.dwvPipe')}
) : null} {liquidLineContext ? (
- Liquid Line + {t('buildTab.mep.liquidLine')} {follow - ? 'Click a lineset to lay the line beside it.' - : 'Trace a line alongside an existing lineset (F).'} + ? t('buildTab.liquidLine.followHintOn') + : t('buildTab.liquidLine.followHintOff')}
) : null} diff --git a/apps/editor/components/icons/file-icon.tsx b/apps/editor/components/icons/file-icon.tsx new file mode 100644 index 0000000000..cbd89e3eb5 --- /dev/null +++ b/apps/editor/components/icons/file-icon.tsx @@ -0,0 +1,17 @@ +'use client' + +/** File icon — folder with document, supports currentColor */ +export function FileIcon({ className }: { className?: string }) { + return ( + + + + ) +} diff --git a/apps/editor/components/icons/scene-icon.tsx b/apps/editor/components/icons/scene-icon.tsx new file mode 100644 index 0000000000..6b812f8910 --- /dev/null +++ b/apps/editor/components/icons/scene-icon.tsx @@ -0,0 +1,21 @@ +'use client' + +/** Scene icon — layered house SVG, supports currentColor */ +export function SceneIcon({ className }: { className?: string }) { + return ( + + + + + ) +} diff --git a/apps/editor/components/icons/settings-icon.tsx b/apps/editor/components/icons/settings-icon.tsx new file mode 100644 index 0000000000..c2cd4a7e9d --- /dev/null +++ b/apps/editor/components/icons/settings-icon.tsx @@ -0,0 +1,18 @@ +'use client' + +/** Settings icon — gear/cog, supports currentColor */ +export function SettingsIcon({ className }: { className?: string }) { + return ( + + + + ) +} diff --git a/apps/editor/components/save-button.tsx b/apps/editor/components/save-button.tsx index bd227b83a4..eaed060ed0 100644 --- a/apps/editor/components/save-button.tsx +++ b/apps/editor/components/save-button.tsx @@ -1,6 +1,7 @@ 'use client' import type { SceneGraph } from '@pascal-app/editor' +import { useTranslations } from '@pascal-app/editor' import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' @@ -19,7 +20,8 @@ interface SaveButtonProps { /** * Creates a new empty scene and navigates the user to it. */ -export function CreateSceneButton({ label = 'Create new scene' }: { label?: string } = {}) { +export function CreateSceneButton({ label }: { label?: string } = {}) { + const t = useTranslations() const router = useRouter() const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -31,31 +33,31 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri const response = await fetch('/api/scenes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Untitled scene', graph: EMPTY_GRAPH }), + body: JSON.stringify({ name: t('save.untitledScene'), graph: EMPTY_GRAPH }), }) if (!response.ok) { - setError(`Failed to create scene (${response.status})`) + setError(`${t('save.failedToCreateScene')} (${response.status})`) return } const meta = (await response.json()) as { id: string } router.push(`/scene/${meta.id}`) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create scene') + setError(err instanceof Error ? err.message : t('save.failedToCreateScene')) } finally { setIsCreating(false) } - }, [router]) + }, [router, t]) return (
{error && {error}}
) @@ -67,6 +69,7 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri * built-in autosave plumbing. */ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps) { + const t = useTranslations() const router = useRouter() const [isSaving, setIsSaving] = useState(false) const [status, setStatus] = useState(null) @@ -74,7 +77,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps const handleSave = useCallback(async () => { const graph = getGraph() if (!graph) { - setStatus('No scene to save') + setStatus(t('save.noSceneToSave')) return } setIsSaving(true) @@ -89,28 +92,29 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps body: JSON.stringify({ name, graph }), }) if (response.status === 409) { - setStatus('Conflict — reload to continue') + setStatus(t('save.conflictReload')) return } if (!response.ok) { - setStatus(`Save failed (${response.status})`) + setStatus(`${t('save.saveFailed')} (${response.status})`) return } - setStatus('Saved') + setStatus(t('save.saved')) } catch (error) { - setStatus(error instanceof Error ? error.message : 'Save failed') + setStatus(error instanceof Error ? error.message : t('save.saveFailed')) } finally { setIsSaving(false) } - }, [getGraph, name, sceneId, version]) + }, [getGraph, name, sceneId, t, version]) const handleSaveAs = useCallback(async () => { const graph = getGraph() if (!graph) { - setStatus('No scene to save') + setStatus(t('save.noSceneToSave')) return } - const newName = typeof window !== 'undefined' ? window.prompt('New scene name', name) : null + const newName = + typeof window !== 'undefined' ? window.prompt(t('save.newSceneName'), name) : null if (!newName) return setIsSaving(true) setStatus(null) @@ -121,17 +125,17 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps body: JSON.stringify({ name: newName, graph }), }) if (!response.ok) { - setStatus(`Save-as failed (${response.status})`) + setStatus(`${t('save.saveAsFailed')} (${response.status})`) return } const meta = (await response.json()) as { id: string } router.push(`/scene/${meta.id}`) } catch (error) { - setStatus(error instanceof Error ? error.message : 'Save-as failed') + setStatus(error instanceof Error ? error.message : t('save.saveAsFailed')) } finally { setIsSaving(false) } - }, [getGraph, name, router]) + }, [getGraph, name, router, t]) return (
@@ -141,7 +145,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps onClick={handleSave} type="button" > - {isSaving ? 'Saving…' : 'Save'} + {isSaving ? t('save.saving') : t('save.save')} {status && {status}}
) -} +} \ No newline at end of file diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index d7538ead71..317b21a754 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -8,12 +8,13 @@ import { Editor, type SceneGraph, type SidebarTab, + useTranslations, } from '@pascal-app/editor' import { Hammer, Layers, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard' import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature' import { cn } from '@/lib/utils' @@ -33,57 +34,6 @@ export interface SceneMeta { nodeCount: number } -const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ - { - id: 'site', - label: 'Scene', - component: () => null, // Built-in SitePanel handles this - mobileDefaultSnap: 0.5, - mobileIcon: , - icon: ( - - ), - }, - { - id: 'build', - label: 'Build', - component: BuildTab, - mobileDefaultSnap: 0.5, - mobileIcon: , - icon: ( - - ), - }, - { - id: 'settings', - label: 'Settings', - component: () => null, - mobileDefaultSnap: 0.5, - mobileIcon: , - icon: ( - - ), - }, -] - interface SceneLoaderProps { initialScene: SceneGraph meta: SceneMeta @@ -240,28 +190,81 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { [meta.id], ) + const t = useTranslations() + const sidebarTabs: (SidebarTab & { component: React.ComponentType })[] = useMemo( + () => [ + { + id: 'site', + label: t('sidebar.scene'), + component: () => null, // Built-in SitePanel handles this + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, + { + id: 'build', + label: t('sidebar.build'), + component: BuildTab, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, + { + id: 'settings', + label: t('sidebar.settings'), + component: () => null, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, + ], + [t], + ) + return (
{conflict && (
-

Another session saved first — refresh?

-

- Your changes haven't been saved. Reload to pick up the latest version. -

+

{t('editor.conflictTitle')}

+

{t('editor.conflictBody')}

@@ -281,16 +284,16 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { onClick={() => router.push(lightPreview ? `/scene/${meta.id}` : `/scene/${meta.id}?disable=postFx`) } - title="Skip the post-processing pipeline — lighter on the GPU, no ambient occlusion or selection outlines" + title={t('editor.lightPreviewTitle')} type="button" > - Light preview + {t('editor.lightPreview')} - All scenes + {t('scenes.allScenes')}
} viewerToolbarRight={} /> diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index a707288ecf..8f70d1be7e 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -14,6 +14,7 @@ import { useFloorplanAnnotationVisibility, useFloorplanMode, useSidebarStore, + useTranslations, type ViewMode, } from '@pascal-app/editor' import { @@ -84,10 +85,10 @@ function ToolbarTooltip({ children, label }: { children: ReactNode; label: strin ) } -const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ +const VIEW_MODES: { id: ViewMode; labelKey: string; icon: React.ReactNode }[] = [ { id: '3d', - label: '3D', + labelKey: 'viewer.viewMode3D', icon: ( , }, ] const levelModeOrder = ['stacked', 'exploded', 'solo'] as const -const levelModeLabels: Record = { - manual: 'Stack', - stacked: 'Stack', - exploded: 'Exploded', - solo: 'Solo', -} +const levelModeLabels = { + manual: 'viewer.manual', + stacked: 'viewer.stack', + exploded: 'viewer.exploded', + solo: 'viewer.solo', +} as const satisfies Record 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: 'viewer.fullHeight' }, + cutaway: { icon: '/icons/wallcut.webp', labelKey: 'viewer.cutaway' }, + down: { icon: '/icons/walllow.webp', labelKey: 'viewer.low' }, + translucent: { icon: '/icons/wall.webp', labelKey: 'viewer.translucent' }, } const SHADING_OPTIONS = [ - { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box }, - { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, + { id: 'solid', nameKey: 'viewer.solid', detailKey: 'viewer.flatAndFast', icon: Box }, + { id: 'rendered', nameKey: 'viewer.rendered', detailKey: 'viewer.fullAO', icon: Sparkles }, ] as const const FLOORPLAN_ANNOTATION_OPTIONS = [ - { id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler }, - { id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler }, - { id: 'measurements', name: 'Measurements', icon: ScanLine }, - { id: 'openingMarks', name: 'Door/window marks', icon: Tag }, - { id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 }, - { id: 'roomLabels', name: 'Room labels', icon: SquareUserRound }, - { id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints }, + { id: 'automaticDimensions', nameKey: 'viewer.automaticDimensions', icon: Ruler }, + { id: 'manualDimensions', nameKey: 'viewer.manualDimensions', icon: Ruler }, + { id: 'measurements', nameKey: 'viewer.measurements', icon: ScanLine }, + { id: 'openingMarks', nameKey: 'viewer.openingMarks', icon: Tag }, + { id: 'structuralGrids', nameKey: 'viewer.structuralGrids', icon: Grid2X2 }, + { id: 'roomLabels', nameKey: 'viewer.roomLabels', icon: SquareUserRound }, + { id: 'stairAnnotations', nameKey: 'viewer.stairAnnotations', icon: Footprints }, ] as const const FLOORPLAN_MODE_OPTIONS = [ { id: 'default', - name: 'Default', - detail: 'Clean plan; dimensions appear with selection', + nameKey: 'viewer.floorplanDefault', + detailKey: 'viewer.floorplanDefaultDetail', }, { id: 'expert', - name: 'Expert', - detail: 'Full documentation and annotation controls', + nameKey: 'viewer.floorplanExpert', + detailKey: 'viewer.floorplanExpertDetail', }, ] as const const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [ - { id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' }, - { id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' }, - { id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' }, + { id: 'finished-faces', nameKey: 'viewer.finishedFaces', detailKey: 'viewer.finishedFacesDetail' }, + { id: 'centerline', nameKey: 'viewer.wallCenterline', detailKey: 'viewer.wallCenterlineDetail' }, + { id: 'stud-faces', nameKey: 'viewer.faceOfStud', detailKey: 'viewer.faceOfStudDetail' }, ] as const function ViewModeControl() { + const t = useTranslations() const viewMode = useEditor((state) => state.viewMode) const setViewMode = useEditor((state) => state.setViewMode) @@ -176,10 +178,11 @@ function ViewModeControl() {
{VIEW_MODES.map((mode) => { const isActive = viewMode === mode.id + const label = t(mode.labelKey) return ( - + ) @@ -201,6 +204,7 @@ function ViewModeControl() { } function CollapseSidebarButton() { + const t = useTranslations() const isCollapsed = useSidebarStore((state) => state.isCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) @@ -210,9 +214,11 @@ function CollapseSidebarButton() { return (
- + ) } function WallModeToggle() { + const t = useTranslations() const wallMode = useViewer((state) => state.wallMode) const setWallMode = useViewer((state) => state.setWallMode) const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway! @@ -281,8 +293,11 @@ function WallModeToggle() { if (next) setWallMode(next) } + const labelText = t(config.labelKey) + const tooltipLabel = t('viewer.wallsWithMode', { mode: labelText }) + return ( - + ) @@ -305,14 +320,15 @@ function WallModeToggle() { // camera projection, units, render mode, edges and scene theme. const EDGE_OPTIONS = [ - { id: 'off', name: 'Off', detail: 'No edge lines' }, - { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' }, - { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' }, -] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[] + { id: 'off', nameKey: 'viewer.off_edge', detailKey: 'viewer.noEdgeLines' }, + { id: 'soft', nameKey: 'viewer.soft', detailKey: 'viewer.faintOutline' }, + { id: 'strong', nameKey: 'viewer.strong', detailKey: 'viewer.crispEdges' }, +] as const satisfies readonly { id: EdgeMode; nameKey: string; detailKey: string }[] const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl' function DisplayMenu() { + const t = useTranslations() const viewMode = useEditor((state) => state.viewMode) const showGrid = useViewer((state) => state.showGrid) const setShowGrid = useViewer((state) => state.setShowGrid) @@ -358,15 +374,15 @@ function DisplayMenu() { return ( - + @@ -378,7 +394,7 @@ function DisplayMenu() { > keepOpen(e, () => setShowGrid(!showGrid))}> - Grid + {t('viewer.grid')} {showGrid ? ( ) : ( @@ -390,7 +406,9 @@ function DisplayMenu() { onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))} > - {viewMode === 'split' ? '3D measurements' : 'Measurements'} + + {viewMode === 'split' ? t('viewer.measurements3d') : t('viewer.measurements')} + {showMeasurements ? ( ) : ( @@ -403,17 +421,17 @@ function DisplayMenu() { - Floor plan mode + {t('viewer.floorplanMode')} - {floorplanMode === 'default' ? 'Default' : 'Expert'} + {floorplanMode === 'default' ? t('viewer.floorplanDefault') : t('viewer.floorplanExpert')} {FLOORPLAN_MODE_OPTIONS.map((option) => ( setFloorplanMode(option.id)}>
- {option.name} - {option.detail} + {t(option.nameKey)} + {t(option.detailKey)}
{floorplanMode === option.id ? ( @@ -427,7 +445,7 @@ function DisplayMenu() { - Floor plan annotations + {t('viewer.floorplanAnnotations')} {FLOORPLAN_ANNOTATION_OPTIONS.map((option) => { @@ -441,7 +459,7 @@ function DisplayMenu() { } > - {option.name} + {t(option.nameKey)} {visible ? ( ) : ( @@ -455,13 +473,13 @@ function DisplayMenu() { - Wall dimensions + {t('viewer.wallDimensions')} - { + {t( FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( (option) => option.id === wallDimensionReference, - )?.name - } + )?.nameKey ?? 'viewer.finishedFaces', + )} @@ -473,8 +491,8 @@ function DisplayMenu() { } >
- {option.name} - {option.detail} + {t(option.nameKey)} + {t(option.detailKey)}
{wallDimensionReference === option.id ? ( @@ -489,15 +507,17 @@ function DisplayMenu() { ) : null} keepOpen(e, () => setMagneticSnap(!magneticSnap))}> - Magnetic snap + {t('viewer.magneticSnap')} - {magneticSnap ? 'On' : 'Off'} + {magneticSnap ? t('viewer.on') : t('viewer.off')} keepOpen(e, () => setShadows(!shadows))}> - Shadows - {shadows ? 'On' : 'Off'} + {t('viewer.shadows')} + + {shadows ? t('viewer.on') : t('viewer.off')} + @@ -511,9 +531,9 @@ function DisplayMenu() { icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'} width={16} /> - Camera + {t('viewer.camera')} - {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + {cameraMode === 'perspective' ? t('viewer.perspective') : t('viewer.orthographic')} @@ -521,13 +541,13 @@ function DisplayMenu() { {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'} - Units + {t('viewer.units')} {unit === 'imperial' - ? 'Feet & inches' + ? t('viewer.imperial') : metricNotation === 'millimeters' - ? 'Millimeters' - : 'Meters'} + ? t('viewer.millimeters') + : t('viewer.meters')} @@ -535,7 +555,7 @@ function DisplayMenu() { m - Meters + {t('viewer.meters')} {unit === 'metric' && metricNotation === 'meters' ? ( ) : null} @@ -544,7 +564,7 @@ function DisplayMenu() { mm - Millimeters + {t('viewer.millimeters')} {unit === 'metric' && metricNotation === 'millimeters' ? ( ) : null} @@ -553,7 +573,7 @@ function DisplayMenu() { ft - Feet & inches + {t('viewer.imperial')} {unit === 'imperial' ? : null}
@@ -564,8 +584,10 @@ function DisplayMenu() { - Render - {activeShading.name} + {t('viewer.render')} + + {t(activeShading.nameKey)} + {SHADING_OPTIONS.map((option) => { @@ -574,8 +596,8 @@ function DisplayMenu() { setShading(option.id)}>
- {option.name} - {option.detail} + {t(option.nameKey)} + {t(option.detailKey)}
{shading === option.id ? ( @@ -589,15 +611,17 @@ function DisplayMenu() { - Edges - {activeEdges.name} + {t('viewer.edges')} + + {t(activeEdges.nameKey)} + {EDGE_OPTIONS.map((option) => ( setEdges(option.id)}>
- {option.name} - {option.detail} + {t(option.nameKey)} + {t(option.detailKey)}
{edges === option.id ? : null}
@@ -608,7 +632,7 @@ function DisplayMenu() { - Theme + {t('viewer.sceneTheme')} {activeTheme.name} @@ -643,6 +667,7 @@ function DisplayMenu() { } function WalkthroughButton() { + const t = useTranslations() const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode) const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode) const handleClick = useCallback(() => { @@ -656,7 +681,7 @@ function WalkthroughButton() { }, [isFirstPersonMode, setFirstPersonMode]) return ( - + ) diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts index 478f4e559f..2aacd1c5a5 100644 --- a/apps/editor/lib/build-tab-state.ts +++ b/apps/editor/lib/build-tab-state.ts @@ -5,15 +5,25 @@ export type RoofFeatureIdentity = { kind?: string } -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' }, +// `labelKey` is resolved at render time via `t()` from `buildTab.roofType.*`. +// `value` is the stable key (used for `data-value` / state lookups / serialization). +// The footprint-source picker UI that used to live here was removed upstream — +// that choice is now driven by `` reading +// `useRoofFootprintSource` directly. +export type RoofTypeOption = { + value: RoofType + labelKey: string +} + +export const ROOF_TYPE_OPTIONS: readonly RoofTypeOption[] = [ + { value: 'hip', labelKey: 'buildTab.roofType.hip' }, + { value: 'gable', labelKey: 'buildTab.roofType.gable' }, + { value: 'shed', labelKey: 'buildTab.roofType.shed' }, + { value: 'flat', labelKey: 'buildTab.roofType.flat' }, + { value: 'gambrel', labelKey: 'buildTab.roofType.gambrel' }, + { value: 'dutch', labelKey: 'buildTab.roofType.dutch' }, + { value: 'mansard', labelKey: 'buildTab.roofType.mansard' }, + { value: 'conical', labelKey: 'buildTab.roofType.conical' }, ] export function getActiveRoofFeatureId( @@ -22,4 +32,4 @@ export function getActiveRoofFeatureId( ): string | null { if (!activeTool) return null return features.find((feature) => feature.kind === activeTool)?.id ?? null -} +} \ No newline at end of file diff --git a/apps/ifc-converter/app/html-lang-sync.tsx b/apps/ifc-converter/app/html-lang-sync.tsx new file mode 100644 index 0000000000..5d1c145766 --- /dev/null +++ b/apps/ifc-converter/app/html-lang-sync.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useEffect, type ReactNode } from 'react' +import { useLocale } from '@/lib/i18n' + +/** + * Mirrors the active locale on `` so screen readers and search + * engines see the correct language after hydration. + * + * Must live in a client component (the parent layout exports `metadata`, + * which is server-only) and must defer the DOM write to `useEffect` — + * mutating `document` during render would warn under React 19. + */ +export function HtmlLangSync({ children }: { children: ReactNode }) { + const { locale } = useLocale() + useEffect(() => { + document.documentElement.lang = locale + }, [locale]) + return <>{children} +} \ No newline at end of file diff --git a/apps/ifc-converter/app/layout.tsx b/apps/ifc-converter/app/layout.tsx index a817d21ab1..b84c8bd591 100644 --- a/apps/ifc-converter/app/layout.tsx +++ b/apps/ifc-converter/app/layout.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react' import { ClientBootstrap } from './client-bootstrap' +import { HtmlLangSync } from './html-lang-sync' +import { I18nProvider } from '@/lib/i18n' import './globals.css' export const metadata = { @@ -9,9 +11,13 @@ export const metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - + - {children} + + + {children} + + ) diff --git a/apps/ifc-converter/app/page.tsx b/apps/ifc-converter/app/page.tsx index 3de1633a63..527df5a7e4 100644 --- a/apps/ifc-converter/app/page.tsx +++ b/apps/ifc-converter/app/page.tsx @@ -1,31 +1,34 @@ +'use client' + import IfcConverter from '@/components/IfcConverter' +import { useTranslations } from '@/lib/i18n' export default function HomePage() { + const t = useTranslations() return (
-

IFC → Pascal Converter

+

{t('ifcConverter.page.title')}

- Upload an IFC building model or pick one of the bundled examples. The converter reads the - IFC geometry, maps it onto Pascal's parametric node types, and returns a scene-graph JSON - you can load into the editor's Load Build dialog. + {t('ifcConverter.page.subtitle.start')} + {t('ifcConverter.page.subtitle.loadBuild')} + {t('ifcConverter.page.subtitle.end')}

- Early alpha. IFC is a sprawling, loosely-followed - standard and real-world exports vary a lot, so expect rough edges — misplaced or missing - elements, default-height walls, skipped items.{' '} + {t('ifcConverter.page.banner.title')}{' '} + {t('ifcConverter.page.banner.bodyBefore')}{' '} - Contributions welcome + {t('ifcConverter.page.banner.link')} {' '} - — a sample IFC that converts badly, or a PR improving the conversion, both help a lot. + {t('ifcConverter.page.banner.bodyAfter')}
) -} +} \ No newline at end of file diff --git a/apps/ifc-converter/components/IfcConverter.tsx b/apps/ifc-converter/components/IfcConverter.tsx index 1daf37a740..d2efb8a10e 100644 --- a/apps/ifc-converter/components/IfcConverter.tsx +++ b/apps/ifc-converter/components/IfcConverter.tsx @@ -3,7 +3,8 @@ import { convertIfcToPascal, type PascalSceneGraph } from '@pascal-app/ifc-converter' import dynamic from 'next/dynamic' import { useCallback, useEffect, useMemo, useState } from 'react' -import { availableTestFiles, exampleFileUrl, testFiles } from '@/lib/test-files' +import { availableTestFiles, exampleFileUrl, testFiles, type TestFile } from '@/lib/test-files' +import { useTranslations } from '@/lib/i18n' // The viewer uses three's WebGPU renderer + the registry-driven scene // store, neither of which run during SSR — dynamic-import with ssr:false @@ -31,7 +32,17 @@ function meta(node: { metadata?: unknown } | null | undefined): ConverterMetadat return (node?.metadata ?? {}) as ConverterMetadata } +interface SearchResult { + id: string + name: string + type: string + /** Dictionary key + params used to render the match description via t(). */ + matchKey: string + matchParams: Record +} + export default function IfcConverter() { + const t = useTranslations() const [pascalData, setPascalData] = useState(null) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) @@ -82,33 +93,53 @@ export default function IfcConverter() { } }, [elementTypes]) - const searchResults = useMemo(() => { + const searchResults = useMemo(() => { if (!pascalData || !searchQuery.trim()) return [] const q = searchQuery.toLowerCase() - const results: { id: string; name: string; type: string; match: string }[] = [] + const results: SearchResult[] = [] for (const node of Object.values(pascalData.nodes)) { if (['site', 'building', 'level'].includes(node.type)) continue const m = meta(node) - let match: string | null = null - if (node.name?.toLowerCase().includes(q)) match = `Name: ${node.name}` - else if (node.type.includes(q)) match = `Type: ${node.type}` - else if (m.ifcType?.toLowerCase().includes(q)) match = `IFC: ${m.ifcType}` - else if (m.typeName?.toLowerCase().includes(q)) match = `Type: ${m.typeName}` - else if (m.material?.toLowerCase().includes(q)) match = `Material: ${m.material}` - else if (m.globalId?.toLowerCase().includes(q)) match = `ID: ${m.globalId}` - else if (m.properties) { + let matchKey: string | null = null + let matchParams: Record = {} + if (node.name?.toLowerCase().includes(q)) { + matchKey = 'ifcConverter.search.matchName' + matchParams = { value: node.name } + } else if (node.type.includes(q)) { + matchKey = 'ifcConverter.search.matchType' + matchParams = { value: node.type } + } else if (m.ifcType?.toLowerCase().includes(q)) { + matchKey = 'ifcConverter.search.matchIfc' + matchParams = { value: m.ifcType } + } else if (m.typeName?.toLowerCase().includes(q)) { + matchKey = 'ifcConverter.search.matchType' + matchParams = { value: m.typeName } + } else if (m.material?.toLowerCase().includes(q)) { + matchKey = 'ifcConverter.search.matchMaterial' + matchParams = { value: m.material } + } else if (m.globalId?.toLowerCase().includes(q)) { + matchKey = 'ifcConverter.search.matchId' + matchParams = { value: m.globalId } + } else if (m.properties) { for (const [psetName, props] of Object.entries(m.properties) as [string, any][]) { for (const [k, v] of Object.entries(props)) { if (k.toLowerCase().includes(q) || String(v).toLowerCase().includes(q)) { - match = `${psetName}: ${k} = ${v}` + matchKey = 'ifcConverter.search.matchProperty' + matchParams = { pset: psetName, key: k, value: String(v) } break } } - if (match) break + if (matchKey) break } } - if (match) { - results.push({ id: node.id, name: node.name ?? node.id, type: node.type, match }) + if (matchKey) { + results.push({ + id: node.id, + name: node.name ?? node.id, + type: node.type, + matchKey, + matchParams, + }) if (results.length >= 50) break } } @@ -133,7 +164,7 @@ export default function IfcConverter() { setSearchQuery('') setSelectedNodeId(null) setConversionProgress(0) - setConversionMessage('Starting conversion...') + setConversionMessage(t('ifcConverter.status.starting')) try { const result = await convertIfcToPascal(data, (message, percent) => { @@ -143,9 +174,9 @@ export default function IfcConverter() { setPascalData(result) setStatus('ready') setConversionProgress(100) - setConversionMessage('Conversion complete!') + setConversionMessage(t('ifcConverter.status.complete')) } catch (err) { - setError(err instanceof Error ? err.message : 'Conversion failed') + setError(err instanceof Error ? err.message : t('ifcConverter.errors.conversionFailed')) setStatus('error') setConversionProgress(0) } @@ -167,13 +198,17 @@ export default function IfcConverter() { const file = testFiles.find((f) => f.name === filename) const url = file ? exampleFileUrl(file) : `/test-ifc-files/${filename}` const response = await fetch(url) - if (!response.ok) throw new Error(`Could not load ${filename} (${response.status})`) + if (!response.ok) { + throw new Error( + t('ifcConverter.errors.couldNotLoad', { filename, status: response.status }), + ) + } const arrayBuffer = await response.arrayBuffer() const uint8Array = new Uint8Array(arrayBuffer) setIfcData(uint8Array) await loadAndConvert(uint8Array, filename) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load file') + setError(err instanceof Error ? err.message : t('ifcConverter.errors.failedToLoad')) setStatus('error') } } @@ -197,22 +232,26 @@ export default function IfcConverter() { setIfcData(uint8Array) await loadAndConvert(uint8Array, file.name) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load file') + setError(err instanceof Error ? err.message : t('ifcConverter.errors.failedToLoad')) setStatus('error') } } - // biome-ignore lint/correctness/useExhaustiveDependencies: stable drop handler; handleFile only calls setState setters, so a mount-time capture stays correct. - const handleDrop = useCallback((e: React.DragEvent) => { + // No `useCallback`: handleFile closes over `t` and the render-scoped load + // helpers, both of which must reflect the active locale. When `I18nProvider` + // swaps en → zh after mount, the drop handler re-binds on the next render + // and picks up the new translator. Stable identity doesn't matter here — + // there are no memoized children consuming `handleDrop`. + const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) const file = e.dataTransfer.files[0] if (file?.name.toLowerCase().endsWith('.ifc')) { handleFile(file) } else { - setError('Please drop a valid IFC file') + setError(t('ifcConverter.errors.invalidFile')) } - }, []) + } const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() @@ -262,9 +301,9 @@ export default function IfcConverter() { return (
-
-

Try It

-

Upload an IFC file or pick an example below

+
+

{t('ifcConverter.section.tryIt')}

+

{t('ifcConverter.section.tryItSub')}

{/* Upload — compact */} @@ -294,8 +333,8 @@ export default function IfcConverter() { /> - Drop an IFC file here or{' '} - browse to upload + {t('ifcConverter.dropzone.prompt')}{' '} + {t('ifcConverter.dropzone.browse')}
@@ -303,10 +342,10 @@ export default function IfcConverter() { {/* Example IFC files — 2 rows x 5 cards */}

- Or pick an example + {t('ifcConverter.section.examples')}

- {availableTestFiles().map((file) => ( + {availableTestFiles().map((file: TestFile) => ( @@ -340,7 +379,7 @@ export default function IfcConverter() { {/* Error */} {status === 'error' && error && (
- Error: {error} + {t('ifcConverter.action.errorPrefix')} {error}
)} @@ -354,10 +393,14 @@ export default function IfcConverter() {

{fileName}

- {Object.keys(pascalData.nodes).length} nodes + {t('ifcConverter.counts.totalNodes', { + count: Object.keys(pascalData.nodes).length, + })} - {new Set(Object.values(pascalData.nodes).map((n) => n.type)).size} types + {t('ifcConverter.counts.totalTypes', { + count: new Set(Object.values(pascalData.nodes).map((n) => n.type)).size, + })}
@@ -366,13 +409,13 @@ export default function IfcConverter() { onClick={downloadIfc} className="px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-colors" > - Download IFC + {t('ifcConverter.action.downloadIfc')}
@@ -381,23 +424,26 @@ export default function IfcConverter() { {elementTypes.length > 1 && (
- Types + {t('ifcConverter.section.types')} - {elementTypes.map((t) => { - const active = visibleTypes.has(t) + {elementTypes.map((typeKey) => { + const active = visibleTypes.has(typeKey) + const count = typeCounts[typeKey] ?? 0 + const countKey = + count === 1 ? 'ifcConverter.counts.types.one' : 'ifcConverter.counts.types.other' return ( ) })} @@ -418,19 +463,19 @@ export default function IfcConverter() { {levels.length > 1 && (
- Levels + {t('ifcConverter.section.levels')} {levels.map((level) => { const active = visibleLevels.has(level.id) @@ -460,7 +505,7 @@ export default function IfcConverter() {
{ setSearchQuery(e.target.value) @@ -484,7 +529,9 @@ export default function IfcConverter() { {searchOpen && searchQuery.trim() && (
{searchResults.length === 0 ? ( -
No results
+
+ {t('ifcConverter.search.noResults')} +
) : ( searchResults.map((r) => (
-

{r.match}

+

+ {t(r.matchKey, r.matchParams)} +

)) )} {searchResults.length >= 50 && (
- Showing first 50 results + {t('ifcConverter.search.showingFirst50')}
)}
@@ -526,7 +575,9 @@ export default function IfcConverter() {

- {status === 'loading' ? 'Loading file...' : 'Converting to Pascal'} + {status === 'loading' + ? t('ifcConverter.status.loading') + : t('ifcConverter.status.converting')}

{status === 'converting' && (
@@ -548,9 +599,7 @@ export default function IfcConverter() { )} {!pascalData &&
} -

- Orbit (left click) / Pan (right click) / Zoom (scroll) / Click element to inspect -

+

{t('ifcConverter.viewerHint')}

{selectedNodeId && Boolean( @@ -558,7 +607,7 @@ export default function IfcConverter() { ) && (() => { const node = (pascalData!.nodes as Record)[selectedNodeId] as any - const meta = node.metadata ?? {} + const nodeMeta = node.metadata ?? {} const Row = ({ k, v }: { k: string; v: string }) => (
{k} @@ -583,17 +632,26 @@ export default function IfcConverter() {
- - {meta.typeName && } - {meta.ifcType && } - {meta.globalId && } - {meta.expressID != null && ( - + + {nodeMeta.typeName && ( + )} - {meta.levelId && ( + {nodeMeta.ifcType && ( + + )} + {nodeMeta.globalId && ( + + )} + {nodeMeta.expressID != null && ( + + )} + {nodeMeta.levelId && ( )}
@@ -606,72 +664,105 @@ export default function IfcConverter() { node.polygon) && (

- Geometry + {t('ifcConverter.inspector.geometry')}

{node.start && ( v.toFixed(2)).join(', ')}]`} /> )} {node.end && ( v.toFixed(2)).join(', ')}]`} /> )} {node.thickness != null && ( - + )} {node.height != null && ( - + + )} + {node.width != null && ( + )} - {node.width != null && } {node.position != null && node.type !== 'wall' && ( v.toFixed(2)).join(', ')}]`} /> )} {node.elevation != null && ( - + )} {node.sillHeight != null && ( - + + )} + {node.polygon && ( + )} - {node.polygon && }
)} - {(meta.material || meta.materialLayers) && ( + {(nodeMeta.material || nodeMeta.materialLayers) && (

- Material + {t('ifcConverter.inspector.material')}

- {meta.material && } - {meta.materialLayers?.map((l: any, i: number) => ( + {nodeMeta.material && ( + + )} + {nodeMeta.materialLayers?.map((l: any, i: number) => ( ))}
)} - {meta.properties && - Object.entries(meta.properties).map(([psetName, props]: [string, any]) => ( -
-

- {psetName} -

- {Object.entries(props).map(([k, v]: [string, any]) => ( - - ))} -
- ))} + {nodeMeta.properties && + Object.entries(nodeMeta.properties).map( + ([psetName, props]: [string, any]) => ( +
+

+ {psetName} +

+ {Object.entries(props).map(([k, v]: [string, any]) => ( + + ))} +
+ ), + )}
) @@ -684,12 +775,14 @@ export default function IfcConverter() { {status === 'ready' && pascalData && showJson && (
-

Pascal JSON

+

+ {t('ifcConverter.section.jsonTitle')} +

@@ -119,15 +119,16 @@ export function PreviewToolbar() { } export function FitSceneButton({ onFit }: { onFit: () => void }) { + const t = useTranslations() return ( ) } @@ -140,6 +141,7 @@ export function FitSceneButton({ onFit }: { onFit: () => void }) { * has 0 or 1 levels — no point picking from a list of one. */ export function LevelSelector() { + const t = useTranslations() const nodes = useScene((s) => s.nodes) const selection = useViewer((s) => s.selection) const setSelection = useViewer((s) => s.setSelection) @@ -173,11 +175,13 @@ export function LevelSelector() { onClick={() => setSelection({ levelId: level.id })} type="button" > - {level.name?.trim() || `Level ${level.level}`} + + {level.name?.trim() || t('ifcConverter.fallback.level', { index: level.level })} + L{level.level} ) })}
) -} +} \ No newline at end of file diff --git a/apps/ifc-converter/lib/i18n.tsx b/apps/ifc-converter/lib/i18n.tsx new file mode 100644 index 0000000000..764e71fa60 --- /dev/null +++ b/apps/ifc-converter/lib/i18n.tsx @@ -0,0 +1,85 @@ +'use client' + +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' +import { IntlProvider } from 'react-intl' +import en from './i18n/en.json' +import zh from './i18n/zh.json' + +export type Locale = 'en' | 'zh' + +function detectBrowserLocale(): Locale { + // Browser locale decides — there's no in-app switcher. Falls through + // to 'en' on any non-zh navigator.language (e.g. en-US, ja-JP). + if (typeof navigator === 'undefined') return 'en' + const lang = navigator.language.toLowerCase() + return lang.startsWith('zh') ? 'zh' : 'en' +} + +/** + * Default used during SSR and before client-side detection runs. Always 'en' + * so the server-rendered HTML and the first client render agree (no hydration + * mismatch); `I18nProvider` re-reads `navigator.language` in `useEffect` and + * flips to `zh` if the browser is Chinese-locale. + */ +export const defaultLocale: Locale = 'en' + +const messages: Record> = { en, zh } + +interface I18nContextType { + locale: Locale + setLocale: (locale: Locale) => void +} + +const I18nContext = createContext({ + locale: defaultLocale, + setLocale: () => {}, +}) + +export function useLocale() { + return useContext(I18nContext) +} + +export function useTranslations() { + const { locale } = useContext(I18nContext) + return useMemo( + () => + (key: string, params?: Record): string => { + const str = messages[locale][key] ?? key + if (!params) return str + return Object.entries(params).reduce( + (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)), + str, + ) + }, + [locale], + ) +} + +export type Translator = ( + key: string, + params?: Record, +) => string + +export function I18nProvider({ children }: { children: ReactNode }) { + // Seed with the SSR-safe default; swap to the browser locale after mount + // so the initial client render matches the server HTML byte-for-byte. + const [locale, setLocale] = useState(defaultLocale) + + useEffect(() => { + const detected = detectBrowserLocale() + if (detected !== locale) setLocale(detected) + // We only want this to run once on mount; locale changes are driven by + // setLocale below, not by re-detecting. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + + {children} + + + ) +} + +export { messages } diff --git a/apps/ifc-converter/lib/i18n/en.json b/apps/ifc-converter/lib/i18n/en.json new file mode 100644 index 0000000000..098f975588 --- /dev/null +++ b/apps/ifc-converter/lib/i18n/en.json @@ -0,0 +1,123 @@ +{ + "ifcConverter.metaTitle": "IFC → Pascal Converter", + "ifcConverter.metaDescription": "Convert IFC building models into Pascal scene-graph JSON.", + + "ifcConverter.editor.grid": "Grid", + "ifcConverter.editor.fitScene": "Fit scene", + "ifcConverter.editor.copyToClipboard": "Copy to clipboard", + "ifcConverter.editor.showJsonPreview": "Show JSON preview", + + "ifcConverter.page.title": "IFC → Pascal Converter", + "ifcConverter.page.subtitle.start": "Upload an IFC building model or pick one of the bundled examples. The converter reads the IFC geometry, maps it onto Pascal's parametric node types, and returns a scene-graph JSON you can load into the editor's ", + "ifcConverter.page.subtitle.loadBuild": "Load Build", + "ifcConverter.page.subtitle.end": " dialog.", + "ifcConverter.page.banner.title": "Early alpha.", + "ifcConverter.page.banner.bodyBefore": "IFC is a sprawling, loosely-followed standard and real-world exports vary a lot, so expect rough edges — misplaced or missing elements, default-height walls, skipped items.", + "ifcConverter.page.banner.link": "Contributions welcome", + "ifcConverter.page.banner.bodyAfter": "— a sample IFC that converts badly, or a PR improving the conversion, both help a lot.", + + "ifcConverter.dropzone.prompt": "Drop an IFC file here or", + "ifcConverter.dropzone.browse": "browse to upload", + + "ifcConverter.section.tryIt": "Try It", + "ifcConverter.section.tryItSub": "Upload an IFC file or pick an example below", + "ifcConverter.section.examples": "Or pick an example", + "ifcConverter.section.types": "Types", + "ifcConverter.section.levels": "Levels", + "ifcConverter.section.jsonTitle": "Pascal JSON", + + "ifcConverter.action.downloadIfc": "Download IFC", + "ifcConverter.action.downloadJson": "Download Pascal JSON", + "ifcConverter.action.errorPrefix": "Error:", + + "ifcConverter.filter.all": "All", + "ifcConverter.filter.none": "None", + + "ifcConverter.search.placeholder": "Search elements by name, type, material, property...", + "ifcConverter.search.noResults": "No results", + "ifcConverter.search.showingFirst50": "Showing first 50 results", + "ifcConverter.search.matchName": "Name: {value}", + "ifcConverter.search.matchType": "Type: {value}", + "ifcConverter.search.matchIfc": "IFC: {value}", + "ifcConverter.search.matchMaterial": "Material: {value}", + "ifcConverter.search.matchId": "ID: {value}", + "ifcConverter.search.matchProperty": "{pset}: {key} = {value}", + + "ifcConverter.status.starting": "Starting conversion...", + "ifcConverter.status.complete": "Conversion complete!", + "ifcConverter.status.loading": "Loading file...", + "ifcConverter.status.converting": "Converting to Pascal", + + "ifcConverter.viewerHint": "Orbit (left click) / Pan (right click) / Zoom (scroll) / Click element to inspect", + + "ifcConverter.inspector.type": "Type", + "ifcConverter.inspector.typeName": "Type Name", + "ifcConverter.inspector.ifcType": "IFC Type", + "ifcConverter.inspector.globalId": "Global ID", + "ifcConverter.inspector.expressId": "Express ID", + "ifcConverter.inspector.level": "Level", + "ifcConverter.inspector.geometry": "Geometry", + "ifcConverter.inspector.start": "Start", + "ifcConverter.inspector.end": "End", + "ifcConverter.inspector.thickness": "Thickness", + "ifcConverter.inspector.height": "Height", + "ifcConverter.inspector.width": "Width", + "ifcConverter.inspector.position": "Position", + "ifcConverter.inspector.elevation": "Elevation", + "ifcConverter.inspector.sillHeight": "Sill Height", + "ifcConverter.inspector.polygon": "Polygon", + "ifcConverter.inspector.material": "Material", + "ifcConverter.inspector.name": "Name", + + "ifcConverter.units.m": "m", + "ifcConverter.units.mm": "mm", + + "ifcConverter.toolbar.perspective": "Perspective", + "ifcConverter.toolbar.orthographic": "Orthographic", + "ifcConverter.toolbar.levelsLabel": "Levels: {mode}", + "ifcConverter.toolbar.wallsLabel": "Walls: {mode}", + "ifcConverter.toolbar.levelMode.stacked": "Stack", + "ifcConverter.toolbar.levelMode.solo": "Solo", + "ifcConverter.toolbar.levelMode.exploded": "Exploded", + "ifcConverter.toolbar.levelMode.manual": "Manual", + "ifcConverter.toolbar.wallMode.up": "Full", + "ifcConverter.toolbar.wallMode.cutaway": "Cutaway", + "ifcConverter.toolbar.wallMode.down": "Down", + "ifcConverter.toolbar.wallMode.translucent": "Translucent", + + "ifcConverter.errors.invalidFile": "Please drop a valid IFC file", + "ifcConverter.errors.conversionFailed": "Conversion failed", + "ifcConverter.errors.failedToLoad": "Failed to load file", + "ifcConverter.errors.couldNotLoad": "Could not load {filename} ({status})", + + "ifcConverter.counts.totalNodes": "{count} nodes", + "ifcConverter.counts.totalTypes": "{count} types", + "ifcConverter.counts.types.one": "{count} {type}", + "ifcConverter.counts.types.other": "{count} {type}s", + "ifcConverter.counts.polygonPoints": "{count} points", + + "ifcConverter.fallback.level": "Level {index}", + + "ifcConverter.examples.heavy": "Very large — may slow down or crash the browser when rendered.", + + "ifcConverter.examples.01.label": "Duplex Apartment", + "ifcConverter.examples.01.description": "Multi-level apartment from IFC Tools Project", + "ifcConverter.examples.02.label": "Schependomlaan", + "ifcConverter.examples.02.description": "Dutch apartment complex (buildingSMART)", + "ifcConverter.examples.03.label": "RAC Sample Project", + "ifcConverter.examples.03.description": "Revit commercial office building", + "ifcConverter.examples.04.label": "IFC Open House", + "ifcConverter.examples.04.description": "Small residential house (IFC4)", + "ifcConverter.examples.05.label": "Paris Building", + "ifcConverter.examples.05.description": "19 rue Marc Antoine Petit, Paris", + "ifcConverter.examples.06.label": "Sample Castle", + "ifcConverter.examples.06.description": "Historic architecture demo model", + "ifcConverter.examples.07.label": "Revit Architectural", + "ifcConverter.examples.07.description": "Autodesk Revit Architecture model", + "ifcConverter.examples.08.label": "Revit MEP", + "ifcConverter.examples.08.description": "Building systems from Revit MEP", + "ifcConverter.examples.09.label": "Revit Structural", + "ifcConverter.examples.09.description": "Structural engineering from Revit", + "ifcConverter.examples.10.label": "Sample House", + "ifcConverter.examples.10.description": "Complete residential house model" +} \ No newline at end of file diff --git a/apps/ifc-converter/lib/i18n/zh.json b/apps/ifc-converter/lib/i18n/zh.json new file mode 100644 index 0000000000..943ee32ade --- /dev/null +++ b/apps/ifc-converter/lib/i18n/zh.json @@ -0,0 +1,123 @@ +{ + "ifcConverter.metaTitle": "IFC → Pascal 转换器", + "ifcConverter.metaDescription": "把 IFC 建筑模型转换成 Pascal 场景图 JSON。", + + "ifcConverter.editor.grid": "网格", + "ifcConverter.editor.fitScene": "适配场景", + "ifcConverter.editor.copyToClipboard": "复制到剪贴板", + "ifcConverter.editor.showJsonPreview": "显示 JSON 预览", + + "ifcConverter.page.title": "IFC → Pascal 转换器", + "ifcConverter.page.subtitle.start": "上传一个 IFC 建筑模型,或从下方选一个示例。转换器读取 IFC 几何数据,按 Pascal 参数化节点类型映射,导出一份场景图 JSON,", + "ifcConverter.page.subtitle.loadBuild": "加载构建", + "ifcConverter.page.subtitle.end": "对话框即可在编辑器里打开。", + "ifcConverter.page.banner.title": "早期 alpha。", + "ifcConverter.page.banner.bodyBefore": "IFC 是一个庞大且松散的标准,真实世界导出的差异很大,因此会有不少粗糙的地方——元素错位或缺失、墙体高度取默认值、部分图元被跳过。", + "ifcConverter.page.banner.link": "欢迎贡献", + "ifcConverter.page.banner.bodyAfter": "——一份转换效果差的 IFC 样例,或一个改进转换的 PR,都大有帮助。", + + "ifcConverter.dropzone.prompt": "拖入 IFC 文件到此处,或", + "ifcConverter.dropzone.browse": "浏览上传", + + "ifcConverter.section.tryIt": "试一试", + "ifcConverter.section.tryItSub": "上传 IFC 文件,或从下方选一个示例", + "ifcConverter.section.examples": "或选一个示例", + "ifcConverter.section.types": "类型", + "ifcConverter.section.levels": "楼层", + "ifcConverter.section.jsonTitle": "Pascal JSON", + + "ifcConverter.action.downloadIfc": "下载 IFC", + "ifcConverter.action.downloadJson": "下载 Pascal JSON", + "ifcConverter.action.errorPrefix": "错误:", + + "ifcConverter.filter.all": "全部", + "ifcConverter.filter.none": "无", + + "ifcConverter.search.placeholder": "按名称、类型、材质、属性搜索图元...", + "ifcConverter.search.noResults": "无匹配结果", + "ifcConverter.search.showingFirst50": "仅显示前 50 条结果", + "ifcConverter.search.matchName": "名称:{value}", + "ifcConverter.search.matchType": "类型:{value}", + "ifcConverter.search.matchIfc": "IFC:{value}", + "ifcConverter.search.matchMaterial": "材质:{value}", + "ifcConverter.search.matchId": "ID:{value}", + "ifcConverter.search.matchProperty": "{pset}:{key} = {value}", + + "ifcConverter.status.starting": "开始转换...", + "ifcConverter.status.complete": "转换完成!", + "ifcConverter.status.loading": "加载文件中...", + "ifcConverter.status.converting": "正在转换为 Pascal", + + "ifcConverter.viewerHint": "轨道(左键)/ 平移(右键)/ 缩放(滚轮)/ 点击图元查看详情", + + "ifcConverter.inspector.type": "类型", + "ifcConverter.inspector.typeName": "类型名称", + "ifcConverter.inspector.ifcType": "IFC 类型", + "ifcConverter.inspector.globalId": "全局 ID", + "ifcConverter.inspector.expressId": "Express ID", + "ifcConverter.inspector.level": "楼层", + "ifcConverter.inspector.geometry": "几何", + "ifcConverter.inspector.start": "起点", + "ifcConverter.inspector.end": "终点", + "ifcConverter.inspector.thickness": "厚度", + "ifcConverter.inspector.height": "高度", + "ifcConverter.inspector.width": "宽度", + "ifcConverter.inspector.position": "位置", + "ifcConverter.inspector.elevation": "标高", + "ifcConverter.inspector.sillHeight": "窗台高度", + "ifcConverter.inspector.polygon": "多边形", + "ifcConverter.inspector.material": "材质", + "ifcConverter.inspector.name": "名称", + + "ifcConverter.units.m": "米", + "ifcConverter.units.mm": "毫米", + + "ifcConverter.toolbar.perspective": "透视", + "ifcConverter.toolbar.orthographic": "正交", + "ifcConverter.toolbar.levelsLabel": "楼层:{mode}", + "ifcConverter.toolbar.wallsLabel": "墙体:{mode}", + "ifcConverter.toolbar.levelMode.stacked": "堆叠", + "ifcConverter.toolbar.levelMode.solo": "单独", + "ifcConverter.toolbar.levelMode.exploded": "爆炸", + "ifcConverter.toolbar.levelMode.manual": "手动", + "ifcConverter.toolbar.wallMode.up": "完整", + "ifcConverter.toolbar.wallMode.cutaway": "剖切", + "ifcConverter.toolbar.wallMode.down": "底部", + "ifcConverter.toolbar.wallMode.translucent": "半透", + + "ifcConverter.errors.invalidFile": "请拖入有效的 IFC 文件", + "ifcConverter.errors.conversionFailed": "转换失败", + "ifcConverter.errors.failedToLoad": "加载文件失败", + "ifcConverter.errors.couldNotLoad": "无法加载 {filename}({status})", + + "ifcConverter.counts.totalNodes": "{count} 个节点", + "ifcConverter.counts.totalTypes": "{count} 种类型", + "ifcConverter.counts.types.one": "{count} 个 {type}", + "ifcConverter.counts.types.other": "{count} 个 {type}", + "ifcConverter.counts.polygonPoints": "{count} 个点", + + "ifcConverter.fallback.level": "第 {index} 层", + + "ifcConverter.examples.heavy": "文件很大——渲染时可能导致浏览器卡顿或崩溃。", + + "ifcConverter.examples.01.label": "复式公寓", + "ifcConverter.examples.01.description": "IFC Tools Project 多层公寓", + "ifcConverter.examples.02.label": "Schependomlaan", + "ifcConverter.examples.02.description": "荷兰公寓楼(buildingSMART)", + "ifcConverter.examples.03.label": "RAC 样例项目", + "ifcConverter.examples.03.description": "Revit 商业办公楼", + "ifcConverter.examples.04.label": "IFC 开放住宅", + "ifcConverter.examples.04.description": "小型住宅(IFC4)", + "ifcConverter.examples.05.label": "巴黎建筑", + "ifcConverter.examples.05.description": "巴黎 19 rue Marc Antoine Petit", + "ifcConverter.examples.06.label": "样例城堡", + "ifcConverter.examples.06.description": "历史建筑演示模型", + "ifcConverter.examples.07.label": "Revit 建筑", + "ifcConverter.examples.07.description": "Autodesk Revit 建筑模型", + "ifcConverter.examples.08.label": "Revit 机电", + "ifcConverter.examples.08.description": "Revit MEP 机电系统", + "ifcConverter.examples.09.label": "Revit 结构", + "ifcConverter.examples.09.description": "Revit 结构工程", + "ifcConverter.examples.10.label": "样例住宅", + "ifcConverter.examples.10.description": "完整住宅模型" +} \ No newline at end of file diff --git a/apps/ifc-converter/lib/test-files.ts b/apps/ifc-converter/lib/test-files.ts index 610d14944c..947b39323e 100644 --- a/apps/ifc-converter/lib/test-files.ts +++ b/apps/ifc-converter/lib/test-files.ts @@ -1,8 +1,11 @@ export interface TestFile { name: string - label: string + /** Dictionary key — resolved by the UI via useTranslations(). */ + labelKey: string + /** File size as a raw number + unit (kept as-is; "MB" stays untranslated). */ detail: string - description: string + /** Dictionary key — resolved by the UI via useTranslations(). */ + descriptionKey: string /** * Served from `examplesBaseUrl` instead of the repo's `public/` folder. * The large IFC samples (tens of MB each) aren't committed to keep the @@ -10,9 +13,9 @@ export interface TestFile { * runtime. Marked entries only appear once a base URL is configured. */ remote?: boolean - /** Shown as a caution on the example card (e.g. heavy models that can - * tax the browser when rendered). */ - warning?: string + /** Dictionary key for a caution shown on the example card (e.g. + * heavy models that can tax the browser when rendered). */ + warningKey?: string } // Host serving the large (remote) example IFCs by filename. The big @@ -30,71 +33,71 @@ export const examplesBaseUrl = ( export const testFiles: TestFile[] = [ { name: '01-duplex.ifc', - label: 'Duplex Apartment', + labelKey: 'ifcConverter.examples.01.label', detail: '1.2 MB', - description: 'Multi-level apartment from IFC Tools Project', + descriptionKey: 'ifcConverter.examples.01.description', }, { name: '02-schependomlaan.ifc', - label: 'Schependomlaan', + labelKey: 'ifcConverter.examples.02.label', detail: '47 MB', - description: 'Dutch apartment complex (buildingSMART)', + descriptionKey: 'ifcConverter.examples.02.description', remote: true, - warning: 'Very large — may slow down or crash the browser when rendered.', + warningKey: 'ifcConverter.examples.heavy', }, { name: '03-rac-sample-project.ifc', - label: 'RAC Sample Project', + labelKey: 'ifcConverter.examples.03.label', detail: '43 MB', - description: 'Revit commercial office building', + descriptionKey: 'ifcConverter.examples.03.description', remote: true, }, { name: '04-ifc-open-house.ifc', - label: 'IFC Open House', + labelKey: 'ifcConverter.examples.04.label', detail: '111 KB', - description: 'Small residential house (IFC4)', + descriptionKey: 'ifcConverter.examples.04.description', }, { name: '05-paris-ground-floor.ifc', - label: 'Paris Building', + labelKey: 'ifcConverter.examples.05.label', detail: '3.9 MB', - description: '19 rue Marc Antoine Petit, Paris', + descriptionKey: 'ifcConverter.examples.05.description', }, { name: '06-sample-castle.ifc', - label: 'Sample Castle', + labelKey: 'ifcConverter.examples.06.label', detail: '47 MB', - description: 'Historic architecture demo model', + descriptionKey: 'ifcConverter.examples.06.description', remote: true, - warning: 'Very large — may slow down or crash the browser when rendered.', + warningKey: 'ifcConverter.examples.heavy', }, { name: '07-revit-architectural.ifc', - label: 'Revit Architectural', + labelKey: 'ifcConverter.examples.07.label', detail: '13 MB', - description: 'Autodesk Revit Architecture model', + descriptionKey: 'ifcConverter.examples.07.description', remote: true, }, { name: '08-revit-mep.ifc', - label: 'Revit MEP', + labelKey: 'ifcConverter.examples.08.label', detail: '28 MB', - description: 'Building systems from Revit MEP', + descriptionKey: 'ifcConverter.examples.08.description', remote: true, }, { name: '09-revit-structural.ifc', - label: 'Revit Structural', + labelKey: 'ifcConverter.examples.09.label', detail: '11 MB', - description: 'Structural engineering from Revit', + descriptionKey: 'ifcConverter.examples.09.description', remote: true, }, { name: '10-sample-house.ifc', - label: 'Sample House', + labelKey: 'ifcConverter.examples.10.label', detail: '2.2 MB', - description: 'Complete residential house model', + descriptionKey: 'ifcConverter.examples.10.description', }, ] @@ -111,4 +114,4 @@ export function exampleFileUrl(file: TestFile): string { export function availableTestFiles(): TestFile[] { if (examplesBaseUrl) return testFiles return testFiles.filter((f) => !f.remote) -} +} \ No newline at end of file diff --git a/bin/bun.zip b/bin/bun.zip new file mode 100644 index 0000000000..14eff6cc20 Binary files /dev/null and b/bin/bun.zip differ diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index d00717a4f2..fc682fde2b 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,3 +1,5 @@ +'use client' + import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' import type { Object3D } from 'three' diff --git a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts index 014ae23292..f9015eb4f6 100644 --- a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts +++ b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts @@ -1,3 +1,5 @@ +'use client' + import { useCallback } from 'react' import type { CeilingNode, LevelNode, WallNode } from '../../schema' import { spatialGridManager } from './spatial-grid-manager' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6bff78e0b..f22bbb1b2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,7 +101,7 @@ export { isOperationDoorType, SECTIONAL_GARAGE_RENDER_OPEN_SCALE, } from './lib/door-operation' -export { getDefaultLevelName, getLevelDisplayName } from './lib/level-name' +export { getDefaultLevelName, getLevelDisplayName, type Translator } from './lib/level-name' export { areMeasurementPointsCoplanar, closestMeasurementFeatureBinding, diff --git a/packages/core/src/lib/level-name.ts b/packages/core/src/lib/level-name.ts index dd4f904f4d..50cebac20d 100644 --- a/packages/core/src/lib/level-name.ts +++ b/packages/core/src/lib/level-name.ts @@ -1,11 +1,40 @@ import type { LevelNode } from '../schema' -export function getDefaultLevelName(level: number): string { - if (level === 0) return 'Ground Floor' - if (level > 0) return `Floor ${level}` - return `Basement ${-level}` +/** + * Minimal i18n shape needed by the level-name helpers — same pattern as + * `node-display.ts` / `selection-breakdown.ts` in `@pascal-app/editor`. Avoids + * pulling the full i18n runtime into `@pascal-app/core` (which has no React + * dependency) while letting callers pass their `t()` straight through. + */ +export type Translator = (key: string, vars?: Record) => string + +/** + * English fallback used by non-React callers (GLB export, level-print export) + * that don't have a `t` from a React tree handy. Mirrors the `en.json` values + * for the three keys below — keep in sync if those translations change. + */ +const fallbackTranslator: Translator = (key, vars) => { + switch (key) { + case 'level.groundFloor': + return 'Ground Floor' + case 'level.floor': + return `Floor ${vars?.n ?? ''}`.trim() + case 'level.basement': + return `Basement ${vars?.n ?? ''}`.trim() + default: + return key + } } -export function getLevelDisplayName(level: Pick): string { - return level.name || getDefaultLevelName(level.level) +export function getDefaultLevelName(level: number, t: Translator = fallbackTranslator): string { + if (level === 0) return t('level.groundFloor') + if (level > 0) return t('level.floor', { n: level }) + return t('level.basement', { n: -level }) } + +export function getLevelDisplayName( + level: Pick, + t: Translator = fallbackTranslator, +): string { + return level.name || getDefaultLevelName(level.level, t) +} \ No newline at end of file diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 55cbb17845..8ce64a01a3 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -8,7 +8,13 @@ export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace' export type MaterialCatalogItem = { id: string - label: string + /** + * i18n key resolved by the host's translator at render time (e.g. + * `materials.wood-finewood27.label`). The catalog ships with keys rather + * than literal text so the same data is reusable across locales without + * recompiling @pascal-app/core. + */ + labelKey: string category: MaterialCategory /** Origin of the entry. Absent = 'pascal' (all static catalog entries). */ source?: MaterialSource @@ -17,7 +23,6 @@ export type MaterialCatalogItem = { * The paint picker may filter by the slot being painted; v1 shows everything. */ surfaces?: MaterialSurface[] - description?: string previewThumbnailUrl?: string previewColor?: string preset: MaterialPresetPayload @@ -93,10 +98,9 @@ export type MaterialSurface = (typeof MATERIAL_SURFACES)[number] export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wood-finewood27', - label: 'Finewood 27', + labelKey: 'materials.wood-finewood27.label', category: 'wood', surfaces: ['floor', 'wall', 'furniture'], - description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/finewood_27/finewood_27_thumb.webp', preset: { maps: { @@ -104,7 +108,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ aoMap: '/material/wood/finewood_27/finewood_27_ao_512.ktx2', normalMap: '/material/wood/finewood_27/finewood_27_normal_512.ktx2', roughnessMap: '/material/wood/finewood_27/finewood_27_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -131,17 +135,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-floorplank1', - label: 'Floor Plank 1', + labelKey: 'materials.wood-floorplank1.label', category: 'wood', surfaces: ['floor'], - description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/floor_plank_1/floor_plank_1_thumb.webp', preset: { maps: { albedoMap: '/material/wood/floor_plank_1/floor_plank_1_basecolor_512.ktx2', aoMap: '/material/wood/floor_plank_1/floor_plank_1_ao_512.ktx2', normalMap: '/material/wood/floor_plank_1/floor_plank_1_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -168,17 +171,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-hungarianparquet10', - label: 'Hungarian Parquet 10', + labelKey: 'materials.wood-hungarianparquet10.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_thumb.webp', preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_basecolor_512.ktx2', normalMap: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_normal_512.ktx2', roughnessMap: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -205,17 +207,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-hungarianparquet2', - label: 'Hungarian Parquet 2', + labelKey: 'materials.wood-hungarianparquet2.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_thumb.webp', preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_basecolor_512.ktx2', normalMap: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_normal_512.ktx2', roughnessMap: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -242,17 +243,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-squareparquet21', - label: 'Square Parquet 21', + labelKey: 'materials.wood-squareparquet21.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/square_parquet_21/square_parquet_21_thumb.webp', preset: { maps: { albedoMap: '/material/wood/square_parquet_21/square_parquet_21_basecolor_512.ktx2', normalMap: '/material/wood/square_parquet_21/square_parquet_21_normal_512.ktx2', roughnessMap: '/material/wood/square_parquet_21/square_parquet_21_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -279,10 +279,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-squareparquet23', - label: 'Square Parquet 23', + labelKey: 'materials.wood-squareparquet23.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/square_wood_parquet_23/square_wood_parquet_23_thumb.webp', preset: { maps: { @@ -291,7 +290,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/wood/square_wood_parquet_23/square_wood_parquet_23_normal_512.ktx2', roughnessMap: '/material/wood/square_wood_parquet_23/square_wood_parquet_23_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -318,17 +317,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodfine1', - label: 'Wood Fine 1', + labelKey: 'materials.wood-woodfine1.label', category: 'wood', surfaces: ['floor', 'wall', 'furniture'], - description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine/wood_fine_thumb.webp', preset: { maps: { albedoMap: '/material/wood/wood_fine/wood_fine_basecolor_512.ktx2', aoMap: '/material/wood/wood_fine/wood_fine_ao_512.ktx2', normalMap: '/material/wood/wood_fine/wood_fine_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -355,17 +353,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodfine11', - label: 'Wood Fine 11', + labelKey: 'materials.wood-woodfine11.label', category: 'wood', surfaces: ['floor', 'wall', 'furniture'], - description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_11/wood_fine_11_thumb.webp', preset: { maps: { albedoMap: '/material/wood/wood_fine_11/wood_fine_11_basecolor_512.ktx2', aoMap: '/material/wood/wood_fine_11/wood_fine_11_ao_512.ktx2', normalMap: '/material/wood/wood_fine_11/wood_fine_11_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -392,17 +389,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodfine13', - label: 'Wood Fine 13', + labelKey: 'materials.wood-woodfine13.label', category: 'wood', surfaces: ['floor', 'wall', 'furniture'], - description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_13/wood_fine_13_thumb.webp', preset: { maps: { albedoMap: '/material/wood/wood_fine_13/wood_fine_13_basecolor_512.ktx2', aoMap: '/material/wood/wood_fine_13/wood_fine_13_ao_512.ktx2', normalMap: '/material/wood/wood_fine_13/wood_fine_13_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -429,17 +425,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodfine2', - label: 'Wood Fine 2', + labelKey: 'materials.wood-woodfine2.label', category: 'wood', surfaces: ['floor', 'wall', 'furniture'], - description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_2/wood_fine_2_thumb.webp', preset: { maps: { albedoMap: '/material/wood/wood_fine_2/wood_fine_2_basecolor_512.ktx2', aoMap: '/material/wood/wood_fine_2/wood_fine_2_ao_512.ktx2', normalMap: '/material/wood/wood_fine_2/wood_fine_2_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -466,17 +461,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodfine22', - label: 'Wood Fine 22', + labelKey: 'materials.wood-woodfine22.label', category: 'wood', surfaces: ['floor', 'wall', 'furniture'], - description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_22/wood_fine_22_thumb.webp', preset: { maps: { albedoMap: '/material/wood/wood_fine_22/wood_fine_22_basecolor_512.ktx2', aoMap: '/material/wood/wood_fine_22/wood_fine_22_ao_512.ktx2', normalMap: '/material/wood/wood_fine_22/wood_fine_22_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -503,17 +497,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodfine24', - label: 'Wood Fine 24', + labelKey: 'materials.wood-woodfine24.label', category: 'wood', surfaces: ['floor', 'wall', 'furniture'], - description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_24/wood_fine_24_thumb.webp', preset: { maps: { albedoMap: '/material/wood/wood_fine_24/wood_fine_24_basecolor_512.ktx2', aoMap: '/material/wood/wood_fine_24/wood_fine_24_ao_512.ktx2', normalMap: '/material/wood/wood_fine_24/wood_fine_24_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -540,10 +533,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodparquet14', - label: 'Wood Parquet 14', + labelKey: 'materials.wood-woodparquet14.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/wood_parquet_14/wood_parquet_14_thumb.webp', preset: { maps: { @@ -552,7 +544,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/wood/wood_parquet_14/wood_parquet_14_metallic_512.ktx2', normalMap: '/material/wood/wood_parquet_14/wood_parquet_14_normal_512.ktx2', roughnessMap: '/material/wood/wood_parquet_14/wood_parquet_14_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -579,17 +571,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodenparquet11', - label: 'Wooden Parquet 11', + labelKey: 'materials.wood-woodenparquet11.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/wooden_parquet_11/wooden_parquet_11_thumb.webp', preset: { maps: { albedoMap: '/material/wood/wooden_parquet_11/wooden_parquet_11_basecolor_512.ktx2', normalMap: '/material/wood/wooden_parquet_11/wooden_parquet_11_normal_512.ktx2', roughnessMap: '/material/wood/wooden_parquet_11/wooden_parquet_11_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -616,10 +607,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodparquet121', - label: 'Wood Parquet 121', + labelKey: 'materials.wood-woodparquet121.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_121/woodparquet_121_thumb.webp', preset: { maps: { @@ -627,7 +617,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ aoMap: '/material/wood/woodparquet_121/woodparquet_121_ao_512.ktx2', normalMap: '/material/wood/woodparquet_121/woodparquet_121_normal_512.ktx2', roughnessMap: '/material/wood/woodparquet_121/woodparquet_121_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -654,10 +644,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodparquet56', - label: 'Wood Parquet 56', + labelKey: 'materials.wood-woodparquet56.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_56/woodparquet_56_thumb.webp', preset: { maps: { @@ -666,7 +655,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/wood/woodparquet_56/woodparquet_56_metallic_512.ktx2', normalMap: '/material/wood/woodparquet_56/woodparquet_56_normal_512.ktx2', roughnessMap: '/material/wood/woodparquet_56/woodparquet_56_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -693,10 +682,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodparquet65', - label: 'Wood Parquet 65', + labelKey: 'materials.wood-woodparquet65.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_65/woodparquet_65_thumb.webp', preset: { maps: { @@ -705,7 +693,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/wood/woodparquet_65/woodparquet_65_metallic_512.ktx2', normalMap: '/material/wood/woodparquet_65/woodparquet_65_normal_512.ktx2', roughnessMap: '/material/wood/woodparquet_65/woodparquet_65_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -732,10 +720,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodparquet99', - label: 'Wood Parquet 99', + labelKey: 'materials.wood-woodparquet99.label', category: 'wood', surfaces: ['floor'], - description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_99/woodparquet_99_thumb.webp', preset: { maps: { @@ -744,7 +731,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/wood/woodparquet_99/woodparquet_99_metallic_512.ktx2', normalMap: '/material/wood/woodparquet_99/woodparquet_99_normal_512.ktx2', roughnessMap: '/material/wood/woodparquet_99/woodparquet_99_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -771,10 +758,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodplank19', - label: 'Wood Plank 19', + labelKey: 'materials.wood-woodplank19.label', category: 'wood', surfaces: ['floor', 'wall'], - description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/woodplank_19/woodplank_19_thumb.webp', preset: { maps: { @@ -782,7 +768,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ aoMap: '/material/wood/woodplank_19/woodplank_19_ao_512.ktx2', normalMap: '/material/wood/woodplank_19/woodplank_19_normal_512.ktx2', roughnessMap: '/material/wood/woodplank_19/woodplank_19_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -809,10 +795,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'wood-woodplank48', - label: 'Wood Plank 48', + labelKey: 'materials.wood-woodplank48.label', category: 'wood', surfaces: ['floor', 'wall'], - description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/woodplank_48/woodplank_48_thumb.webp', preset: { maps: { @@ -820,7 +805,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ aoMap: '/material/wood/woodplank_48/woodplank_48_ao_512.ktx2', normalMap: '/material/wood/woodplank_48/woodplank_48_normal_512.ktx2', roughnessMap: '/material/wood/woodplank_48/woodplank_48_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -847,10 +832,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-tile85a', - label: 'Quarry Tile', + labelKey: 'materials.flooring-tile85a.label', category: 'tile', surfaces: ['floor'], - description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_quarry/tile_quarry_thumb.webp', preset: { maps: { @@ -858,7 +842,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ aoMap: '/material/flooring/tile_quarry/tile_quarry_ao_512.ktx2', normalMap: '/material/flooring/tile_quarry/tile_quarry_normal_512.ktx2', roughnessMap: '/material/flooring/tile_quarry/tile_quarry_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -885,10 +869,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-rusticbrick', - label: 'Rustic Brick', + labelKey: 'materials.flooring-rusticbrick.label', category: 'brick', surfaces: ['wall', 'floor', 'outdoor'], - description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_rustic/brick_wall_rustic_thumb.webp', preset: { maps: { @@ -897,7 +880,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_metallic_512.ktx2', normalMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_normal_512.ktx2', roughnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -924,10 +907,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-agedbrick', - label: 'Aged Brick', + labelKey: 'materials.flooring-agedbrick.label', category: 'brick', surfaces: ['wall', 'floor', 'outdoor'], - description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_aged/brick_wall_aged_thumb.webp', preset: { maps: { @@ -936,7 +918,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_metallic_512.ktx2', normalMap: '/material/flooring/brick_wall_aged/brick_wall_aged_normal_512.ktx2', roughnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -963,10 +945,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-weatheredbrick', - label: 'Weathered Brick', + labelKey: 'materials.flooring-weatheredbrick.label', category: 'brick', surfaces: ['wall', 'floor', 'outdoor'], - description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_weathered/brick_wall_weathered_thumb.webp', preset: { maps: { @@ -976,7 +957,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_normal_512.ktx2', roughnessMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1003,17 +984,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-garagedoor', - label: 'Garage Panel', + labelKey: 'materials.flooring-garagedoor.label', category: 'metal', surfaces: ['wall', 'furniture'], - description: 'Panel finish', previewThumbnailUrl: '/material/flooring/garage_panel/garage_panel_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/garage_panel/garage_panel_basecolor_512.ktx2', aoMap: '/material/flooring/garage_panel/garage_panel_ao_512.ktx2', normalMap: '/material/flooring/garage_panel/garage_panel_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -1040,17 +1020,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-greenlabradorite', - label: 'Green Labradorite', + labelKey: 'materials.flooring-greenlabradorite.label', category: 'stone', surfaces: ['floor', 'wall'], - description: 'Stone flooring finish', previewThumbnailUrl: '/material/flooring/green_labradorite/green_labradorite_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/green_labradorite/green_labradorite_basecolor_512.ktx2', aoMap: '/material/flooring/green_labradorite/green_labradorite_ao_512.ktx2', normalMap: '/material/flooring/green_labradorite/green_labradorite_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.35, @@ -1077,10 +1056,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-ground13', - label: 'Earth Ground', + labelKey: 'materials.flooring-ground13.label', category: 'ground', surfaces: ['outdoor', 'floor'], - description: 'Ground surface finish', previewThumbnailUrl: '/material/flooring/ground_earth/ground_earth_thumb.webp', preset: { maps: { @@ -1089,7 +1067,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/ground_earth/ground_earth_metallic_512.ktx2', normalMap: '/material/flooring/ground_earth/ground_earth_normal_512.ktx2', roughnessMap: '/material/flooring/ground_earth/ground_earth_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1116,17 +1094,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-pooltiles', - label: 'Pool Tiles', + labelKey: 'materials.flooring-pooltiles.label', category: 'tile', surfaces: ['floor', 'outdoor'], - description: 'Pool tile finish', previewThumbnailUrl: '/material/flooring/pool_tiles/pool_tiles_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/pool_tiles/pool_tiles_basecolor_512.ktx2', aoMap: '/material/flooring/pool_tiles/pool_tiles_ao_512.ktx2', normalMap: '/material/flooring/pool_tiles/pool_tiles_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -1153,17 +1130,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-tiles3', - label: 'Checker Tiles', + labelKey: 'materials.flooring-tiles3.label', category: 'tile', surfaces: ['floor'], - description: 'Tile flooring finish', previewThumbnailUrl: '/material/flooring/tiles_checker/tiles_checker_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/tiles_checker/tiles_checker_basecolor_512.ktx2', aoMap: '/material/flooring/tiles_checker/tiles_checker_ao_512.ktx2', normalMap: '/material/flooring/tiles_checker/tiles_checker_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -1190,17 +1166,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-tiles4', - label: 'Grid Tiles', + labelKey: 'materials.flooring-tiles4.label', category: 'tile', surfaces: ['floor'], - description: 'Tile flooring finish', previewThumbnailUrl: '/material/flooring/tiles_grid/tiles_grid_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/tiles_grid/tiles_grid_basecolor_512.ktx2', aoMap: '/material/flooring/tiles_grid/tiles_grid_ao_512.ktx2', normalMap: '/material/flooring/tiles_grid/tiles_grid_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -1227,17 +1202,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-wallstone1', - label: 'Stone Wall', + labelKey: 'materials.flooring-wallstone1.label', category: 'stone', surfaces: ['wall', 'floor', 'outdoor'], - description: 'Stone finish', previewThumbnailUrl: '/material/flooring/stone_wall/stone_wall_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/stone_wall/stone_wall_basecolor_512.ktx2', aoMap: '/material/flooring/stone_wall/stone_wall_ao_512.ktx2', normalMap: '/material/flooring/stone_wall/stone_wall_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -1264,17 +1238,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-woodenceramic3', - label: 'Wooden Ceramic 3', + labelKey: 'materials.flooring-woodenceramic3.label', category: 'tile', surfaces: ['floor'], - description: 'Wood-look ceramic flooring finish', previewThumbnailUrl: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_basecolor_512.ktx2', aoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_ao_512.ktx2', normalMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -1301,10 +1274,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-ceramic53', - label: 'Ceramic Mosaic', + labelKey: 'materials.flooring-ceramic53.label', category: 'tile', surfaces: ['floor', 'wall'], - description: 'Ceramic flooring finish', previewThumbnailUrl: '/material/flooring/ceramic_mosaic/ceramic_mosaic_thumb.webp', preset: { maps: { @@ -1313,7 +1285,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_metallic_512.ktx2', normalMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_normal_512.ktx2', roughnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1340,10 +1312,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-terrazzo19', - label: 'Terrazzo', + labelKey: 'materials.flooring-terrazzo19.label', category: 'stone', surfaces: ['floor', 'wall'], - description: 'Terrazzo flooring finish', previewThumbnailUrl: '/material/flooring/terrazzo/terrazzo_thumb.webp', preset: { maps: { @@ -1351,7 +1322,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/terrazzo/terrazzo_metallic_512.ktx2', normalMap: '/material/flooring/terrazzo/terrazzo_normal_512.ktx2', roughnessMap: '/material/flooring/terrazzo/terrazzo_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1378,10 +1349,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-tile79', - label: 'Stone Tile', + labelKey: 'materials.flooring-tile79.label', category: 'stone', surfaces: ['floor', 'wall'], - description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_stone/tile_stone_thumb.webp', preset: { maps: { @@ -1389,7 +1359,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ aoMap: '/material/flooring/tile_stone/tile_stone_ao_512.ktx2', normalMap: '/material/flooring/tile_stone/tile_stone_normal_512.ktx2', roughnessMap: '/material/flooring/tile_stone/tile_stone_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1416,10 +1386,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-tile86', - label: 'Terracotta Tile', + labelKey: 'materials.flooring-tile86.label', category: 'tile', surfaces: ['floor'], - description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_terracotta/tile_terracotta_thumb.webp', preset: { maps: { @@ -1427,7 +1396,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ aoMap: '/material/flooring/tile_terracotta/tile_terracotta_ao_512.ktx2', normalMap: '/material/flooring/tile_terracotta/tile_terracotta_normal_512.ktx2', roughnessMap: '/material/flooring/tile_terracotta/tile_terracotta_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1454,10 +1423,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-greenquartzitea', - label: 'Green Quartzite A', + labelKey: 'materials.flooring-greenquartzitea.label', category: 'stone', surfaces: ['floor', 'wall'], - description: 'Green quartzite flooring finish', previewThumbnailUrl: '/material/flooring/green_glass_quartzite/green_glass_quartzite_thumb.webp', preset: { @@ -1466,7 +1434,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ '/material/flooring/green_glass_quartzite/green_glass_quartzite_basecolor_512.ktx2', aoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_ao_512.ktx2', normalMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.35, @@ -1493,10 +1461,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-darkceramic22', - label: 'Dark Ceramic Grunge', + labelKey: 'materials.flooring-darkceramic22.label', category: 'tile', surfaces: ['floor'], - description: 'Dark ceramic flooring finish', previewThumbnailUrl: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_thumb.webp', preset: { maps: { @@ -1507,7 +1474,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_normal_512.ktx2', roughnessMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1534,10 +1501,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-lightceramic24', - label: 'Light Ceramic Grunge', + labelKey: 'materials.flooring-lightceramic24.label', category: 'tile', surfaces: ['floor'], - description: 'Light ceramic flooring finish', previewThumbnailUrl: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_thumb.webp', preset: { maps: { @@ -1549,7 +1515,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_normal_512.ktx2', roughnessMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1576,17 +1542,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-statuarettowhite', - label: 'Statuaretto White', + labelKey: 'materials.flooring-statuarettowhite.label', category: 'stone', surfaces: ['floor', 'wall'], - description: 'White marble flooring finish', previewThumbnailUrl: '/material/flooring/statuaretto/statuaretto_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/statuaretto/statuaretto_basecolor_512.ktx2', aoMap: '/material/flooring/statuaretto/statuaretto_ao_512.ktx2', normalMap: '/material/flooring/statuaretto/statuaretto_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.35, @@ -1613,10 +1578,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-tile20', - label: 'Mosaic Tile', + labelKey: 'materials.flooring-tile20.label', category: 'tile', surfaces: ['floor', 'wall'], - description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_mosaic/tile_mosaic_thumb.webp', preset: { maps: { @@ -1625,7 +1589,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/tile_mosaic/tile_mosaic_metallic_512.ktx2', normalMap: '/material/flooring/tile_mosaic/tile_mosaic_normal_512.ktx2', roughnessMap: '/material/flooring/tile_mosaic/tile_mosaic_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1652,10 +1616,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-tile68', - label: 'Pattern Tile', + labelKey: 'materials.flooring-tile68.label', category: 'tile', surfaces: ['floor', 'wall'], - description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_pattern/tile_pattern_thumb.webp', preset: { maps: { @@ -1664,7 +1627,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/tile_pattern/tile_pattern_metallic_512.ktx2', normalMap: '/material/flooring/tile_pattern/tile_pattern_normal_512.ktx2', roughnessMap: '/material/flooring/tile_pattern/tile_pattern_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1691,17 +1654,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-woodenceramic2', - label: 'Wooden Ceramic 2', + labelKey: 'materials.flooring-woodenceramic2.label', category: 'tile', surfaces: ['floor'], - description: 'Wood-look ceramic flooring finish', previewThumbnailUrl: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_thumb.webp', preset: { maps: { albedoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_basecolor_512.ktx2', aoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_ao_512.ktx2', normalMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -1728,10 +1690,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'flooring-woodparquet76', - label: 'Wood Parquet', + labelKey: 'materials.flooring-woodparquet76.label', category: 'wood', surfaces: ['floor'], - description: 'Wood parquet flooring finish', previewThumbnailUrl: '/material/flooring/woodparquet/woodparquet_thumb.webp', preset: { maps: { @@ -1740,7 +1701,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/flooring/woodparquet/woodparquet_metallic_512.ktx2', normalMap: '/material/flooring/woodparquet/woodparquet_normal_512.ktx2', roughnessMap: '/material/flooring/woodparquet/woodparquet_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -1767,10 +1728,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'roof-classicshingles', - label: 'Classic Shingles', + labelKey: 'materials.roof-classicshingles.label', category: 'roofing', surfaces: ['roof'], - description: 'Classic roof shingle finish', previewThumbnailUrl: '/material/roofing/roof_shingles_classic/roof_shingles_classic_thumb.webp', preset: { maps: { @@ -1782,7 +1742,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_normal_512.ktx2', roughnessMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 1, @@ -1809,10 +1769,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'roof-claytiles', - label: 'Clay Tiles', + labelKey: 'materials.roof-claytiles.label', category: 'roofing', surfaces: ['roof'], - description: 'Clay roof tile finish', previewThumbnailUrl: '/material/roofing/roof_tiles_clay/roof_tiles_clay_thumb.webp', preset: { maps: { @@ -1821,7 +1780,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ metalnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_metallic_512.ktx2', normalMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_normal_512.ktx2', roughnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 1, @@ -1848,10 +1807,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'roof-terracottatiles', - label: 'Terracotta Tiles', + labelKey: 'materials.roof-terracottatiles.label', category: 'roofing', surfaces: ['roof'], - description: 'Terracotta roof tile finish', previewThumbnailUrl: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_thumb.webp', preset: { maps: { @@ -1863,7 +1821,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_normal_512.ktx2', roughnessMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 1, @@ -1890,10 +1848,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'roof-weatheredshingles', - label: 'Weathered Shingles', + labelKey: 'materials.roof-weatheredshingles.label', category: 'roofing', surfaces: ['roof'], - description: 'Weathered roof shingle finish', previewThumbnailUrl: '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_thumb.webp', preset: { @@ -1907,7 +1864,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_normal_512.ktx2', roughnessMap: '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 1, @@ -1934,9 +1891,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'preset-white', - label: 'White', + labelKey: 'materials.preset-white.label', category: 'colors', - description: 'Clean painted finish', // Real white paint reflects ~80%; a literal #ffffff albedo kills GI/shadow // contrast, so "white" is clamped to ≈0.83 linear. previewColor: '#e9e9e9', @@ -1963,14 +1919,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-softwhite', - label: 'Soft White', + labelKey: 'materials.preset-softwhite.label', category: 'colors', - description: 'Warm off-white painted finish', previewColor: '#ebe7df', preset: { maps: {}, @@ -1995,14 +1950,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-cream', - label: 'Cream', + labelKey: 'materials.preset-cream.label', category: 'colors', - description: 'Soft cream painted finish', previewColor: '#efe3cc', preset: { maps: {}, @@ -2027,14 +1981,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-beige', - label: 'Beige', + labelKey: 'materials.preset-beige.label', category: 'colors', - description: 'Balanced beige painted finish', previewColor: '#d9c7ad', preset: { maps: {}, @@ -2059,14 +2012,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-lightgrey', - label: 'Light grey', + labelKey: 'materials.preset-lightgrey.label', category: 'colors', - description: 'Cool light grey painted finish', previewColor: '#d8d6d1', preset: { maps: {}, @@ -2091,14 +2043,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-greige', - label: 'Greige', + labelKey: 'materials.preset-greige.label', category: 'colors', - description: 'Neutral greige painted finish', previewColor: '#c8c1b8', preset: { maps: {}, @@ -2123,14 +2074,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-midgrey', - label: 'Mid grey', + labelKey: 'materials.preset-midgrey.label', category: 'colors', - description: 'Neutral mid grey painted finish', previewColor: '#8b8a86', preset: { maps: {}, @@ -2155,14 +2105,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-charcoal', - label: 'Charcoal', + labelKey: 'materials.preset-charcoal.label', category: 'colors', - description: 'Dark charcoal painted finish', previewColor: '#4e5257', preset: { maps: {}, @@ -2187,14 +2136,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-nearblack', - label: 'Near-black', + labelKey: 'materials.preset-nearblack.label', category: 'colors', - description: 'Soft near-black painted finish', previewColor: '#232322', preset: { maps: {}, @@ -2219,14 +2167,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-blush', - label: 'Blush', + labelKey: 'materials.preset-blush.label', category: 'colors', - description: 'Pale blush painted finish', previewColor: '#e8c6c0', preset: { maps: {}, @@ -2251,14 +2198,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-tomato', - label: 'Tomato', + labelKey: 'materials.preset-tomato.label', category: 'colors', - description: 'Warm tomato red painted finish', previewColor: '#c0594f', preset: { maps: {}, @@ -2283,14 +2229,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-brickred', - label: 'Brick red', + labelKey: 'materials.preset-brickred.label', category: 'colors', - description: 'Deep brick red painted finish', previewColor: '#9e3b34', preset: { maps: {}, @@ -2315,14 +2260,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-oxblood', - label: 'Oxblood', + labelKey: 'materials.preset-oxblood.label', category: 'colors', - description: 'Dark oxblood painted finish', previewColor: '#6f2c2a', preset: { maps: {}, @@ -2347,14 +2291,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-peach', - label: 'Peach', + labelKey: 'materials.preset-peach.label', category: 'colors', - description: 'Soft peach painted finish', previewColor: '#f0c3a0', preset: { maps: {}, @@ -2379,14 +2322,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-terracotta', - label: 'Terracotta', + labelKey: 'materials.preset-terracotta.label', category: 'colors', - description: 'Warm terracotta painted finish', previewColor: '#c86f4c', preset: { maps: {}, @@ -2411,14 +2353,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-burntorange', - label: 'Burnt orange', + labelKey: 'materials.preset-burntorange.label', category: 'colors', - description: 'Burnt orange painted finish', previewColor: '#b25a2c', preset: { maps: {}, @@ -2443,14 +2384,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-clay', - label: 'Clay', + labelKey: 'materials.preset-clay.label', category: 'colors', - description: 'Deep clay painted finish', previewColor: '#8f4a2e', preset: { maps: {}, @@ -2475,14 +2415,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-paleyellow', - label: 'Pale yellow', + labelKey: 'materials.preset-paleyellow.label', category: 'colors', - description: 'Pale yellow painted finish', previewColor: '#f2e3b3', preset: { maps: {}, @@ -2507,14 +2446,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-mustard', - label: 'Mustard', + labelKey: 'materials.preset-mustard.label', category: 'colors', - description: 'Warm mustard painted finish', previewColor: '#c8a449', preset: { maps: {}, @@ -2539,14 +2477,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-ochre', - label: 'Ochre', + labelKey: 'materials.preset-ochre.label', category: 'colors', - description: 'Warm ochre painted finish', previewColor: '#b8852f', preset: { maps: {}, @@ -2571,14 +2508,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-gold', - label: 'Gold', + labelKey: 'materials.preset-gold.label', category: 'colors', - description: 'Deep gold painted finish', previewColor: '#9c7320', preset: { maps: {}, @@ -2603,14 +2539,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-mint', - label: 'Mint', + labelKey: 'materials.preset-mint.label', category: 'colors', - description: 'Soft mint painted finish', previewColor: '#cfe0cf', preset: { maps: {}, @@ -2635,14 +2570,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-sage', - label: 'Sage', + labelKey: 'materials.preset-sage.label', category: 'colors', - description: 'Muted sage painted finish', previewColor: '#bcc5b2', preset: { maps: {}, @@ -2667,14 +2601,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-olive', - label: 'Olive', + labelKey: 'materials.preset-olive.label', category: 'colors', - description: 'Natural olive painted finish', previewColor: '#8d9368', preset: { maps: {}, @@ -2699,14 +2632,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-forest', - label: 'Forest', + labelKey: 'materials.preset-forest.label', category: 'colors', - description: 'Deep forest green painted finish', previewColor: '#4f6b57', preset: { maps: {}, @@ -2731,14 +2663,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-paleteal', - label: 'Pale teal', + labelKey: 'materials.preset-paleteal.label', category: 'colors', - description: 'Pale teal painted finish', previewColor: '#b9d2cf', preset: { maps: {}, @@ -2763,14 +2694,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-teal', - label: 'Teal', + labelKey: 'materials.preset-teal.label', category: 'colors', - description: 'Balanced teal painted finish', previewColor: '#4f8a86', preset: { maps: {}, @@ -2795,14 +2725,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-deepteal', - label: 'Deep teal', + labelKey: 'materials.preset-deepteal.label', category: 'colors', - description: 'Deep teal painted finish', previewColor: '#2f5f5c', preset: { maps: {}, @@ -2827,14 +2756,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-powderblue', - label: 'Powder blue', + labelKey: 'materials.preset-powderblue.label', category: 'colors', - description: 'Powder blue painted finish', previewColor: '#cddce8', preset: { maps: {}, @@ -2859,14 +2787,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-softblue', - label: 'Soft Blue', + labelKey: 'materials.preset-softblue.label', category: 'colors', - description: 'Muted blue painted finish', previewColor: '#c7d6e3', preset: { maps: {}, @@ -2891,14 +2818,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-sky', - label: 'Sky', + labelKey: 'materials.preset-sky.label', category: 'colors', - description: 'Sky blue painted finish', previewColor: '#8fb4d4', preset: { maps: {}, @@ -2923,14 +2849,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-slateblue', - label: 'Slate Blue', + labelKey: 'materials.preset-slateblue.label', category: 'colors', - description: 'Muted slate blue painted finish', previewColor: '#6f87a4', preset: { maps: {}, @@ -2955,14 +2880,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-royalblue', - label: 'Royal blue', + labelKey: 'materials.preset-royalblue.label', category: 'colors', - description: 'Royal blue painted finish', previewColor: '#3a5a9c', preset: { maps: {}, @@ -2987,14 +2911,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-navy', - label: 'Navy', + labelKey: 'materials.preset-navy.label', category: 'colors', - description: 'Classic navy painted finish', previewColor: '#2f4865', preset: { maps: {}, @@ -3019,14 +2942,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-lavender', - label: 'Lavender', + labelKey: 'materials.preset-lavender.label', category: 'colors', - description: 'Soft lavender painted finish', previewColor: '#c9c2da', preset: { maps: {}, @@ -3051,14 +2973,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-plum', - label: 'Plum', + labelKey: 'materials.preset-plum.label', category: 'colors', - description: 'Muted plum painted finish', previewColor: '#6d4a63', preset: { maps: {}, @@ -3083,14 +3004,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-aubergine', - label: 'Aubergine', + labelKey: 'materials.preset-aubergine.label', category: 'colors', - description: 'Deep aubergine painted finish', previewColor: '#594354', preset: { maps: {}, @@ -3115,14 +3035,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-petal', - label: 'Petal', + labelKey: 'materials.preset-petal.label', category: 'colors', - description: 'Pale petal pink painted finish', previewColor: '#f0d3da', preset: { maps: {}, @@ -3147,14 +3066,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-rose', - label: 'Rose', + labelKey: 'materials.preset-rose.label', category: 'colors', - description: 'Muted rose painted finish', previewColor: '#cf8fa6', preset: { maps: {}, @@ -3179,14 +3097,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-dustyrose', - label: 'Dusty Rose', + labelKey: 'materials.preset-dustyrose.label', category: 'colors', - description: 'Muted rose painted finish', previewColor: '#c48a8d', preset: { maps: {}, @@ -3211,14 +3128,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-berry', - label: 'Berry', + labelKey: 'materials.preset-berry.label', category: 'colors', - description: 'Deep berry painted finish', previewColor: '#8f4a5a', preset: { maps: {}, @@ -3243,14 +3159,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-sand', - label: 'Sand', + labelKey: 'materials.preset-sand.label', category: 'colors', - description: 'Warm sand painted finish', previewColor: '#ddccae', preset: { maps: {}, @@ -3275,14 +3190,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-tan', - label: 'Tan', + labelKey: 'materials.preset-tan.label', category: 'colors', - description: 'Natural tan painted finish', previewColor: '#c4a87f', preset: { maps: {}, @@ -3307,14 +3221,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-taupe', - label: 'Taupe', + labelKey: 'materials.preset-taupe.label', category: 'colors', - description: 'Earthy taupe painted finish', previewColor: '#8f7a64', preset: { maps: {}, @@ -3339,14 +3252,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-espresso', - label: 'Espresso', + labelKey: 'materials.preset-espresso.label', category: 'colors', - description: 'Dark espresso painted finish', previewColor: '#4a382c', preset: { maps: {}, @@ -3371,15 +3283,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-metal', - label: 'Metal', + labelKey: 'materials.preset-metal.label', category: 'metal', surfaces: ['furniture', 'wall'], - description: 'Brushed metal finish', previewColor: '#c0c0c0', preset: { maps: {}, @@ -3404,14 +3315,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 1, lightMapIntensity: 1, - }, + }, }, }, { id: 'preset-glass', - label: 'Glass', + labelKey: 'materials.preset-glass.label', category: 'glass', - description: 'Light glass finish', previewColor: '#87ceeb', preset: { maps: {}, @@ -3438,21 +3348,20 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ side: 0, opacity: 0.3, lightMapIntensity: 1, - }, + }, }, }, { id: 'fabric-linen', - label: 'Linen', + labelKey: 'materials.fabric-linen.label', category: 'fabric', surfaces: ['furniture', 'wall'], - description: 'Natural linen weave', previewThumbnailUrl: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_thumb.webp', preset: { maps: { albedoMap: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_basecolor_512.ktx2', normalMap: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.85, @@ -3479,10 +3388,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'fabric-cotton', - label: 'Cotton', + labelKey: 'materials.fabric-cotton.label', category: 'fabric', surfaces: ['furniture', 'wall'], - description: 'Plain cotton weave', previewThumbnailUrl: '/material/fabric/blue_cotton/blue_cotton_thumb.webp', preset: { maps: { @@ -3490,7 +3398,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/fabric/blue_cotton/blue_cotton_normal_512.ktx2', roughnessMap: '/material/fabric/blue_cotton/blue_cotton_roughness_512.ktx2', aoMap: '/material/fabric/blue_cotton/blue_cotton_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.85, @@ -3517,10 +3425,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'fabric-velvet', - label: 'Velvet', + labelKey: 'materials.fabric-velvet.label', category: 'fabric', surfaces: ['furniture'], - description: 'Velvet with a soft sheen', previewThumbnailUrl: '/material/fabric/red_velvet/red_velvet_thumb.webp', preset: { maps: { @@ -3528,7 +3435,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/fabric/red_velvet/red_velvet_normal_512.ktx2', roughnessMap: '/material/fabric/red_velvet/red_velvet_roughness_512.ktx2', aoMap: '/material/fabric/red_velvet/red_velvet_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.6, @@ -3555,10 +3462,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'fabric-wool', - label: 'Wool', + labelKey: 'materials.fabric-wool.label', category: 'fabric', surfaces: ['furniture'], - description: 'Matte wool felt', previewThumbnailUrl: '/material/fabric/white_wool/white_wool_thumb.webp', preset: { maps: { @@ -3566,7 +3472,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/fabric/white_wool/white_wool_normal_512.ktx2', roughnessMap: '/material/fabric/white_wool/white_wool_roughness_512.ktx2', aoMap: '/material/fabric/white_wool/white_wool_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.9, @@ -3593,10 +3499,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'fabric-suede', - label: 'Suede', + labelKey: 'materials.fabric-suede.label', category: 'fabric', surfaces: ['furniture'], - description: 'Matte suede nap', previewThumbnailUrl: '/material/fabric/suede/suede_thumb.webp', preset: { maps: { @@ -3604,7 +3509,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/fabric/suede/suede_normal_512.ktx2', roughnessMap: '/material/fabric/suede/suede_roughness_512.ktx2', aoMap: '/material/fabric/suede/suede_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.9, @@ -3631,16 +3536,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'fabric-boucle', - label: 'Bouclé', + labelKey: 'materials.fabric-boucle.label', category: 'fabric', surfaces: ['furniture'], - description: 'Looped bouclé upholstery', previewThumbnailUrl: '/material/fabric/wool_boucle/wool_boucle_thumb.webp', preset: { maps: { albedoMap: '/material/fabric/wool_boucle/wool_boucle_basecolor_512.ktx2', normalMap: '/material/fabric/wool_boucle/wool_boucle_normal_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.9, @@ -3667,17 +3571,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'leather-black', - label: 'Black Leather', + labelKey: 'materials.leather-black.label', category: 'leather', surfaces: ['furniture'], - description: 'Smooth black leather', previewThumbnailUrl: '/material/leather/black_leather/black_leather_thumb.webp', preset: { maps: { albedoMap: '/material/leather/black_leather/black_leather_basecolor_512.ktx2', normalMap: '/material/leather/black_leather/black_leather_normal_512.ktx2', roughnessMap: '/material/leather/black_leather/black_leather_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -3704,10 +3607,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'leather-calf', - label: 'Calf Leather', + labelKey: 'materials.leather-calf.label', category: 'leather', surfaces: ['furniture'], - description: 'Pebbled calf leather', previewThumbnailUrl: '/material/leather/calf_leather/calf_leather_thumb.webp', preset: { maps: { @@ -3715,7 +3617,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/leather/calf_leather/calf_leather_normal_512.ktx2', roughnessMap: '/material/leather/calf_leather/calf_leather_roughness_512.ktx2', aoMap: '/material/leather/calf_leather/calf_leather_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -3742,10 +3644,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'concrete-plaster', - label: 'Painted Plaster', + labelKey: 'materials.concrete-plaster.label', category: 'concrete', surfaces: ['wall', 'ceiling'], - description: 'Smooth painted plaster wall', previewThumbnailUrl: '/material/concrete/plaster_painted/plaster_painted_thumb.webp', preset: { maps: { @@ -3753,7 +3654,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/concrete/plaster_painted/plaster_painted_normal_512.ktx2', roughnessMap: '/material/concrete/plaster_painted/plaster_painted_roughness_512.ktx2', aoMap: '/material/concrete/plaster_painted/plaster_painted_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.85, @@ -3780,17 +3681,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'concrete-polished', - label: 'Polished Concrete', + labelKey: 'materials.concrete-polished.label', category: 'concrete', surfaces: ['floor', 'wall'], - description: 'Polished concrete floor', previewThumbnailUrl: '/material/concrete/concrete_polished/concrete_polished_thumb.webp', preset: { maps: { albedoMap: '/material/concrete/concrete_polished/concrete_polished_basecolor_512.ktx2', normalMap: '/material/concrete/concrete_polished/concrete_polished_normal_512.ktx2', roughnessMap: '/material/concrete/concrete_polished/concrete_polished_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.5, @@ -3817,10 +3717,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'concrete-raw', - label: 'Raw Concrete', + labelKey: 'materials.concrete-raw.label', category: 'concrete', surfaces: ['wall', 'floor', 'outdoor'], - description: 'Board-formed raw concrete', previewThumbnailUrl: '/material/concrete/concrete_raw/concrete_raw_thumb.webp', preset: { maps: { @@ -3828,7 +3727,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/concrete/concrete_raw/concrete_raw_normal_512.ktx2', roughnessMap: '/material/concrete/concrete_raw/concrete_raw_roughness_512.ktx2', aoMap: '/material/concrete/concrete_raw/concrete_raw_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.8, @@ -3855,10 +3754,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'concrete-plate', - label: 'Concrete Plate', + labelKey: 'materials.concrete-plate.label', category: 'concrete', surfaces: ['wall', 'floor'], - description: 'Smooth cast concrete plate', previewThumbnailUrl: '/material/concrete/concrete_plate/concrete_plate_thumb.webp', preset: { maps: { @@ -3866,7 +3764,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2', roughnessMap: '/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2', aoMap: '/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.8, @@ -3893,10 +3791,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'concrete-stucco', - label: 'White Stucco', + labelKey: 'materials.concrete-stucco.label', category: 'concrete', surfaces: ['wall', 'outdoor'], - description: 'Exterior stucco render', previewThumbnailUrl: '/material/concrete/white_stucco/white_stucco_thumb.webp', preset: { maps: { @@ -3904,7 +3801,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/concrete/white_stucco/white_stucco_normal_512.ktx2', roughnessMap: '/material/concrete/white_stucco/white_stucco_roughness_512.ktx2', aoMap: '/material/concrete/white_stucco/white_stucco_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.9, @@ -3931,10 +3828,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'concrete-drywall', - label: 'Prepared Drywall', + labelKey: 'materials.concrete-drywall.label', category: 'concrete', surfaces: ['wall', 'ceiling'], - description: 'Smooth prepared drywall ready for paint', previewThumbnailUrl: '/material/concrete/prepared_drywall/prepared_drywall_thumb.webp', preset: { maps: { @@ -3942,7 +3838,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2', roughnessMap: '/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2', aoMap: '/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.9, @@ -3969,10 +3865,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'metal-copper', - label: 'Copper', + labelKey: 'materials.metal-copper.label', category: 'metal', surfaces: ['furniture', 'wall'], - description: 'Warm copper', previewThumbnailUrl: '/material/metal/copper_metal/copper_metal_thumb.webp', preset: { maps: { @@ -3980,7 +3875,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalMap: '/material/metal/copper_metal/copper_metal_normal_512.ktx2', roughnessMap: '/material/metal/copper_metal/copper_metal_roughness_512.ktx2', metalnessMap: '/material/metal/copper_metal/copper_metal_metallic_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.4, @@ -4007,10 +3902,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'metal-polished', - label: 'Polished Metal', + labelKey: 'materials.metal-polished.label', category: 'metal', surfaces: ['furniture', 'wall'], - description: 'Polished stainless steel', previewThumbnailUrl: '/material/metal/polished_metal/polished_metal_thumb.webp', preset: { maps: { @@ -4019,7 +3913,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ roughnessMap: '/material/metal/polished_metal/polished_metal_roughness_512.ktx2', aoMap: '/material/metal/polished_metal/polished_metal_ao_512.ktx2', metalnessMap: '/material/metal/polished_metal/polished_metal_metallic_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.3, @@ -4046,10 +3940,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, { id: 'metal-steel', - label: 'Brushed Steel', + labelKey: 'materials.metal-steel.label', category: 'metal', surfaces: ['furniture', 'wall'], - description: 'Brushed stainless steel', previewThumbnailUrl: '/material/metal/stainless_steel_brushed/stainless_steel_brushed_thumb.webp', preset: { @@ -4060,7 +3953,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ '/material/metal/stainless_steel_brushed/stainless_steel_brushed_normal_512.ktx2', roughnessMap: '/material/metal/stainless_steel_brushed/stainless_steel_brushed_roughness_512.ktx2', - }, + }, mapProperties: { color: '#ffffff', roughness: 0.45, @@ -4089,10 +3982,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ // Parameter-only metal (no texture maps) — a worked example of a non-PBR // catalog finish driven purely by three.js material settings. id: 'metal-brass', - label: 'Brass', + labelKey: 'materials.metal-brass.label', category: 'metal', surfaces: ['furniture', 'wall'], - description: 'Polished brass (flat metal, no maps)', previewColor: '#b08d57', preset: { maps: {}, @@ -4124,10 +4016,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ // Parameter-only chrome (no texture maps) — moderate metalness so it reads // as bright metal under direct/ambient light without needing an env map. id: 'metal-chrome', - label: 'Chrome', + labelKey: 'materials.metal-chrome.label', category: 'metal', surfaces: ['furniture', 'wall'], - description: 'Polished chrome (flat metal, no maps)', previewColor: '#c8ccce', preset: { maps: {}, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 249c796712..9488ee5342 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,3 +1,5 @@ +'use client' + export type { ArcResizeHandle, Cursor, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 57e1060fc7..180078e509 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,3 +1,5 @@ +'use client' + import type { ComponentType } from 'react' import type { AnimationClip, BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' @@ -328,6 +330,12 @@ export type ToolHint = { key: string /** Description of what the input does. Sentence case. */ label: string + /** + * Optional i18n key for `label`. When set, the contextual helper resolves + * through the active locale at render time; otherwise `label` is used + * verbatim (suitable for hints that already arrive pre-localised). + */ + labelKey?: string /** * Only show this hint once the in-progress draft has at least this many * vertices (reads `useEditor.draftVertexCount`). Lets a polygon tool's @@ -368,6 +376,11 @@ export type ToolHintChip = { icons?: Record /** Hover tooltip, e.g. 'Placement type — click or press I to cycle'. */ tooltip?: string + /** + * Translator key resolving to the hover tooltip. Consumers resolve + * `tooltipKey` first and fall back to `tooltip` when untranslated. + */ + tooltipKey?: string } // ─── ToolOption ────────────────────────────────────────────────────── @@ -384,8 +397,19 @@ export type ToolOptionChoice = { value: string /** Button label. Sentence case. */ label: string + /** + * Translator key resolving to the button label. If both `label` and + * `labelKey` are set, the consumer resolves `labelKey` first and falls back + * to `label` when the key is untranslated. + */ + labelKey?: string /** Helper line shown under the row while this choice is active. */ description?: string + /** + * Translator key resolving to the helper line. Falls back to `description` + * when the key is untranslated. + */ + descriptionKey?: string } export type ToolOption = { @@ -393,6 +417,11 @@ export type ToolOption = { id: string /** Row label. Sentence case, e.g. 'Create from'. */ label: string + /** + * Translator key resolving to the row label. Consumers resolve `labelKey` + * first and fall back to `label` when untranslated. + */ + labelKey?: string choices: readonly ToolOptionChoice[] /** Subscribe to live value changes (Zustand-store-like); returns unsubscribe. */ subscribe: (onChange: () => void) => () => void @@ -1483,8 +1512,21 @@ export type KeyboardAction = { export type Presentation = { /** Sentence-case label shown in palette buttons, breadcrumbs, etc. */ label: string + /** + * Optional i18n key resolved at render time. When set, the i18n catalog + * value wins over `label` so the same definition can ship translated UI + * surfaces without re-publishing. Falls back to `label` if the catalog + * lookup misses or the active locale has no entry yet. + */ + labelKey?: string /** Optional longer tooltip / help text. */ description?: string + /** + * Optional i18n key for `description`. Same fallback semantics as + * `labelKey`. Defined alongside it so MCP and tooltip surfaces can be + * wired without re-touching every definition. + */ + descriptionKey?: string /** Icon for palette buttons and tree views. */ icon: IconRef /** Tool palette section. Defaults to `category` when omitted. */ @@ -2366,6 +2408,13 @@ export type ParamAction = { export type ParamGroup = { label: string + /** + * Optional i18n key resolved at render time. Wins over `label` when the + * catalog has an entry; falls back to `label` unchanged otherwise. Defined + * alongside it so the inspector can localize section headings without + * re-touching every parametrics descriptor. + */ + labelKey?: string fields: ParamField[] } @@ -2373,6 +2422,9 @@ export type ParamField = | { key: keyof N label?: string + /** Optional i18n key for `label` — same fallback semantics as + * `ParamGroup.labelKey`. */ + labelKey?: string kind: 'number' unit?: string min?: number @@ -2381,21 +2433,35 @@ export type ParamField = visibleIf?: (n: N) => boolean customEditor?: ComponentType } - | { key: keyof N; label?: string; kind: 'boolean'; visibleIf?: (n: N) => boolean } | { key: keyof N label?: string + labelKey?: string + kind: 'boolean' + visibleIf?: (n: N) => boolean + } + | { + key: keyof N + label?: string + labelKey?: string kind: 'enum' options: readonly string[] + /** + * Per-option i18n keys. Each entry maps a raw option value (the + * string stored in `options`) to its catalog key. Missing entries + * fall through to `prettifyEnumValue(option)` exactly as before, so + * un-translated kinds keep their current English rendering. + */ + optionLabelKeys?: Record /** Defaults to 'select' (dropdown). 'segmented' renders the inline * tabbed switcher — better for short option lists (2-4 items). */ display?: 'select' | 'segmented' visibleIf?: (n: N) => boolean } - | { key: keyof N; label?: string; kind: 'vec3'; visibleIf?: (n: N) => boolean } - | { key: keyof N; label?: string; kind: 'color'; visibleIf?: (n: N) => boolean } - | { key: keyof N; label?: string; kind: 'material'; visibleIf?: (n: N) => boolean } - | { key: keyof N; label?: string; kind: 'ref'; refKind: string; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; labelKey?: string; kind: 'vec3'; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; labelKey?: string; kind: 'color'; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; labelKey?: string; kind: 'material'; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; labelKey?: string; kind: 'ref'; refKind: string; visibleIf?: (n: N) => boolean } /** Escape hatch for fields that don't map to a single node key — * derived values (`length` from `start`/`end`), sliders with * dynamic min/max (curve sagitta bounded by chord length), @@ -2404,6 +2470,7 @@ export type ParamField = | { key: string label?: string + labelKey?: string kind: 'custom' component: ComponentType<{ node: N; onUpdate: (patch: Partial) => void }> visibleIf?: (n: N) => boolean diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx index 70840020e3..deeedee16c 100644 --- a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx @@ -35,6 +35,7 @@ import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap, } from '../../lib/surface-plan-snap' +import { useTranslations } from '../../lib/i18n' import useEditor from '../../store/use-editor' import { commitMeasurementDraft, @@ -635,6 +636,7 @@ function FloorplanExtrusionControl({ sceneRotationDeg: number unitsPerPixel: number }) { + const t = useTranslations() const unit = useViewer((state) => state.unit) const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight) const points = useMeasurementDraft((state) => state.points) @@ -696,14 +698,14 @@ function FloorplanExtrusionControl({ }} >
H state.request) const cancel = useDeleteConfirmation((state) => state.cancel) const confirm = useDeleteConfirmation((state) => state.confirm) @@ -26,11 +28,8 @@ export function DeleteConfirmationDialog() { showCloseButton={false} > - Delete {request?.count ?? 0} elements? - - This removes every selected element. You can undo the deletion while it remains in the - editor history. - + {t('dialog.deleteElements.title', { count: request?.count ?? 0 })} + {t('dialog.deleteElements.description')} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index df455444e2..d1a5ed4d68 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -78,6 +78,8 @@ import { } from '../../lib/window-interaction' import useEditor from '../../store/use-editor' import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud' +import type { Translator } from '@pascal-app/core' +import { useTranslations } from '../../lib/i18n' import { WalkthroughHud } from '../walkthrough-hud' import { buildFirstPersonColliderWorldFromRegistry, @@ -285,7 +287,7 @@ function pointIsInLevelFootprint( ) } -function resolveFirstPersonHudLabels(worldPoint: Vector3) { +function resolveFirstPersonHudLabels(worldPoint: Vector3, t: Translator) { const nodes = useScene.getState().nodes const levelElevations = getLevelElevations(nodes as Record) @@ -328,7 +330,7 @@ function resolveFirstPersonHudLabels(worldPoint: Vector3) { ) return { - floorLabel: getLevelDisplayName(activeLevel), + floorLabel: getLevelDisplayName(activeLevel, t), zoneLabel: zone?.type === 'zone' ? zone.name : null, } } @@ -660,6 +662,7 @@ const resolvePlacedSpawnNode = ( export const FirstPersonControls = () => { const { camera, gl } = useThree() + const t = useTranslations() const selectedLevelId = useViewer((state) => state.selection.levelId) const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId)) const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone') @@ -1688,7 +1691,7 @@ export const FirstPersonControls = () => { if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) { hudLabelFrameRef.current = 0 camera.getWorldPosition(hudWorldEyePosition) - useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition)) + useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition, t)) } }, 2.5) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index eb8474d87d..00ea31eb7f 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -1,3 +1,5 @@ +'use client' + import { type AnyNode, type AnyNodeId, diff --git a/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx b/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx index bb8a213e10..1a9027816b 100644 --- a/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx +++ b/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx @@ -3,12 +3,16 @@ import { emitter, nodeRegistry } from '@pascal-app/core' import { X } from 'lucide-react' import { useEffect, useRef } from 'react' +import { camelType } from '../ui/panels/node-display' +import { useTranslations } from '../../lib/i18n' import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension' import { isFloorplanToolAvailableInMode } from '../../lib/floorplan/floorplan-mode' import useEditor from '../../store/use-editor' import useFloorplanMode from '../../store/use-floorplan-mode' -function getToolLabel(tool: string): string { +function getToolLabel(tool: string, t: ReturnType): string { + const fromCatalog = t(`panel.nodeType.${camelType(tool)}`) + if (!fromCatalog.startsWith('panel.nodeType.')) return fromCatalog return nodeRegistry.get(tool)?.presentation?.label ?? tool } @@ -21,6 +25,7 @@ export function FloorplanModeCoordinator() { const setFloorplanMode = useFloorplanMode((state) => state.setMode) const showExpertModeNotice = useFloorplanMode((state) => state.showExpertModeNotice) const showNotice = useFloorplanMode((state) => state.showNotice) + const t = useTranslations() const previousFloorplanMode = useRef(floorplanMode) useEffect(() => { @@ -30,7 +35,7 @@ export function FloorplanModeCoordinator() { const extension = getFloorplanNodeExtension(nodeRegistry.get(tool)) if (isFloorplanToolAvailableInMode(extension?.availableModes, floorplanMode)) return - const toolLabel = getToolLabel(tool) + const toolLabel = getToolLabel(tool, t) emitter.emit('tool:cancel') useEditor.getState().setMode('select') if (priorFloorplanMode === 'expert') { @@ -40,7 +45,7 @@ export function FloorplanModeCoordinator() { } else { showExpertModeNotice(toolLabel) } - }, [editorMode, floorplanMode, showExpertModeNotice, showNotice, tool]) + }, [editorMode, floorplanMode, showExpertModeNotice, showNotice, tool, t]) useEffect(() => { if (!notice || notice.kind === 'switch-to-expert') return @@ -70,7 +75,7 @@ export function FloorplanModeCoordinator() { ) : null} ) @@ -228,7 +232,7 @@ export function MeasurementControl() { <>
- Floor plan + {t('measurement.floorplanSection')}
{constructionDimensionOptions.map((option) => { @@ -253,7 +257,7 @@ export function MeasurementControl() { type="button" >
handleOpacityChange(guide.id, value)} @@ -337,7 +343,7 @@ function GuidesControl() {
) : (
- {REFERENCES_EMPTY_TEXT} + {t('viewer.noGuideImagesOnLevel')}
)}
@@ -349,6 +355,7 @@ function GuidesControl() { // ── Scans toggle + dropdown ───────────────────────────────────────────────── function ScansControl() { + const t = useTranslations() const showScans = useViewer((state) => state.showScans) const setShowScans = useViewer((state) => state.setShowScans) const setSelection = useViewer((state) => state.setSelection) @@ -389,7 +396,7 @@ function ScansControl() { ? 'bg-white/15' : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', )} - label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`} + label={t('viewer.scansState', { state: showScans ? t('viewer.visible') : t('viewer.hidden') })} onClick={() => setShowScans(!showScans)} size="icon" variant="ghost" @@ -406,7 +413,7 @@ function ScansControl() {
handleOpacityChange(scan.id, value)} @@ -512,7 +524,7 @@ function ScansControl() {
) : (
- {REFERENCES_EMPTY_TEXT} + {t('viewer.noScansOnLevel')}
)}
@@ -529,7 +541,8 @@ function ScansControl() { function ReferenceListSection({ title, iconSrc, - noun, + countKey, + defaultNameKey, emptyText, nodes, show, @@ -538,13 +551,15 @@ function ReferenceListSection({ }: { title: string iconSrc: string - noun: string + countKey: string + defaultNameKey: string emptyText: string nodes: (GuideNode | ScanNode)[] show: boolean setShow: (show: boolean) => void onError: (message: string | null) => void }) { + const t = useTranslations() const setSelection = useViewer((state) => state.setSelection) const updateNode = useScene((state) => state.updateNode) const deleteNode = useScene((state) => state.deleteNode) @@ -571,13 +586,12 @@ function ReferenceListSection({

{title}

{hasItems && (

- {nodes.length} {noun} - {nodes.length !== 1 ? 's' : ''} on this level + {t(nodes.length === 1 ? countKey : `${countKey}_plural`, { count: nodes.length })}

)}
@@ -657,6 +671,7 @@ function ReferenceListSection({ } function ReferencesControl() { + const t = useTranslations() const showScans = useViewer((state) => state.showScans) const setShowScans = useViewer((state) => state.setShowScans) const showGuides = useViewer((state) => state.showGuides) @@ -685,7 +700,7 @@ 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('viewer.referencesState', { state: anyVisible ? t('viewer.visible') : t('viewer.hidden') })} onClick={toggleAll} size="icon" variant="ghost" @@ -705,7 +720,7 @@ function ReferencesControl() { ) })}
) : (
- No lower floor available. + {t('viewer.noLowerFloor')}
)}
@@ -921,6 +939,7 @@ function ReferenceFloorControl() { // ── Riser diagram control ──────────────────────────────────────────────────── function RiserControl() { + const t = useTranslations() const isRiserOpen = useEditor((state) => state.isRiserOpen) const toggleRiserOpen = useEditor((state) => state.toggleRiserOpen) @@ -931,7 +950,7 @@ function RiserControl() { ? 'bg-white/15' : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', )} - label="Riser diagram" + label={t('viewer.riserDiagram')} onClick={toggleRiserOpen} size="icon" variant="ghost" diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index e9a22493de..10e331fbd7 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -39,12 +39,14 @@ import { import { useEffect } from 'react' import { getHistoryCommandState, runRedo, runUndo } from '../../../lib/history' import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection' +import { useTranslations } from '../../../lib/i18n' import { useCommandRegistry } from '../../../store/use-command-registry' import type { StructureTool } from '../../../store/use-editor' import useEditor from '../../../store/use-editor' import { useCommandPalette } from './index' export function EditorCommands() { + const t = useTranslations() const register = useCommandRegistry((s) => s.register) const { navigateTo, setInputValue, setOpen } = useCommandPalette() @@ -77,72 +79,72 @@ export function EditorCommands() { // ── Scene ──────────────────────────────────────────────────────────── { id: 'editor.tool.wall', - label: 'Wall Tool', - group: 'Scene', + label: t('commands.wallTool'), + group: t('commands.group.scene'), icon: , keywords: ['draw', 'build', 'structure'], execute: () => activateTool('wall'), }, { id: 'editor.tool.slab', - label: 'Slab Tool', - group: 'Scene', + label: t('commands.slabTool'), + group: t('commands.group.scene'), icon: , keywords: ['floor', 'build'], execute: () => activateTool('slab'), }, { id: 'editor.tool.ceiling', - label: 'Ceiling Tool', - group: 'Scene', + label: t('commands.ceilingTool'), + group: t('commands.group.scene'), icon: , keywords: ['top', 'build'], execute: () => activateTool('ceiling'), }, { id: 'editor.tool.door', - label: 'Door Tool', - group: 'Scene', + label: t('commands.doorTool'), + group: t('commands.group.scene'), icon: , keywords: ['opening', 'entrance'], execute: () => activateTool('door'), }, { id: 'editor.tool.window', - label: 'Window Tool', - group: 'Scene', + label: t('commands.windowTool'), + group: t('commands.group.scene'), icon: , keywords: ['opening', 'glass'], execute: () => activateTool('window'), }, { id: 'editor.tool.item', - label: 'Item Tool', - group: 'Scene', + label: t('commands.itemTool'), + group: t('commands.group.scene'), icon: , keywords: ['furniture', 'object', 'asset', 'furnish'], execute: () => activateTool('item'), }, { id: 'editor.tool.stair', - label: 'Stair Tool', - group: 'Scene', + label: t('commands.stairTool'), + group: t('commands.group.scene'), icon: , keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'], execute: () => activateTool('stair'), }, { id: 'editor.tool.zone', - label: 'Zone Tool', - group: 'Scene', + label: t('commands.zoneTool'), + group: t('commands.group.scene'), icon: , keywords: ['area', 'room', 'space'], execute: () => activateTool('zone'), }, { id: 'editor.delete-selection', - label: 'Delete Selection', - group: 'Scene', + label: t('commands.deleteSelection'), + group: t('commands.group.scene'), icon: , keywords: ['remove', 'erase'], shortcut: ['⌫'], @@ -155,8 +157,8 @@ export function EditorCommands() { }, { id: 'editor.mode.material-paint', - label: 'Material Paint', - group: 'Scene', + label: t('commands.materialPaint'), + group: t('commands.group.scene'), icon: , keywords: ['paint', 'material', 'texture', 'bucket', 'surface'], shortcut: ['P'], @@ -169,8 +171,8 @@ export function EditorCommands() { }, { id: 'editor.mode.terrain-sculpt', - label: 'Sculpt Terrain', - group: 'Scene', + label: t('commands.sculptTerrain'), + group: t('commands.group.scene'), icon: , keywords: ['terrain', 'ground', 'elevation', 'sculpt', 'hill', 'slope', 'grade', 'dig'], shortcut: ['G'], @@ -181,8 +183,8 @@ export function EditorCommands() { // ── Levels ─────────────────────────────────────────────────────────── { id: 'editor.level.goto', - label: 'Go to Level', - group: 'Levels', + label: t('commands.gotoLevel'), + group: t('commands.group.levels'), icon: , keywords: ['level', 'floor', 'go', 'navigate', 'switch', 'select'], navigate: true, @@ -191,8 +193,8 @@ export function EditorCommands() { }, { id: 'editor.level.add', - label: 'Add Level', - group: 'Levels', + label: t('commands.addLevel'), + group: t('commands.group.levels'), icon: , keywords: ['level', 'floor', 'add', 'create', 'new'], execute: () => @@ -215,8 +217,8 @@ export function EditorCommands() { }, { id: 'editor.level.rename', - label: 'Rename Level', - group: 'Levels', + label: t('commands.renameLevel'), + group: t('commands.group.levels'), icon: , keywords: ['level', 'floor', 'rename', 'name'], navigate: true, @@ -231,8 +233,8 @@ export function EditorCommands() { }, { id: 'editor.level.delete', - label: 'Delete Level', - group: 'Levels', + label: t('commands.deleteLevel'), + group: t('commands.group.levels'), icon: , keywords: ['level', 'floor', 'delete', 'remove'], when: () => { @@ -252,26 +254,42 @@ export function EditorCommands() { // ── Viewer Controls ────────────────────────────────────────────────── { id: 'editor.viewer.wall-mode', - label: 'Wall Mode', - group: 'Viewer Controls', + label: t('commands.wallMode'), + group: t('commands.group.viewerControls'), icon: , keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'], badge: () => { const mode = useViewer.getState().wallMode - return { cutaway: 'Cutaway', up: 'Up', down: 'Down', translucent: 'Translucent' }[mode] + const labelKey = + mode === 'cutaway' + ? 'commands.cutaway' + : mode === 'up' + ? 'commands.up' + : mode === 'down' + ? 'commands.down' + : 'commands.translucent' + return t(labelKey) }, navigate: true, execute: () => navigateTo('wall-mode'), }, { id: 'editor.viewer.level-mode', - label: 'Level Mode', - group: 'Viewer Controls', + label: t('commands.levelMode'), + group: t('commands.group.viewerControls'), icon: , keywords: ['level', 'floor', 'exploded', 'stacked', 'solo'], badge: () => { const mode = useViewer.getState().levelMode - return { manual: 'Manual', stacked: 'Stacked', exploded: 'Exploded', solo: 'Solo' }[mode] + const labelKey = + mode === 'manual' + ? 'commands.manual' + : mode === 'stacked' + ? 'commands.stacked' + : mode === 'exploded' + ? 'commands.exploded' + : 'commands.solo' + return t(labelKey) }, navigate: true, execute: () => navigateTo('level-mode'), @@ -280,9 +298,10 @@ export function EditorCommands() { id: 'editor.viewer.camera-mode', label: () => { const mode = useViewer.getState().cameraMode - return `Camera: Switch to ${mode === 'perspective' ? 'Orthographic' : 'Perspective'}` + const next = mode === 'perspective' ? t('commands.orthographic') : t('commands.perspective') + return t('commands.cameraSwitchTo', { mode: next }) }, - group: 'Viewer Controls', + group: t('commands.group.viewerControls'), icon:
@@ -128,12 +130,12 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
- Scene materials + {t('paint.sceneMaterials')} - Add material + {t('paint.addMaterial')}
@@ -150,7 +152,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan ) : (

- No custom materials yet — add one with +. + {t('paint.noCustomMaterials')}

)}
diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 39f2019db2..570fc1b7e0 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -15,6 +15,7 @@ import { } from '@pascal-app/core' import { Plus } from 'lucide-react' import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import { useTranslations, type Translator } from '../../../lib/i18n' import { triggerSFX } from '../../../lib/sfx-bus' export type MaterialSourceFilter = MaterialSource @@ -28,17 +29,20 @@ export type MaterialPickerProps = { onCreateMaterialRequest?: () => void } -// No 'All': the browse surfaces (Items / Rooms / Build) dropped it and default +// No 'all': the browse surfaces (Items / Rooms / Build) dropped it and default // to the Pascal library — the combined list buried the curated set. -const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ - { id: 'pascal', label: 'Pascal' }, - { id: 'mine', label: 'Mine' }, - { id: 'workspace', label: 'Workspace' }, - { id: 'community', label: 'Community' }, +// Labels are resolved at render time via t(). +const SOURCE_FILTERS: { id: MaterialSourceFilter; labelKey: string }[] = [ + { id: 'pascal', labelKey: 'materialPicker.source.pascal' }, + { id: 'mine', labelKey: 'items.mine' }, + { id: 'workspace', labelKey: 'materialPicker.source.workspace' }, + { id: 'community', labelKey: 'items.community' }, ] -function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { - return category.charAt(0).toUpperCase() + category.slice(1) +function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number], t: Translator): string { + const translated = t(`materials.${category}`) + // Un-translated categories echo the key back; fall back to the capitalized id. + return translated.startsWith('materials.') ? category.charAt(0).toUpperCase() + category.slice(1) : translated } function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) { @@ -57,6 +61,7 @@ export function MaterialPicker({ disabled = false, onCreateMaterialRequest, }: MaterialPickerProps) { + const t = useTranslations() const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( MATERIAL_CATEGORIES[0], ) @@ -115,7 +120,7 @@ export function MaterialPicker({ }} type="button" > - {getCategoryLabel(category)} + {getCategoryLabel(category, t)} ))}
@@ -138,7 +143,7 @@ export function MaterialPicker({ onMouseEnter={() => triggerSFX('sfx:menu-hover')} type="button" > - {filter.label} + {t(filter.labelKey)} ))}
@@ -161,12 +166,13 @@ export function MaterialPicker({
- New material + {t('materialPicker.newMaterial')} ) : null} {catalogItems.map((item) => { const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id) + const label = t(item.labelKey) return (
- {item.label} + {label} ) diff --git a/packages/editor/src/components/ui/controls/material-properties-editor.tsx b/packages/editor/src/components/ui/controls/material-properties-editor.tsx index 5334000973..d417bfe973 100644 --- a/packages/editor/src/components/ui/controls/material-properties-editor.tsx +++ b/packages/editor/src/components/ui/controls/material-properties-editor.tsx @@ -1,6 +1,7 @@ 'use client' import type { MaterialProperties, MaterialSchema } from '@pascal-app/core' +import { useTranslations } from '../../../lib/i18n' import { Input } from '../primitives/input' import { SliderControl } from './slider-control' @@ -20,6 +21,7 @@ export function MaterialPropertiesEditor({ value: MaterialSchema onChange: (next: MaterialSchema) => void }) { + const t = useTranslations() const currentProps = value.properties ?? DEFAULT_MATERIAL_PROPERTIES const updateMaterial = ( @@ -41,7 +43,7 @@ export function MaterialPropertiesEditor({
updateMaterial({ roughness: value })} @@ -68,7 +70,7 @@ export function MaterialPropertiesEditor({ /> updateMaterial({ metalness: value })} @@ -78,7 +80,7 @@ export function MaterialPropertiesEditor({ /> updateMaterial({ opacity: value }, value < 1 || currentProps.transparent)} @@ -89,7 +91,7 @@ export function MaterialPropertiesEditor({
diff --git a/packages/editor/src/components/ui/controls/scene-material-list.tsx b/packages/editor/src/components/ui/controls/scene-material-list.tsx index f0bb423278..719ceb3df6 100644 --- a/packages/editor/src/components/ui/controls/scene-material-list.tsx +++ b/packages/editor/src/components/ui/controls/scene-material-list.tsx @@ -10,6 +10,7 @@ import { } from '@pascal-app/core' import { Copy, Paintbrush, Pencil, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' +import { useTranslations } from '../../../lib/i18n' import useEditor from '../../../store/use-editor' import { Button } from '../primitives/button' import { Input } from '../primitives/input' @@ -108,6 +109,7 @@ function SceneMaterialRow({ removeSceneMaterial: ReturnType['removeSceneMaterial'] armMaterialPaint: ReturnType['armMaterialPaint'] }) { + const t = useTranslations() // A freshly-created material (via "+ Custom") mounts with its editor open. const [isEditingMaterial, setIsEditingMaterial] = useState(autoEdit) const [draftName, setDraftName] = useState(sceneMaterial.name) @@ -131,7 +133,7 @@ function SceneMaterialRow({ const duplicateMaterial = () => { addSceneMaterial({ id: generateSceneMaterialId(), - name: `${sceneMaterial.name} copy`, + name: t('paint.material.copySuffix', { name: sceneMaterial.name }), material: structuredClone(sceneMaterial.material) as MaterialSchema, }) } @@ -167,13 +169,15 @@ function SceneMaterialRow({
- Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'} + {usageCount === 1 + ? t('paint.material.usedByOne') + : t('paint.material.usedByOther', { count: usageCount })}
- Paint with + {t('paint.material.paintWith')} - Edit + {t('common.edit')} - Duplicate + {t('common.duplicate')} - Delete + {t('common.delete')}
diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index a3c5d06d70..39f150cb08 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -2,6 +2,7 @@ import { useScene } from '@pascal-app/core' import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslations } from '../../../lib/i18n' import { lingoUnitSpec, measurementHint, @@ -67,6 +68,7 @@ export function SliderControl({ restoreOnCommit = true, mixed = false, }: SliderControlProps) { + const t = useTranslations() // Display/storage conversion so the value honors the metric/imperial toggle. // `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the // stored unit (meters for `unit === 'm'`); the step, drag deltas, text field @@ -354,7 +356,7 @@ export function SliderControl({ className="flex cursor-text items-center text-muted-foreground transition-colors hover:text-foreground" onClick={handleValueClick} > - Mixed + {t('common.mixed')}
) : (
= [ - { value: 'raise', iconSrc: '/icons/terrain-raise.webp', hint: 'Raise' }, - { value: 'lower', iconSrc: '/icons/terrain-lower.webp', hint: 'Lower' }, - { value: 'flatten', iconSrc: '/icons/terrain-flatten.webp', hint: 'Flatten' }, - { value: 'smooth', iconSrc: '/icons/terrain-smooth.webp', hint: 'Smooth' }, +// Verb hints and labels are resolved via i18n at render time so this panel +// stays a presentational client of `useTranslations()`. +const VERB_OPTIONS: Array<{ + value: TerrainVerb + iconSrc: string + labelKey: string + hintKey: string +}> = [ + { value: 'raise', iconSrc: '/icons/terrain-raise.webp', labelKey: 'terrain.verb.raise', hintKey: 'terrain.hint.raise' }, + { value: 'lower', iconSrc: '/icons/terrain-lower.webp', labelKey: 'terrain.verb.lower', hintKey: 'terrain.hint.lower' }, + { + value: 'flatten', + iconSrc: '/icons/terrain-flatten.webp', + labelKey: 'terrain.verb.flatten', + hintKey: 'terrain.hint.flatten', + }, + { value: 'smooth', iconSrc: '/icons/terrain-smooth.webp', labelKey: 'terrain.verb.smooth', hintKey: 'terrain.hint.smooth' }, ] -const VERB_HINTS: Record = { - raise: `Drag to raise the ground. One pass moves it up to ${RAISE_METRES_PER_STROKE} m — release and drag again to go further.`, - lower: `Drag to lower the ground. One pass moves it down to ${RAISE_METRES_PER_STROKE} m.`, - flatten: 'Drag to level the ground toward the target height. It never overshoots.', - smooth: 'Drag to soften slopes and remove ridges. Flat ground stays flat.', +function resolveVerbHint(t: Translator, verb: TerrainVerb): string { + const metres = RAISE_METRES_PER_STROKE + switch (verb) { + case 'raise': + return t('terrain.hint.raise', { metres }) + case 'lower': + return t('terrain.hint.lower', { metres }) + case 'flatten': + return t('terrain.hint.flatten') + case 'smooth': + return t('terrain.hint.smooth') + default: + return '' + } } /** @@ -41,6 +63,7 @@ const VERB_HINTS: Record = { * `MaterialPaintPanel`. */ export function TerrainSculptPanel() { + const t = useTranslations() const verb = useEditor((state) => state.terrainVerb) const setTerrainVerb = useEditor((state) => state.setTerrainVerb) const brush = useEditor((state) => state.terrainBrush) @@ -63,7 +86,7 @@ export function TerrainSculptPanel() { setTerrainVerb(next)} - options={VERB_OPTIONS.map(({ value, iconSrc, hint }) => ({ + options={VERB_OPTIONS.map(({ value, iconSrc, labelKey }) => ({ value, label: ( @@ -76,13 +99,13 @@ export function TerrainSculptPanel() { src={iconSrc} width={28} /> - {hint} + {t(labelKey)} ), }))} value={verb} /> -

{VERB_HINTS[verb]}

+

{resolveVerbHint(t, verb)}

@@ -92,7 +115,7 @@ export function TerrainSculptPanel() { under it lands between samples and paints nothing at all. */} setTerrainBrush({ radius })} @@ -102,7 +125,7 @@ export function TerrainSculptPanel() { value={brush.radius} /> setTerrainBrush({ strength })} @@ -111,7 +134,7 @@ export function TerrainSculptPanel() { value={brush.strength} /> setTerrainBrush({ falloff })} @@ -122,8 +145,8 @@ export function TerrainSculptPanel() { setTerrainBrush({ shape })} options={[ - { value: 'round', label: 'Round' }, - { value: 'square', label: 'Square' }, + { value: 'round', label: t('terrain.brush.round') }, + { value: 'square', label: t('terrain.brush.square') }, ]} value={brush.shape} /> @@ -134,7 +157,7 @@ export function TerrainSculptPanel() {

{sampling - ? 'Click the ground to pick its height as the target.' + ? t('terrain.flatten.samplingHint') : flattenTarget === null - ? 'No target yet — the first click samples the ground under it.' - : 'Every flatten stroke levels toward this height.'} + ? t('terrain.flatten.noTargetHint') + : t('terrain.flatten.targetHint')}

)} @@ -175,7 +198,7 @@ export function TerrainSculptPanel() { variant="outline" > - Level lot + {t('terrain.levelLot')}
diff --git a/packages/editor/src/components/ui/controls/tool-options-panel.tsx b/packages/editor/src/components/ui/controls/tool-options-panel.tsx index 96c0807c75..6de2eadb26 100644 --- a/packages/editor/src/components/ui/controls/tool-options-panel.tsx +++ b/packages/editor/src/components/ui/controls/tool-options-panel.tsx @@ -2,6 +2,7 @@ import { nodeRegistry, type ToolOption } from '@pascal-app/core' import { useSyncExternalStore } from 'react' +import { useTranslations } from '../../../lib/i18n' import { cn } from '../../../lib/utils' import { triggerSFX } from '../../../lib/sfx-bus' @@ -10,6 +11,20 @@ const ALWAYS_VISIBLE = { value: () => true, } +/** Resolve a `*Key` override against `t()`, falling back to the static string. */ +function resolveText( + t: (key: string, vars?: Record) => string, + key: string | undefined, + fallback: string | undefined, +): string | undefined { + if (key) { + const translated = t(key) + // Echoed key = untranslated in the active locale; fall back. + if (translated !== key) return translated + } + return fallback +} + function ToolOptionRow({ option, onSelect, @@ -17,15 +32,17 @@ function ToolOptionRow({ option: ToolOption onSelect?: (option: ToolOption, value: string) => void }) { + const t = useTranslations() const visibility = option.visible ?? ALWAYS_VISIBLE const visible = useSyncExternalStore(visibility.subscribe, visibility.value, visibility.value) const value = useSyncExternalStore(option.subscribe, option.value, option.value) if (!visible) return null const activeChoice = option.choices.find((choice) => choice.value === value) + const rowLabel = resolveText(t, option.labelKey, option.label) ?? '' return (
-
{option.label}
+
{rowLabel}
{option.choices.map((choice) => { const active = choice.value === value + const choiceLabel = resolveText(t, choice.labelKey, choice.label) ?? '' return ( ) })}
- {activeChoice?.description ? ( -

- {activeChoice.description} -

+ {activeChoice ? ( + (() => { + const desc = resolveText(t, activeChoice.descriptionKey, activeChoice.description) + return desc ? ( +

{desc}

+ ) : null + })() ) : null}
) diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 18a3e93f78..79c3f5454c 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -43,13 +43,15 @@ import { buildLevelDuplicateCreateOps, type LevelDuplicatePreset, } from '../../lib/level-duplication' -import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core' +import { getDefaultLevelName } from '@pascal-app/core' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' +import { useTranslations } from '../../lib/i18n' import { useLinearDisplay } from '../../lib/use-linear-display' import { cn } from '../../lib/utils' import { ActionButton } from './controls/action-button' import { SliderControl } from './controls/slider-control' import { LevelDuplicateDialog } from './level-duplicate-dialog' +import { localizedLevelName } from '../../lib/level-display' import { Dialog, DialogContent, @@ -72,7 +74,8 @@ function LevelInlineRename({ onStopEditing: () => void }) { const updateNode = useScene((s) => s.updateNode) - const defaultName = getDefaultLevelName(level.level) + const t = useTranslations() + const defaultName = getDefaultLevelName(level.level, t) const [value, setValue] = useState(level.name || '') const inputRef = useRef(null) @@ -142,6 +145,7 @@ function LevelRow({ onPaste?: () => void onRequestDelete: () => void }) { + const t = useTranslations() const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) const updateNode = useScene((s) => s.updateNode) @@ -188,7 +192,7 @@ function LevelRow({ > {/* Storey height badge — opens the height popover */} @@ -223,7 +227,7 @@ function LevelRow({ {onPaste && ( )} @@ -389,6 +393,7 @@ function SortableLevelRow({ // ── Main component ────────────────────────────────────────────────────────── export function FloatingLevelSelector() { + const t = useTranslations() const selectedBuildingId = useViewer((s) => s.selection.buildingId) const levelId = useViewer((s) => s.selection.levelId) const setSelection = useViewer((s) => s.setSelection) @@ -581,7 +586,7 @@ export function FloatingLevelSelector() { // read only from outside: nothing here depends on it. data-guide-target="level-add" onClick={handleAddAbove} - title="Add level above" + title={t('level.addLevelAbove')} type="button" > @@ -593,7 +598,7 @@ export function FloatingLevelSelector() { diff --git a/packages/editor/src/components/ui/helpers/building-helper.tsx b/packages/editor/src/components/ui/helpers/building-helper.tsx index 3f4a24db2b..e9cfebcb40 100644 --- a/packages/editor/src/components/ui/helpers/building-helper.tsx +++ b/packages/editor/src/components/ui/helpers/building-helper.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from '../../../lib/i18n' import { ContextualHelperPanel } from './contextual-helper-panel' interface BuildingHelperProps { @@ -7,13 +8,14 @@ interface BuildingHelperProps { // Rotate is one hint with both keys (R / T) — never two separate // counterclockwise / clockwise rows — to match every other placement helper. export function BuildingHelper({ showRotate }: BuildingHelperProps) { + const t = useTranslations() return ( ) -} +} \ No newline at end of file diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index 778391b8ee..004fe44b8f 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -15,6 +15,7 @@ import { type SnapContext, } from '../../../lib/snapping-mode' import { cn } from '../../../lib/utils' +import { useTranslations } from '../../../lib/i18n' import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useFenceCurveDraft from '../../../store/use-fence-curve-draft' import { ShortcutToken } from '../primitives/shortcut-token' @@ -161,10 +162,10 @@ const SNAPPING_MODE_ICONS = { } as const const SNAPPING_MODE_LABELS = { - grid: 'Grid', - lines: 'Lines', - angles: 'Angles', - off: 'Off', + grid: 'helper.snapping.grid', + lines: 'helper.snapping.lines', + angles: 'helper.snapping.angles', + off: 'helper.snapping.off', } as const const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] @@ -177,38 +178,41 @@ function nextGridSnapStep(step: GridSnapStep): GridSnapStep { // The active interaction's snapping controls, scoped to its context (wall / item // / polygon) so each action shows only the modes that make sense for it. function SnappingChips({ context }: { context: SnapContext }) { + const t = useTranslations() const snappingMode = useEditor((s) => s.snappingModeByContext[context]) const setSnappingMode = useEditor((s) => s.setSnappingMode) const gridSnapStep = useEditor((s) => s.gridSnapStep) const setGridSnapStep = useEditor((s) => s.setGridSnapStep) const gridActive = resolveSnapFlags(snappingMode).grid + const modeLabel = t(SNAPPING_MODE_LABELS[snappingMode]) + const snappingLine = t('helper.snapping.line', { mode: modeLabel }) return ( <> { setSnappingMode(context, cycleSnappingModeIn(context, snappingMode)) sfxEmitter.emit('sfx:grid-snap') }} shortcut="Shift" - tooltip="Snapping mode — click or press Shift to cycle" + tooltip={t('helper.snapping.tooltip')} /> {gridActive ? ( { setGridSnapStep(nextGridSnapStep(gridSnapStep)) sfxEmitter.emit('sfx:grid-snap') }} shortcut="Ctrl" - tooltip="Grid step — click or tap Ctrl to cycle" + tooltip={t('helper.gridStep.tooltip')} /> ) : null} @@ -220,9 +224,19 @@ function SnappingChips({ context }: { context: SnapContext }) { // current value's label, and clicking the row (or the hint's key, handled by // the tool itself) cycles it. function ToolHintChipRow({ hint }: { hint: ToolHint & { chip: NonNullable } }) { + const t = useTranslations() const { chip } = hint const value = useSyncExternalStore(chip.subscribe, chip.value, chip.value) - const label = chip.labels[value] ?? hint.label + const valueLabel = chip.labels[value] + // Per-value label wins; resolve via `t()` when supplied as an i18n key, + // otherwise render verbatim. Fall back to the hint's own labelKey/label. + const label = valueLabel + ? /^[a-z][a-zA-Z]*\.[a-zA-Z.]+$/.test(valueLabel) && t(valueLabel) !== valueLabel + ? t(valueLabel) + : valueLabel + : hint.labelKey + ? t(hint.labelKey) + : hint.label return ( ) } function ContinuationChip({ context }: { context: ContinuationContext }) { + const t = useTranslations() const mode = useEditor((s) => s.getContinuation(context)) const cycleContinuation = useEditor((s) => s.cycleContinuation) const profile = CONTINUATION_PROFILES[context] - const label = profile.labels[mode] ?? mode + const labelKey = profile.labels[mode] ?? mode + const label = t(labelKey) const icon = profile.icons[mode] ?? 'lucide:repeat' return ( cycleContinuation(context)} shortcut="C" - tooltip="Continuation — click or press C to cycle" + tooltip={t('helper.continuation.tooltip')} /> ) } function FenceContinuationChips() { + const t = useTranslations() const mode = useEditor((s) => s.getContinuation('fence')) const setContinuation = useEditor((s) => s.setContinuation) const curveStarted = useFenceCurveDraft((s) => s.pointCount > 0) const isCurved = mode === 'curved' const straightMode = isCurved ? 'continuous' : mode - const straightLabel = straightMode === 'single' ? 'Straight: Single' : 'Straight: Continuous' + const straightStraightKey = + straightMode === 'single' ? 'helper.fence.straightSingle' : 'helper.fence.straightContinuous' + const straightLabel = t(straightStraightKey) const straightIcon = straightMode === 'single' ? 'lucide:minus' : 'lucide:waypoints' - const typeLabel = isCurved ? 'Type: Curved' : 'Type: Straight' + const typeLabelKey = isCurved ? 'helper.fence.typeCurved' : 'helper.fence.typeStraight' + const typeLabel = t(typeLabelKey) const typeIcon = isCurved ? 'lucide:spline' : 'lucide:minus' return ( <> setContinuation('fence', isCurved ? 'continuous' : 'curved')} shortcut="T" - tooltip="Fence type — click or press T to switch between straight and curved" + tooltip={t('helper.fence.typeTooltip')} /> {/* Curved fences are committed by a closing gesture rather than per-click, @@ -299,7 +325,7 @@ function FenceContinuationChips() { {isCurved && curveStarted ? ( ) : null} @@ -318,6 +344,7 @@ const PAINT_SCOPE_ICONS: Record = { // derived `paintHover` (scopes + labels), so it works for any kind without a // per-target table. function PaintScopeChip() { + const t = useTranslations() // What the cursor is over (that's what the next click paints). `null` when not // over a paintable surface — including an item with no slots. const paintHover = useEditor((s) => s.paintHover) @@ -329,13 +356,17 @@ function PaintScopeChip() { // Nothing to paint with yet (no material picked, not erasing) → the first step // is choosing a material, so say that before anything about scope or hovering. if (!(paintEraser || hasActivePaintMaterial(activePaintMaterial))) { - return + return } // Not over anything paintable → guide the user to hover, still teaching Shift. if (!paintHover) { return ( - + ) } @@ -343,26 +374,23 @@ function PaintScopeChip() { // A scope carried over from another node (the mode is global) falls back to // the narrowest for both display and — via the apply-time resolver — behaviour. const effective: PaintScope = scopes.includes(paintScope) ? paintScope : 'single' + const scopeLabel = paintScopeLabel(effective, paintHover, t) + const paintLine = t('helper.paint.line', { scope: scopeLabel }) // Paintable but with no scope choice (roof, a one-slot node, …) → a passive // row that still names the surface, so the user always sees what they'll paint. if (scopes.length <= 1) { - return ( - - ) + return } return ( cyclePaintScope()} shortcut="Shift" - tooltip="Paint scope — click or press Shift to cycle" + tooltip={t('helper.paint.scopeTooltip')} /> ) } @@ -384,6 +412,7 @@ export function ContextualHelperPanel({ showPaintScope?: boolean continuationContext?: ContinuationContext | null }) { + const t = useTranslations() if ( hints.length === 0 && chipHints.length === 0 && @@ -422,11 +451,11 @@ export function ContextualHelperPanel({ hint.active ? 'font-medium text-white' : 'text-muted-foreground', )} > - {hint.label} + {hint.labelKey ? t(hint.labelKey) : hint.label}
- {hint.subtitle ? ( + {hint.subtitle || hint.subtitleKey ? (
- {hint.subtitle} + {hint.subtitleKey ? t(hint.subtitleKey) : hint.subtitle}
) : null} diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index ed00ec1daf..bb2f0a810e 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -25,6 +25,7 @@ import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-mani import type { ReshapeKind } from '../../../lib/interaction/scope' import { isFreshPlacementMetadata } from '../../../lib/placement-metadata' import { snapContextOf } from '../../../lib/snapping-mode' +import { useTranslations, type Translator } from '../../../lib/i18n' import useEditor, { getActiveContinuationContext } from '../../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, @@ -37,20 +38,20 @@ import { RegisteredToolHelper } from './registered-tool-helper' // Reshaping a selected node's geometry (endpoint / curve / polygon corner). The // snapping chip is the main control; these just name the gesture + Esc. -function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] { - const action = +function reshapingHints(reshape: ReshapeKind, t: Translator): ContextualShortcutHint[] { + const actionKey = reshape === 'curve' - ? 'Curve' + ? 'contextualHelp.reshape.curve' : reshape === 'control-point' - ? 'Move control point' + ? 'contextualHelp.reshape.controlPoint' : reshape === 'tangent' - ? 'Move tangent' + ? 'contextualHelp.reshape.tangent' : reshape === 'endpoint' - ? 'Move endpoint' - : 'Move corner' + ? 'contextualHelp.reshape.endpoint' + : 'contextualHelp.reshape.corner' return [ - { keys: ['Drag'], label: action }, - { keys: ['Esc'], label: 'Cancel' }, + { keys: ['Drag'], label: t(actionKey) }, + { keys: ['Esc'], label: t('common.cancel') }, ] } @@ -59,27 +60,31 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] { // brush ring now carries the verb in colour (`brushRingColor`): the ring says // *which* verb only to someone who already knows the mapping, and the HUD is what // teaches it. -function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualShortcutHint[] { +function terrainSculptHints( + verb: TerrainVerb, + sampling: boolean, + t: Translator, +): ContextualShortcutHint[] { if (sampling) { return [ - { keys: ['Click'], label: 'Pick target height' }, - { keys: ['Esc'], label: 'Cancel picking' }, + { keys: ['Click'], label: t('contextualHelp.sculpt.pickTarget') }, + { keys: ['Esc'], label: t('contextualHelp.sculpt.cancelPick') }, ] } - const action = + const actionKey = verb === 'raise' - ? 'Raise ground' + ? 'contextualHelp.sculpt.raise' : verb === 'lower' - ? 'Lower ground' + ? 'contextualHelp.sculpt.lower' : verb === 'flatten' - ? 'Level ground' - : 'Smooth ground' + ? 'contextualHelp.sculpt.flatten' + : 'contextualHelp.sculpt.smooth' return [ - { keys: ['Drag'], label: action }, + { keys: ['Drag'], label: t(actionKey) }, // Nested, so the two render as alternatives ("[ / ]") rather than a chord — // a flat `['[', ']']` joins with "+" and would read as "press both". - { keys: [['[', ']']], label: 'Brush size' }, - { keys: ['Esc'], label: 'Cancel stroke' }, + { keys: [['[', ']']], label: t('contextualHelp.sculpt.brushSize') }, + { keys: ['Esc'], label: t('contextualHelp.sculpt.cancelStroke') }, ] } @@ -126,6 +131,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { } export function HelperManager() { + const t = useTranslations() const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) const terrainVerb = useEditor((s) => s.terrainVerb) @@ -228,7 +234,9 @@ export function HelperManager() { if (activeHandleDrag?.label === GROUP_MOVE_DRAG_LABEL) { return ( ) @@ -242,8 +250,8 @@ export function HelperManager() { return ( @@ -254,7 +262,7 @@ export function HelperManager() { // before the select branch so the idle "drag selected / add objects" hints // never leak over an in-progress reshape — and it gets its own snapping chip. if (scope.kind === 'reshaping') { - return + return } if (movingNode) { @@ -290,7 +298,7 @@ export function HelperManager() { // mode-specific — bracket resize, and an Esc that abandons the stroke rather // than exiting the mode. if (mode === 'terrain-sculpt') { - return + return } if (scope.kind === 'mesh-editing') { @@ -307,9 +315,9 @@ export function HelperManager() { return ( ) diff --git a/packages/editor/src/components/ui/helpers/item-helper.tsx b/packages/editor/src/components/ui/helpers/item-helper.tsx index 02138eb9b1..3047e5f079 100644 --- a/packages/editor/src/components/ui/helpers/item-helper.tsx +++ b/packages/editor/src/components/ui/helpers/item-helper.tsx @@ -1,4 +1,5 @@ import type { ContinuationContext } from '../../../lib/continuation' +import { useTranslations } from '../../../lib/i18n' import type { SnapContext } from '../../../lib/snapping-mode' import { ContextualHelperPanel } from './contextual-helper-panel' @@ -21,16 +22,17 @@ export function ItemHelper({ showForce, continuationContext = null, }: ItemHelperProps) { + const t = useTranslations() return ( ) -} +} \ No newline at end of file diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 85483fa208..aa1692bee4 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,6 +1,7 @@ import type { ToolHint } from '@pascal-app/core' import { useMemo, useSyncExternalStore } from 'react' import type { ContinuationContext } from '../../../lib/continuation' +import { useTranslations } from '../../../lib/i18n' import type { SnapContext } from '../../../lib/snapping-mode' import useEditor from '../../../store/use-editor' import { ContextualHelperPanel } from './contextual-helper-panel' @@ -25,6 +26,7 @@ export function RegisteredToolHelper({ snapContext?: SnapContext | null continuationContext?: ContinuationContext | null }) { + const t = useTranslations() // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. const draftVertexCount = useEditor((s) => s.draftVertexCount) @@ -67,9 +69,18 @@ export function RegisteredToolHelper({ // Shift is a per-kind bypass for opening / zone / duct placement ("Free // place", "Free angle", …) — those flip to a bypassed state while held. const isBypassHint = hint.key === 'Shift' + const resolvedLabel = hint.labelKey + ? t(hint.labelKey) + : t(hint.label) !== hint.label + ? t(hint.label) + : hint.label return { keys: [hint.key], - label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, + label: + shiftPressed && isBypassHint + ? t('editor.guidedConstraintsBypassed') + : resolvedLabel, + labelKey: hint.labelKey, active: shiftPressed && isBypassHint, } })} diff --git a/packages/editor/src/components/ui/level-duplicate-dialog.tsx b/packages/editor/src/components/ui/level-duplicate-dialog.tsx index 413f60bb92..23b150c055 100644 --- a/packages/editor/src/components/ui/level-duplicate-dialog.tsx +++ b/packages/editor/src/components/ui/level-duplicate-dialog.tsx @@ -3,8 +3,8 @@ import type { LevelNode } from '@pascal-app/core' import { useEffect, useState } from 'react' import type { LevelDuplicatePreset } from '../../lib/level-duplication' -import { getLevelDisplayName } from '@pascal-app/core' import { cn } from '../../lib/utils' +import { useTranslations } from '../../lib/i18n' import { Dialog, DialogContent, @@ -16,34 +16,37 @@ import { const DUPLICATE_PRESETS: Array<{ id: LevelDuplicatePreset - label: string - description: string + labelKey: string + descKey: string }> = [ { id: 'everything', - label: 'Everything', - description: 'Structure, materials, furniture, and references.', + labelKey: 'levelDuplicate.everything', + descKey: 'levelDuplicate.everythingDesc', }, { id: 'structure', - label: 'Structure only', - description: 'Walls, slabs, roofs, stairs, windows, and doors without finishes.', + labelKey: 'levelDuplicate.structure', + descKey: 'levelDuplicate.structureDesc', }, { id: 'structure-materials', - label: 'Structure + materials', - description: 'Structure with the current material and finish assignments.', + labelKey: 'levelDuplicate.structureMaterials', + descKey: 'levelDuplicate.structureMaterialsDesc', }, { id: 'structure-furniture', - label: 'Structure + furniture', - description: 'Structure, finishes, and placed items, without guide references.', + labelKey: 'levelDuplicate.structureFurniture', + descKey: 'levelDuplicate.structureFurnitureDesc', }, ] -function getLevelLabel(level: LevelNode | null) { - if (!level) return 'this level' - return getLevelDisplayName(level) +function getLevelLabel(level: LevelNode | null, t: (key: string, params?: Record) => string) { + if (!level) return t('level.thisLevel') + if (level.name) return level.name + if (level.level === 0) return t('level.groundFloor') + if (level.level > 0) return t('level.floor', { n: level.level }) + return t('level.basement', { n: -level.level }) } export function LevelDuplicateDialog({ @@ -58,6 +61,7 @@ export function LevelDuplicateDialog({ onOpenChange: (open: boolean) => void }) { const [preset, setPreset] = useState('everything') + const t = useTranslations() useEffect(() => { if (open) { @@ -69,8 +73,8 @@ export function LevelDuplicateDialog({ - Duplicate Level - Choose what to copy from {getLevelLabel(level)}. + {t('levelDuplicate.duplicateLevel')} + {t('levelDuplicate.chooseWhatToCopy', { level: getLevelLabel(level, t) })}
@@ -86,8 +90,8 @@ export function LevelDuplicateDialog({ onClick={() => setPreset(option.id)} type="button" > -
{option.label}
-
{option.description}
+
{t(option.labelKey)}
+
{t(option.descKey)}
))}
@@ -98,14 +102,14 @@ export function LevelDuplicateDialog({ onClick={() => onOpenChange(false)} type="button" > - Cancel + {t('levelDuplicate.cancel')}
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 8239ea48a4..685f01e908 100644 --- a/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx +++ b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx @@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'motion/react' import Image from 'next/image' import { type ReactNode, useEffect, useState } from 'react' import { createPortal } from 'react-dom' +import { useTranslations } from '../../../lib/i18n' import useEditor from '../../../store/use-editor' interface MobilePanelSheetProps { @@ -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 = useTranslations() const [mounted, setMounted] = useState(false) const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight) @@ -88,7 +90,7 @@ export function MobilePanelSheet({ open, onClose, icon, title, children }: Mobil - + this.setState({ hasError: false, error: null })} + /> ) } return this.props.children } } + +function ErrorBoundaryFallback({ + errorMessage, + onReset, +}: { + errorMessage?: string + onReset: () => void +}) { + const t = useTranslations() + return ( +
+

{t('editor.somethingWentWrong')}

+
+        {errorMessage}
+      
+ +
+ ) +} diff --git a/packages/editor/src/components/ui/primitives/input.tsx b/packages/editor/src/components/ui/primitives/input.tsx index e1be92daf4..00e07f8790 100644 --- a/packages/editor/src/components/ui/primitives/input.tsx +++ b/packages/editor/src/components/ui/primitives/input.tsx @@ -1,3 +1,5 @@ +'use client' + import type * as React from 'react' import { cn } from '../../../lib/utils' diff --git a/packages/editor/src/components/ui/primitives/shortcut-token.tsx b/packages/editor/src/components/ui/primitives/shortcut-token.tsx index 84c8d34bc0..b779e38ea5 100644 --- a/packages/editor/src/components/ui/primitives/shortcut-token.tsx +++ b/packages/editor/src/components/ui/primitives/shortcut-token.tsx @@ -1,26 +1,33 @@ +'use client' + import { Icon } from '@iconify/react' import type * as React from 'react' import { cn } from '../../../lib/utils' +import { useTranslations } from '../../../lib/i18n' -const MOUSE_SHORTCUTS = { +// Mouse + key labels live as i18n keys so screen-reader / tooltip text +// follows the active locale. The map below is keyed on the raw string passed +// to ; on the component side the renderer looks up the +// matching label via `t()`. +const MOUSE_SHORTCUTS: Record = { Click: { icon: 'ph:mouse-left-click-fill', - label: 'Left click', + labelKey: 'keys.leftClick', }, 'Left click': { icon: 'ph:mouse-left-click-fill', - label: 'Left click', + labelKey: 'keys.leftClick', }, 'Middle click': { icon: 'qlementine-icons:mouse-middle-button-16', - label: 'Middle click', + labelKey: 'keys.middleClick', }, 'Right click': { icon: 'ph:mouse-right-click-fill', - label: 'Right click', + labelKey: 'keys.rightClick', }, -} as const +} // The platform-agnostic command modifier. Both Cmd and Ctrl bind the action; we // render the symbol for the *current* device so the hint reads native (⌘ on Mac, @@ -61,25 +68,28 @@ type ShortcutTokenProps = React.ComponentProps<'kbd'> & { } function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) { + const t = useTranslations() const mouseShortcut = - value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null + value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value] : null const isCommand = COMMAND_VALUES.has(value) const isShift = value === 'Shift' const commandDisplay = IS_MAC ? '⌘' : 'Ctrl' - const commandLabel = IS_MAC ? 'Command' : 'Control' + const commandLabel = IS_MAC ? t('keys.commandMac') : t('keys.control') + const shiftLabel = t('keys.shift') + const mouseLabel = mouseShortcut ? t(mouseShortcut.labelKey) : null return ( {mouseShortcut ? ( @@ -92,7 +102,7 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok icon={mouseShortcut.icon} width={14} /> - {mouseShortcut.label} + {mouseLabel} ) : isShift ? ( // Icon rather than the ⇧ text glyph — the font renders the glyph's @@ -106,7 +116,7 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok icon="ph:arrow-fat-up" width={13} /> - Shift + {shiftLabel} ) : isCommand ? ( // The ⌘ glyph reads small next to letters at the same font size, so bump diff --git a/packages/editor/src/components/ui/primitives/sidebar.tsx b/packages/editor/src/components/ui/primitives/sidebar.tsx index 0231fd2a12..1e4010fa48 100644 --- a/packages/editor/src/components/ui/primitives/sidebar.tsx +++ b/packages/editor/src/components/ui/primitives/sidebar.tsx @@ -24,6 +24,7 @@ import { TooltipTrigger, } from './../../../components/ui/primitives/tooltip' import { useIsMobile } from './../../../hooks/use-mobile' +import { useTranslations } from './../../../lib/i18n' import { cn } from './../../../lib/utils' const SIDEBAR_COOKIE_NAME = 'sidebar_state' @@ -246,6 +247,7 @@ function Sidebar({ variant?: 'sidebar' | 'floating' | 'inset' collapsible?: 'offcanvas' | 'icon' | 'none' }) { + const t = useTranslations() const { isMobile, state, openMobile, setOpenMobile } = useSidebar() if (collapsible === 'none') { @@ -280,7 +282,7 @@ function Sidebar({ > Sidebar - Displays the mobile sidebar. + {t('editor.displaysMobileSidebar')}
{children}
@@ -363,10 +365,11 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { const { toggleSidebar } = useSidebar() + const t = useTranslations() return ( - {panel.label} + {label} ) })} @@ -127,6 +132,7 @@ export function IconRail({ {/* Settings panel */} {[settingsPanel].map((panel) => { const isActive = activePanel === panel.id + const label = labelOf(panel) return ( @@ -139,7 +145,7 @@ export function IconRail({ type="button" > {panel.label} - {panel.label} + {label} ) })} @@ -163,4 +169,4 @@ export function IconRail({ ) } -export { panels } +export { panels } \ No newline at end of file diff --git a/packages/editor/src/components/ui/sidebar/panels/file-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/file-panel/index.tsx new file mode 100644 index 0000000000..25ee098770 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/file-panel/index.tsx @@ -0,0 +1,39 @@ +'use client' + +import Link from 'next/link' +import { FolderOpen, Plus } from 'lucide-react' +import { messages, useLocale } from '../../../../../lib/i18n' +import { Button } from '../../../../../components/ui/primitives/button' + +export function FilePanel() { + const { locale } = useLocale() + const t = (key: string) => (messages[locale] as Record)[key] || key + + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx b/packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx index f11028a217..cd4e3c2a53 100644 --- a/packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx @@ -12,19 +12,42 @@ import { TooltipProvider, TooltipTrigger, } from '../../../../../components/ui/primitives/tooltip' +import { messages, useLocale } from '../../../../../lib/i18n' /** A function-axis taxonomy node, assembled into a tree by the embedder. */ export type FunctionTreeNode = { slug: string name: string + /** + * Optional i18n key for `name`. If provided, the panel resolves the display + * label through the active locale's translation table; otherwise `name` is + * used verbatim (suitable for embedder-supplied taxonomies that already come + * pre-localised server-side). + */ + nameKey?: string iconUrl?: string | null children: FunctionTreeNode[] } -const SOURCE_CHIPS: Array<{ id: NonNullable; label: string }> = [ - { id: 'library', label: 'Library' }, - { id: 'community', label: 'Community' }, - { id: 'mine', label: 'Mine' }, +/** Resolve a tree node's name, preferring the i18n key when supplied. */ +function resolveNodeLabel( + node: FunctionTreeNode, + t: (key: string, vars?: Record) => string, +): string { + if (node.nameKey) { + const translated = t(node.nameKey) + // `t` falls back to the key itself when missing, which would surface as a + // raw `functionTree.kitchen` tooltip. Detect that and prefer the human + // name in that case. + if (translated && translated !== node.nameKey) return translated + } + return node.name +} + +const SOURCE_CHIPS: Array<{ id: NonNullable; labelKey: string }> = [ + { id: 'library', labelKey: 'items.library' }, + { id: 'community', labelKey: 'items.community' }, + { id: 'mine', labelKey: 'items.mine' }, ] /** Every slug at or below `node`, so a non-leaf selection matches descendants. */ @@ -70,6 +93,14 @@ export function FunctionTreePanel({ const [activeChildSlug, setActiveChildSlug] = useState(null) const [activeSource, setActiveSource] = useState('library') const [search, setSearch] = useState('') + const { locale } = useLocale() + const t = (key: string, params?: Record) => { + const str = (messages[locale] as Record)[key] || key + if (!params) return str + return Object.entries(params).reduce( + (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)), str, + ) + } const isServerSearch = onSearchChange !== undefined const isSearchPending = isServerSearch && search.length > 0 && searchResults === null @@ -128,6 +159,7 @@ export function FunctionTreePanel({
{functionTree.map((root) => { const isActive = activeRoot?.slug === root.slug + const label = resolveNodeLabel(root, t) return ( @@ -147,7 +179,7 @@ export function FunctionTreePanel({ > {root.iconUrl ? ( ) : ( - {root.name.slice(0, 2)} + {label.slice(0, 2)} )} - {root.name} + {label} ) @@ -178,7 +210,7 @@ export function FunctionTreePanel({ setSearch(e.target.value) onSearchChange?.(e.target.value) }} - placeholder="Search..." + placeholder={t('items.search')} type="text" value={search} /> @@ -197,7 +229,7 @@ export function FunctionTreePanel({ onClick={() => setActiveSource(isActive ? null : chip.id)} type="button" > - {chip.label} + {t(chip.labelKey)} ) })} @@ -217,7 +249,7 @@ export function FunctionTreePanel({ onClick={() => setActiveChildSlug(null)} type="button" > - All + {t('items.all')} {activeRoot.children.map((child) => { const isActive = activeChildSlug === child.slug @@ -233,7 +265,7 @@ export function FunctionTreePanel({ onClick={() => setActiveChildSlug(isActive ? null : child.slug)} type="button" > - {child.name} + {resolveNodeLabel(child, t)} ) })} @@ -250,7 +282,7 @@ export function FunctionTreePanel({ ) : isServerSearch && search && searchResults?.length === 0 ? ( (emptyState ?? (
- No results for “{search}” + {t('items.noResults', { search })}
)) ) : ( diff --git a/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx index df81988a6f..cac5bcd770 100644 --- a/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx @@ -8,6 +8,7 @@ import { cn } from '../../../../../lib/utils' import type { CatalogCategory } from '../../../../../store/use-editor' import useEditor from '../../../../../store/use-editor' import { furnishTools } from '../../../action-menu/furnish-tools' +import { messages, useLocale } from '../../../../../lib/i18n' import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items' import { ItemCatalog } from '../../../item-catalog/item-catalog' import { type FunctionTreeNode, FunctionTreePanel } from './function-tree-panel' @@ -98,6 +99,14 @@ function LegacyItemsPanel({ showSourceFilter?: boolean showTagFilters?: boolean }) { + const { locale } = useLocale() + const t = (key: string, params?: Record) => { + const str = (messages[locale] as Record)[key] || key + if (!params) return str + return Object.entries(params).reduce( + (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)), str, + ) + } const mode = useEditor((s) => s.mode) const catalogCategory = useEditor((s) => s.catalogCategory) const setMode = useEditor((s) => s.setMode) @@ -166,10 +175,10 @@ function LegacyItemsPanel({ // The three source chips are always shown so users can discover the // filter even before they own any items. Selecting "Mine" with no // matching items falls through to the empty/no-results state. - const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [ - { id: 'library', label: 'Library' }, - { id: 'community', label: 'Community' }, - { id: 'mine', label: 'Mine' }, + const sourceChips: Array<{ id: AssetInput['source']; labelKey: string }> = [ + { id: 'library', labelKey: 'items.library' }, + { id: 'community', labelKey: 'items.community' }, + { id: 'mine', labelKey: 'items.mine' }, ] const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? []))) const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t)) @@ -215,13 +224,13 @@ function LegacyItemsPanel({ type="button" > - {cat.label} + {t(cat.labelKey)} ) })} @@ -242,7 +251,7 @@ function LegacyItemsPanel({ setSearch(e.target.value) onSearchChange?.(e.target.value) }} - placeholder="Search..." + placeholder={t('items.search')} type="text" value={search} /> @@ -262,7 +271,7 @@ function LegacyItemsPanel({ onClick={() => setActiveSource(isActive ? null : chip.id)} type="button" > - {chip.label} + {t(chip.labelKey)} ) })} @@ -284,7 +293,7 @@ function LegacyItemsPanel({ onClick={() => setActivePlacementTag(null)} type="button" > - All + {t('items.all')} {placementTags.map((tag) => { const count = placementCount(tag) @@ -376,7 +385,7 @@ function LegacyItemsPanel({ ) : isServerSearch && search && searchResults?.length === 0 ? ( (emptyState ?? (
- No results for “{search}” + {t('items.noResults', { search })}
)) ) : ( 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 dbe1446cea..6438b99d5a 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 @@ -9,9 +9,12 @@ import { DialogTrigger, } from '../../../../../components/ui/primitives/dialog' import { Slider } from '../../../../../components/ui/slider' +import { messages, useLocale } from '../../../../../lib/i18n' import useAudio from '../../../../../store/use-audio' export function AudioSettingsDialog() { + const { locale } = useLocale() + const t = (key: string) => (messages[locale] as Record)[key] || key const { masterVolume, sfxVolume, @@ -28,19 +31,19 @@ export function AudioSettingsDialog() { - Audio Settings - Adjust volume levels and mute settings + {t('settings.audioSettings')} + {t('settings.adjustVolume')}
{/* Master Volume */}
- + {masterVolume}%
- + {radioVolume}%
- + {sfxVolume}%
{muted ? : } - {muted ? 'Unmute All Sounds' : 'Mute All Sounds'} + {muted ? t('settings.unmuteAllSounds') : t('settings.muteAllSounds')}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 3baefe5a6f..6e4f9a03d0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -18,6 +18,7 @@ import { useState, } from 'react' import { exportFloorplanPdf } from '../../../../../lib/floorplan/floorplan-export' +import { useTranslations } from '../../../../../lib/i18n' import { Button } from './../../../../../components/ui/primitives/button' import { Dialog, @@ -187,6 +188,7 @@ export function SettingsPanel({ projectVisibility, onVisibilityChange, }: SettingsPanelProps = {}) { + const t = useTranslations() const fileInputRef = useRef(null) const copyResetTimeoutRef = useRef(null) const nodes = useScene((state) => state.nodes) @@ -270,7 +272,7 @@ export function SettingsPanel({ { severity: 'error', code: 'invalid_json', - message: 'File could not be parsed as JSON.', + message: t('loadBuild.invalidJson'), }, ], warnings: [], @@ -362,17 +364,21 @@ export function SettingsPanel({
{projectId && (
- -
Project ID
+ +
{t('settings.projectId')}
@@ -397,12 +403,15 @@ export function SettingsPanel({ {/* Visibility Section (only for cloud projects) */} {projectId && !isLocalProject && (
- +
-
Public
+
{t('settings.visibilityPublic')}
- {projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view + {projectVisibility?.isPrivate + ? t('settings.visibilityOnlyYou') + : t('settings.visibilityAnyone')}{' '} + {t('settings.visibilityCanView')}
-
Show 3D Scans
-
Visible to public viewers
+
{t('settings.visibilityShow3DScans')}
+
{t('settings.visibilityVisiblePublic')}
-
Show Floorplans
-
Visible to public viewers
+
{t('settings.visibilityShowFloorplans')}
+
{t('settings.visibilityVisiblePublic')}
-
Shadows
-
Cast shadows from lights
+
{t('settings.shadows')}
+
{t('settings.visibilityCastShadows')}
- +
-
3D model
+
{t('settings.export3DModel')}
-
Visible nodes only
+
{t('settings.visibleNodesOnly')}
- Exclude hidden furniture and other hidden scene nodes + {t('settings.visibleNodesOnlyDesc')}
@@ -464,7 +473,7 @@ export function SettingsPanel({ variant="outline" > - Export GLB + {t('settings.exportedGLB')} @@ -488,8 +497,8 @@ export function SettingsPanel({
- Floor plan - {floorplanMode === 'default' ? 'Default mode' : 'Expert mode'} + {t('settings.floorPlan')} + {floorplanMode === 'default' ? t('settings.floorplanDefault') : t('settings.floorplanExpert')}
@@ -513,7 +522,7 @@ export function SettingsPanel({ {/* Thumbnail Section (only for cloud projects) */} {projectId && !isLocalProject && (
- +
)} {/* Save/Load Section */}
- + - +
{/* Keyboard Section */}
- +
{/* Scene Graph */}
- + - Scene Graph + {t('settings.sceneGraph')}
- +
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 b81d279ff7..9777cde76c 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 @@ -1,4 +1,5 @@ import { Keyboard } from 'lucide-react' +import { useTranslations } from '../../../../../lib/i18n' import { Button } from './../../../../../components/ui/primitives/button' import { Dialog, @@ -15,193 +16,190 @@ import { type Shortcut = { keys: string[] - action: string - note?: string + actionKey: string + noteKey?: string } type ShortcutCategory = { - title: string + titleKey: string shortcuts: Shortcut[] } const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { - title: 'Editor Navigation', + titleKey: 'shortcuts.editorNavigation', shortcuts: [ - { keys: ['1'], action: 'Switch to Site phase' }, - { keys: ['2'], action: 'Switch to Structure phase' }, - { keys: ['3'], action: 'Switch to Furnish phase' }, - { keys: ['F'], action: 'Switch to Furnish layer' }, - { keys: ['Z'], action: 'Switch to Zones layer' }, + { keys: ['1'], actionKey: 'shortcuts.switchToSitePhase' }, + { keys: ['2'], actionKey: 'shortcuts.switchToStructurePhase' }, + { keys: ['3'], actionKey: 'shortcuts.switchToFurnishPhase' }, + { keys: ['F'], actionKey: 'shortcuts.switchToFurnishLayer' }, + { keys: ['Z'], actionKey: 'shortcuts.switchToZonesLayer' }, { keys: ['Cmd/Ctrl', 'Arrow Up'], - action: 'Select next level in the active building', + actionKey: 'shortcuts.selectNextLevel', }, { keys: ['Cmd/Ctrl', 'Arrow Down'], - action: 'Select previous level in the active building', + actionKey: 'shortcuts.selectPreviousLevel', }, - { keys: ['Cmd/Ctrl', 'B'], action: 'Toggle sidebar' }, + { keys: ['Cmd/Ctrl', 'B'], actionKey: 'shortcuts.toggleSidebar' }, ], }, { - title: 'Modes & History', + titleKey: 'shortcuts.modesAndHistory', shortcuts: [ - { keys: ['V'], action: 'Switch to Select mode' }, - { keys: ['B'], action: 'Switch to Build mode' }, - { keys: ['M'], action: 'Activate the last measurement tool' }, - { keys: ['X'], action: 'Switch to Delete mode' }, + { keys: ['V'], actionKey: 'shortcuts.switchToSelectMode' }, + { keys: ['B'], actionKey: 'shortcuts.switchToBuildMode' }, + { keys: ['M'], actionKey: 'shortcuts.activateMeasurementTool' }, + { keys: ['X'], actionKey: 'shortcuts.switchToDeleteMode' }, { keys: ['Esc'], - action: 'Cancel the active tool and return to Select mode', - note: 'Mid-draw it cancels only the chain in progress and keeps the tool armed; press it again to leave the tool.', + actionKey: 'shortcuts.cancelTool', + noteKey: 'shortcuts.cancelToolNote', }, - { keys: ['Delete / Backspace'], action: 'Delete selected objects' }, - { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' }, - { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' }, + { keys: ['Delete / Backspace'], actionKey: 'shortcuts.deleteSelected' }, + { keys: ['Cmd/Ctrl', 'Z'], actionKey: 'shortcuts.undo' }, + { keys: ['Cmd/Ctrl', 'Shift', 'Z'], actionKey: 'shortcuts.redo' }, ], }, { - title: 'Selection', + titleKey: 'shortcuts.selection', shortcuts: [ { keys: ['Cmd/Ctrl', 'C'], - action: 'Copy the selected objects', - note: 'The copied selection can be pasted into another level, project, or browser tab.', + actionKey: 'shortcuts.copySelection', + noteKey: 'shortcuts.copySelectionNote', }, { keys: ['Cmd/Ctrl', 'X'], - action: 'Cut the selected objects', - note: 'Copies the selection to the clipboard, then removes it from this scene.', + actionKey: 'shortcuts.cutSelection', + noteKey: 'shortcuts.cutSelectionNote', }, { keys: ['Cmd/Ctrl', 'V'], - action: 'Paste and place copied objects', - note: - 'Carries a preview under the cursor. Click to place it, or press Escape to cancel.', + actionKey: 'shortcuts.pasteSelection', + noteKey: 'shortcuts.pasteSelectionNote', }, { keys: ['Cmd/Ctrl', 'Left click'], - action: 'Add or remove an object from multi-selection', - note: 'Works in Select mode on the 3D canvas, the 2D floor plan, and the scene graph.', + actionKey: 'shortcuts.addToSelection', + noteKey: 'shortcuts.addToSelectionNote', }, { keys: ['Shift', 'Left click'], - action: 'Add or remove an object from canvas multi-selection', - note: 'In the scene graph, Shift-click selects the visible range like a file browser.', + actionKey: 'shortcuts.addToCanvasSelection', + noteKey: 'shortcuts.addToCanvasSelectionNote', }, { keys: ['Left click'], - action: 'Move the whole multi-selection', - note: - '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.', + actionKey: 'shortcuts.moveMultiSelection', + noteKey: 'shortcuts.moveMultiSelectionNote', }, { keys: ['R', 'T'], - action: 'Rotate a multi-selection ±45° around its center', - note: 'Also works mid-move while carrying the selection.', + actionKey: 'shortcuts.rotateMultiSelection', + noteKey: 'shortcuts.rotateMultiSelectionNote', }, { keys: ['Cmd/Ctrl', 'G'], - action: 'Group the multi-selection (session only)', - note: - 'Editor-only. Plain click a member later to reselect the whole group. Not saved with the project.', + actionKey: 'shortcuts.groupSelection', + noteKey: 'shortcuts.groupSelectionNote', }, { keys: ['Cmd/Ctrl', 'Shift', 'G'], - action: 'Ungroup the session selection', - note: 'Keeps the current selection; only dissolves the session group.', + actionKey: 'shortcuts.ungroupSelection', + noteKey: 'shortcuts.ungroupSelectionNote', }, { keys: ['Esc'], - action: 'Clear the selection', - note: 'Clicking empty space does the same.', + actionKey: 'shortcuts.clearSelection', + noteKey: 'shortcuts.clearSelectionNote', }, ], }, { - title: 'Direct Manipulation', + titleKey: 'shortcuts.directManipulation', shortcuts: [ { keys: ['Cmd/Ctrl', 'Left click'], - action: 'Move the selected movable object under the cursor', - note: 'Drag in Select mode with a single object selected. Guided snapping and guides are enabled by default.', + actionKey: 'shortcuts.moveUnderCursor', + noteKey: 'shortcuts.moveUnderCursorNote', }, { keys: ['Cmd/Ctrl', 'Right click'], - action: 'Rotate the selected object under the cursor', - note: 'Drag left or right in Select mode with a single object selected. Rotation snaps to 15° increments by default.', + actionKey: 'shortcuts.rotateUnderCursor', + noteKey: 'shortcuts.rotateUnderCursorNote', }, { keys: ['Cmd/Ctrl', 'Shift', 'Right click'], - action: 'Rotate freely', - note: 'Hold Shift during the drag to bypass the 15° rotation increment.', + actionKey: 'shortcuts.rotateFreely', + noteKey: 'shortcuts.rotateFreelyNote', }, ], }, { - title: 'Drawing Tools', + titleKey: 'shortcuts.drawingTools', shortcuts: [ // Shift and Ctrl each mean one thing held and another tapped, and only // the hold was documented — which read as the taps not existing. Both // taps are listed first because they are the ones nobody discovers. { keys: ['Shift'], - action: 'Cycle the snapping mode', - note: 'Tap and release without pressing anything else, while a drawing or move gesture is available.', + actionKey: 'shortcuts.cycleSnapMode', + noteKey: 'shortcuts.cycleSnapModeNote', }, { keys: ['Cmd/Ctrl'], - action: 'Cycle the grid step: 0.5 m → 0.25 m → 0.1 m → 0.05 m', - note: 'Tap and release on its own. Use it when the default half-metre grid is too coarse — placing a window, for instance.', + actionKey: 'shortcuts.cycleGridStep', + noteKey: 'shortcuts.cycleGridStepNote', }, { keys: ['Shift'], - action: 'Bypass guided snapping and angle constraints', - note: 'Hold during the active gesture. Passive guide or measurement feedback may stay visible.', + actionKey: 'shortcuts.bypassGuidedConstraints', + noteKey: 'shortcuts.bypassGuidedConstraintsNote', }, { keys: ['Shift'], - action: 'Rotate freely, bypassing the default 15° rotation snap', - note: 'Hold while dragging a rotate handle or direct-rotation gesture.', + actionKey: 'shortcuts.bypassRotationSnap', + noteKey: 'shortcuts.bypassRotationSnapNote', }, ], }, { - title: 'Item Placement', + titleKey: 'shortcuts.itemPlacement', shortcuts: [ { keys: ['R', 'T'], - action: 'Rotate item; with a door selected, R toggles open/closed and T closes', + actionKey: 'shortcuts.rotateItemOrToggleDoor', }, { keys: ['E'], - action: 'Operate the selected node — doors, windows, and cabinet doors/drawers animate open/closed', + actionKey: 'shortcuts.operateSelectedNode', }, { keys: ['Shift'], - action: 'Temporarily bypass placement validation constraints', - note: 'Hold while placing.', + actionKey: 'shortcuts.bypassPlacementValidation', + noteKey: 'shortcuts.holdWhilePlacing', }, ], }, { - title: 'Camera', + titleKey: 'shortcuts.camera', shortcuts: [ { keys: ['W', 'A', 'S', 'D'], - action: 'Pan camera', - note: 'Moves in screen space, similar to dragging the camera view.', + actionKey: 'shortcuts.panCamera', + noteKey: 'shortcuts.panCameraNote', }, { keys: ['Middle click'], - action: 'Pan camera', - note: 'Drag with the middle mouse button, or hold Space while dragging with the left mouse button.', + actionKey: 'shortcuts.panCameraMiddle', + noteKey: 'shortcuts.dragMiddleMouseOrHoldSpace', }, { keys: ['Right click'], - action: 'Orbit camera', - note: 'Drag with the right mouse button.', + actionKey: 'shortcuts.orbitCamera', + noteKey: 'shortcuts.dragRightMouse', }, ], }, @@ -221,38 +219,38 @@ function ShortcutKeys({ keys }: { keys: string[] }) { } export function KeyboardShortcutsDialog() { + const t = useTranslations() return ( - Keyboard Shortcuts + {t('shortcuts.keyboardShortcuts')} - Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift - during an active gesture to build freely. + {t('shortcuts.contextAware')}
{SHORTCUT_CATEGORIES.map((category) => ( -
-

{category.title}

+
+

{t(category.titleKey)}

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

{shortcut.action}

- {shortcut.note ? ( -

{shortcut.note}

+

{t(shortcut.actionKey)}

+ {shortcut.noteKey ? ( +

{t(shortcut.noteKey)}

) : 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..128296bfa8 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 { useTranslations } from '../../../../../lib/i18n' export type PendingImport = { fileName: string @@ -43,24 +44,24 @@ type Props = { type StatRow = { icon: typeof Building2 - label: string + labelKey: string count: number } function statsRows(stats: BuildStats): StatRow[] { return ( [ - { icon: MapPin, label: 'Sites', count: stats.byType.site ?? 0 }, - { icon: Building2, label: 'Buildings', count: stats.byType.building ?? 0 }, - { icon: Layers, label: 'Levels', count: stats.byType.level ?? 0 }, - { icon: Square, label: 'Walls', count: stats.byType.wall ?? 0 }, - { icon: DoorOpen, label: 'Doors', count: stats.byType.door ?? 0 }, - { icon: AppWindow, label: 'Windows', count: stats.byType.window ?? 0 }, - { icon: Box, label: 'Items', count: stats.byType.item ?? 0 }, - { icon: Square, label: 'Slabs', count: stats.byType.slab ?? 0 }, - { icon: Square, label: 'Ceilings', count: stats.byType.ceiling ?? 0 }, - { icon: Square, label: 'Zones', count: stats.byType.zone ?? 0 }, - { icon: Scan, label: 'Scans', count: stats.byType.scan ?? 0 }, + { icon: MapPin, labelKey: 'loadBuild.sites', count: stats.byType.site ?? 0 }, + { icon: Building2, labelKey: 'loadBuild.buildings', count: stats.byType.building ?? 0 }, + { icon: Layers, labelKey: 'loadBuild.levels', count: stats.byType.level ?? 0 }, + { icon: Square, labelKey: 'loadBuild.walls', count: stats.byType.wall ?? 0 }, + { icon: DoorOpen, labelKey: 'loadBuild.doors', count: stats.byType.door ?? 0 }, + { icon: AppWindow, labelKey: 'loadBuild.windows', count: stats.byType.window ?? 0 }, + { icon: Box, labelKey: 'loadBuild.items', count: stats.byType.item ?? 0 }, + { icon: Square, labelKey: 'loadBuild.slabs', count: stats.byType.slab ?? 0 }, + { icon: Square, labelKey: 'loadBuild.ceilings', count: stats.byType.ceiling ?? 0 }, + { icon: Square, labelKey: 'loadBuild.zones', count: stats.byType.zone ?? 0 }, + { icon: Scan, labelKey: 'loadBuild.scans', count: stats.byType.scan ?? 0 }, ] satisfies StatRow[] ).filter((row) => row.count > 0) } @@ -94,6 +95,7 @@ function formatFloorArea(m2: number, unit: LinearUnit): string { } export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { + const t = useTranslations() const [showAllWarnings, setShowAllWarnings] = useState(false) const [showSchemaIssues, setShowSchemaIssues] = useState(false) const unit = useViewer((state) => state.unit) @@ -122,11 +124,10 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { ) : ( )} - {ok ? 'Ready to import' : 'Cannot import this file'} + {ok ? t('loadBuild.readyToImport') : t('loadBuild.cannotImport')} - {fileName} · {formatFileSize(fileSizeBytes)} · {stats.total} node - {stats.total === 1 ? '' : 's'} + {fileName} · {formatFileSize(fileSizeBytes)} · {t('loadBuild.nodes', { count: stats.total })} @@ -135,7 +136,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
- {errors.length} error{errors.length === 1 ? '' : 's'} + {t(errors.length === 1 ? 'loadBuild.errors' : 'loadBuild.errors_plural', { count: errors.length })}
    {errors.map((e) => ( @@ -148,7 +149,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { {stats.total > 0 && (
    - Structure + {t('loadBuild.structure')}
    {rows.length > 0 ? (
    @@ -159,11 +160,11 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { className={`flex items-center justify-between px-3 py-2 ${ i === rows.length - 1 ? '' : 'border-b' }`} - key={row.label} + key={row.labelKey} >
    - {row.label} + {t(row.labelKey)}
    {row.count}
    @@ -171,7 +172,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { })} {stats.floorAreaM2 > 0 && (
    - Floor area + {t('loadBuild.floorArea')} {formatFloorArea(stats.floorAreaM2, unit)} @@ -180,7 +181,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
    ) : (
    - The file contains no recognised nodes. + {t('loadBuild.noRecognisedNodes')}
    )}
    @@ -190,7 +191,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
    - {warnings.length} warning{warnings.length === 1 ? '' : 's'} + {t(warnings.length === 1 ? 'loadBuild.warnings' : 'loadBuild.warnings_plural', { count: warnings.length })}
      {visibleWarnings.map((w, i) => ( @@ -203,7 +204,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { onClick={() => setShowAllWarnings(true)} type="button" > - Show {hiddenWarningCount} more + {t('loadBuild.showMore', { count: hiddenWarningCount })} )}
    @@ -217,11 +218,10 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { type="button" > - Schema details ({schemaIssueCount} node - {schemaIssueCount === 1 ? '' : 's'}) + {t('loadBuild.schemaDetails', { count: schemaIssueCount })} - {showSchemaIssues ? 'Hide' : 'Show'} + {showSchemaIssues ? t('common.hide') : t('common.show')} {showSchemaIssues && ( @@ -250,7 +250,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx index 764a50c128..78358dc644 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type ChimneyNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' @@ -43,7 +45,7 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({ [nodeId, setSelection], ) - const defaultName = node?.name || 'Chimney' + const defaultName = node?.name || 'nodeTypes.chimney' return ( setIsEditing(true)} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx index 86f5a7beff..a7ec911190 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' @@ -51,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ isVisible={node.visible !== false} label={ setIsEditing(true)} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 3f4cae036a..e3bfa579bc 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -52,6 +52,7 @@ import { } from './../../../../../lib/measurements' import { createLocalGuideImage } from './../../../../../lib/local-guide-image' import { editorHostTreeChildrenRegistry } from './../../../../../lib/host-tree-children' +import { useTranslations } from './../../../../../lib/i18n' import { cn } from './../../../../../lib/utils' import useEditor from './../../../../../store/use-editor' import { useUploadStore } from '../../../../../store/use-upload' @@ -103,6 +104,7 @@ function useSiteNode(): SiteNode | null { } const PropertyLineSection = memo(function PropertyLineSection() { + const t = useTranslations() const siteNode = useSiteNode() const updateNode = useScene((state) => state.updateNode) const mode = useEditor((state) => state.mode) @@ -171,7 +173,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
    - Property Line + {t('site.propertyLine')}
)} @@ -274,6 +276,7 @@ const CameraPopover = memo(function CameraPopover({ onOpenChange: (open: boolean) => void buttonClassName?: string }) { + const t = useTranslations() const updateNode = useScene((state) => state.updateNode) return ( @@ -284,7 +287,7 @@ const CameraPopover = memo(function CameraPopover({ buttonClassName, )} onClick={(e) => e.stopPropagation()} - title="Camera snapshot" + title={t('site.cameraSnapshot')} > {hasCamera && ( @@ -309,7 +312,7 @@ const CameraPopover = memo(function CameraPopover({ }} > - View snapshot + {t('site.camera.viewSnapshot')} )} {hasCamera && ( )}
@@ -353,6 +356,7 @@ const ReferenceItem = memo(function ReferenceItem({ setSelectedReferenceId: (id: string) => void handleDelete: (id: string, e: React.MouseEvent) => void }) { + const t = useTranslations() const [isEditing, setIsEditing] = useState(false) const [isExpanded, setIsExpanded] = useState(true) const updateNode = useScene((state) => state.updateNode) @@ -427,19 +431,19 @@ const ReferenceItem = memo(function ReferenceItem({ > {isCapture ? ( Capture ) : ( Guide )} setIsEditing(true)} @@ -454,7 +458,7 @@ const ReferenceItem = memo(function ReferenceItem({ event.stopPropagation() updateNode(refNode.id, { visible: !isVisible }) }} - title={isVisible ? 'Hide' : 'Show'} + title={isVisible ? t('site.hide') : t('site.show')} type="button" > {isVisible ? : } @@ -463,7 +467,7 @@ const ReferenceItem = memo(function ReferenceItem({ void onDeleteAsset?: (projectId: string, url: string) => void }) { + const t = useTranslations() const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false) const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) @@ -807,7 +815,7 @@ const LevelItem = memo(function LevelItem({
Level setIsEditing(true)} @@ -833,7 +841,7 @@ const LevelItem = memo(function LevelItem({ : 'text-muted-foreground hover:bg-accent hover:text-foreground', )} onClick={(e) => e.stopPropagation()} - title="Camera snapshot" + title={t('site.cameraSnapshot')} > {level.camera && ( @@ -858,7 +866,7 @@ const LevelItem = memo(function LevelItem({ }} > - View snapshot + {t('site.camera.viewSnapshot')} )} {level.camera && ( )}
@@ -906,27 +914,27 @@ const LevelItem = memo(function LevelItem({ @@ -943,7 +951,7 @@ const LevelItem = memo(function LevelItem({
updateNode(level.id, { baseElevation: value })} precision={2} step={0.05} @@ -980,6 +988,7 @@ const LevelsSection = memo(function LevelsSection({ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void onDeleteAsset?: (projectId: string, url: string) => void } = {}) { + const t = useTranslations() const createNode = useScene((state) => state.createNode) const updateNode = useScene((state) => state.updateNode) const selectedBuildingId = useViewer((state) => state.selection.buildingId) @@ -1029,7 +1038,7 @@ const LevelsSection = memo(function LevelsSection({
- Add level + {t('site.addLevel')} {levels.length === 0 && (
@@ -1037,7 +1046,7 @@ const LevelsSection = memo(function LevelsSection({
{/* Horizontal branch line */}
- No levels yet + {t('site.noLevelsYet')}
)} {[...levels].reverse().map((level, index) => ( @@ -1060,6 +1069,7 @@ const LevelsSection = memo(function LevelsSection({ }) const LayerToggle = memo(function LayerToggle() { + const t = useTranslations() const structureLayer = useEditor((state) => state.structureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer) const phase = useEditor((state) => state.phase) @@ -1097,14 +1107,14 @@ const LayerToggle = memo(function LayerToggle() { )}
Structure - Structure + {t('site.structure')}
@@ -1133,14 +1143,14 @@ const LayerToggle = memo(function LayerToggle() { )}
Furnish - Furnish + {t('site.furnish')}
@@ -1170,14 +1180,14 @@ const LayerToggle = memo(function LayerToggle() { )}
Zones - Zones + {t('site.zones')}
@@ -1190,6 +1200,7 @@ const LayerToggle = memo(function LayerToggle() { }) const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) { + const t = useTranslations() const [isEditing, setIsEditing] = useState(false) const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false) const deleteNode = useScene((state) => state.deleteNode) @@ -1286,7 +1297,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa )} {zone.camera && ( )}
@@ -1353,6 +1364,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa }) const MultiSelectionBadge = memo(function MultiSelectionBadge() { + const t = useTranslations() const selectedIds = useViewer((state) => state.selection.selectedIds) const setSelection = useViewer((state) => state.setSelection) @@ -1361,11 +1373,11 @@ const MultiSelectionBadge = memo(function MultiSelectionBadge() { return (
- {selectedIds.length} objects selected + {t('site.objectsSelected', { count: selectedIds.length })} @@ -1375,6 +1387,7 @@ const MultiSelectionBadge = memo(function MultiSelectionBadge() { }) const ContentSection = memo(function ContentSection() { + const t = useTranslations() const selectedLevelId = useViewer((state) => state.selection.levelId) const structureLayer = useEditor((state) => state.structureLayer) const phase = useEditor((state) => state.phase) @@ -1407,7 +1420,7 @@ const ContentSection = memo(function ContentSection() { if (!level) { return ( -
Select a level to view content
+
{t('level.selectLevelToView')}
) } @@ -1421,9 +1434,9 @@ const ContentSection = memo(function ContentSection() { if (levelZones.length === 0) { return (
- No zones on this level.{' '} + {t('level.noZonesOnLevel')}{' '}
) @@ -1439,7 +1452,7 @@ const ContentSection = memo(function ContentSection() { } if (elementChildren.length === 0) { - return
No elements on this level
+ return
{t('level.noElementsOnLevel')}
} return ( @@ -1474,6 +1487,7 @@ const BuildingItem = memo(function BuildingItem({ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void onDeleteAsset?: (projectId: string, url: string) => void }) { + const t = useTranslations() const setSelection = useViewer((state) => state.setSelection) const phase = useEditor((state) => state.phase) const setPhase = useEditor((state) => state.setPhase) @@ -1514,14 +1528,14 @@ const BuildingItem = memo(function BuildingItem({ >
Building - {building.name || 'Building'} + {building.name || t('site.building')}
setBuildingCameraOpen(open ? building.id : null)} @@ -1536,7 +1550,7 @@ const BuildingItem = memo(function BuildingItem({ : 'text-muted-foreground hover:bg-accent hover:text-foreground', )} onClick={(e) => e.stopPropagation()} - title="Camera snapshot" + title={t('site.cameraSnapshot')} > {building.camera && ( @@ -1561,7 +1575,7 @@ const BuildingItem = memo(function BuildingItem({ }} > - View snapshot + {t('site.camera.viewSnapshot')} )} {building.camera && ( )}
@@ -1631,6 +1645,7 @@ export interface SitePanelProps { } export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) { + const t = useTranslations() const rootNodeIds = useScene((state) => state.rootNodeIds) const updateNode = useScene((state) => state.updateNode) const selectedBuildingId = useViewer((state) => state.selection.buildingId) @@ -1670,14 +1685,14 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel >
Site - {siteNode.name || 'Site'} + {siteNode.name || t('site.site')}
- No buildings yet + {t('site.noBuildingsYet')} ) : (
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx index 2003314ee4..6afaaea3ab 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx @@ -1,13 +1,37 @@ +'use client' + import { type AnyNodeId, useScene } from '@pascal-app/core' import { Pencil } from 'lucide-react' import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn } from './../../../../../lib/utils' +import { messages, useLocale } from '../../../../../lib/i18n' + +/** + * Resolve a label string. + * - If it contains a dot (.) it is treated as an i18n key. + * - If it contains {param} placeholders, pass `params` to fill them. + * - Otherwise returned as-is (custom user name). + */ +function resolveLabel(label: string, locale: string, params?: Record): string { + if (label.includes('.')) { + const str = (messages[locale as 'en' | 'zh'] as Record)[label] || label + if (!params) return str + return Object.entries(params).reduce( + (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)), + str, + ) + } + return label +} interface InlineRenameInputProps { nodeId: AnyNodeId isEditing: boolean onStopEditing: () => void + /** e.g. 'nodeTypes.wall' or 'nodeTypes.zoneWithArea' (params fills {area}) */ defaultName: string + /** Params for resolving parameterized i18n keys (e.g. { area: 23 }) */ + defaultNameParams?: Record className?: string onStartEditing?: () => void } @@ -17,14 +41,17 @@ export const InlineRenameInput = memo(function InlineRenameInput({ isEditing, onStopEditing, defaultName, + defaultNameParams, className, onStartEditing, }: InlineRenameInputProps) { + const { locale } = useLocale() const updateNode = useScene((s) => s.updateNode) const name = useScene((s) => s.nodes[nodeId]?.name) const [value, setValue] = useState(name || '') const inputRef = useRef(null) - const inputSize = Math.max((value || defaultName).length, 1) + const resolvedDefault = resolveLabel(defaultName, locale, defaultNameParams) + const inputSize = Math.max((value || resolvedDefault).length, 1) useEffect(() => { if (isEditing) { @@ -61,7 +88,7 @@ export const InlineRenameInput = memo(function InlineRenameInput({ return (
- {name || defaultName} + {name || resolvedDefault} {onStartEditing && ( @@ -67,7 +73,7 @@ export const TreeNodeActions = memo(function TreeNodeActions({ nodeId }: TreeNod )} {hasCamera && ( )}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx index 2cdbe70ec4..f4b80d6d06 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' @@ -87,7 +89,7 @@ export const WallTreeNode = memo(function WallTreeNode({ isVisible={isVisible} label={ state.deleteNode) const updateNode = useScene((state) => state.updateNode) @@ -67,7 +69,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) { )} {zone.camera && ( )}
@@ -133,6 +135,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) { } export function ZonePanel() { + const t = useTranslations() const nodes = useScene((state) => state.nodes) const currentLevelId = useViewer((state) => state.selection.levelId) const selectedZoneId = useViewer((state) => state.selection.zoneId) @@ -169,7 +172,7 @@ export function ZonePanel() { if (!currentLevelId) { return (
- Select a level to view and create zones + {t('editor.selectLevelFirst')}
) } @@ -178,34 +181,34 @@ export function ZonePanel() {
{levelZones.length === 0 ? (
- No zones on this level.{' '} + {t('editor.noZonesOnLevel')}{' '}
) : ( levelZones.map((zone) => ) )} {selectedZone ? ( - + } - label="Save to catalog" + label={t('editor.saveToCatalog')} onClick={() => emitter.emit('room-preset:create', { zoneId: selectedZone.id })} type="button" /> } - label="Delete" + label={t('editor.delete')} onClick={() => deleteSelectedZone(false)} type="button" /> } - label="Delete with contents" + label={t('editor.deleteWithContents')} onClick={() => deleteSelectedZone(true)} type="button" /> diff --git a/packages/editor/src/components/ui/slider-demo.tsx b/packages/editor/src/components/ui/slider-demo.tsx index d7c3bcb18b..8c71d2010c 100644 --- a/packages/editor/src/components/ui/slider-demo.tsx +++ b/packages/editor/src/components/ui/slider-demo.tsx @@ -1,3 +1,5 @@ +'use client' + import NumberFlow from '@number-flow/react' import { useState } from 'react' diff --git a/packages/editor/src/components/ui/slider.tsx b/packages/editor/src/components/ui/slider.tsx index d83475848e..fe2fd44855 100644 --- a/packages/editor/src/components/ui/slider.tsx +++ b/packages/editor/src/components/ui/slider.tsx @@ -1,3 +1,5 @@ +'use client' + import * as SliderPrimitive from '@radix-ui/react-slider' import { cva, type VariantProps } from 'class-variance-authority' import type * as React from 'react' diff --git a/packages/editor/src/components/viewer/floorplan-compass-button.tsx b/packages/editor/src/components/viewer/floorplan-compass-button.tsx index eef109049d..12b6de0a8b 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 { useTranslations } from '../../lib/i18n' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' export type FloorplanCompassButtonProps = { @@ -14,11 +15,12 @@ export function FloorplanCompassButton({ onAlignNorth, needleRef, }: FloorplanCompassButtonProps) { + const t = useTranslations() return ( - Align view to north + {t('editor.alignViewNorth')} ) } diff --git a/packages/editor/src/components/viewer/floorplan-preview.tsx b/packages/editor/src/components/viewer/floorplan-preview.tsx index fc2d98e425..deec78450f 100644 --- a/packages/editor/src/components/viewer/floorplan-preview.tsx +++ b/packages/editor/src/components/viewer/floorplan-preview.tsx @@ -6,10 +6,12 @@ import { type FloorplanGeometry, type FloorplanPalette, type GeometryContext, + getDefaultLevelName, isNodeKindEnabled, nodeRegistry, useScene, } from '@pascal-app/core' +import type { Translator } from '@pascal-app/core' import { AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' import { useViewer } from '@pascal-app/viewer' import { Maximize2, Minus, Plus } from 'lucide-react' @@ -33,6 +35,7 @@ import { } from '../../lib/floorplan' import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension' import { buildFloorplanContext, floorplanLayerRank } from '../../lib/floorplan/floorplan-readonly' +import { useTranslations } from '../../lib/i18n' import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store' import useEditor, { type NavigationSyncPose } from '../../store/use-editor' import { @@ -141,13 +144,10 @@ function boundsToViewBox(bounds: FloorplanBounds): FloorplanViewBox { } } -function levelLabel(level: AnyNode): string { +function levelLabel(level: AnyNode, t: Translator): string { const named = (level as { name?: string }).name?.trim() if (named) return named - const ordinal = (level as { level?: number }).level ?? 0 - if (ordinal === 0) return 'Ground floor' - if (ordinal < 0) return `Basement ${Math.abs(ordinal)}` - return `Level ${ordinal}` + return getDefaultLevelName((level as { level?: number }).level ?? 0, t) } function collectLevelTree(root: AnyNode, nodes: Record): AnyNode[] { @@ -321,6 +321,7 @@ export function FloorplanPreview({ showLevelSelector = true, synchronizeNavigation = false, }: FloorplanPreviewProps) { + const t = useTranslations() const storeNodes = useScene((state) => (scene ? EMPTY_PREVIEW_NODES : state.nodes)) const storeInstalledPlugins = useScene((state) => scene ? EMPTY_INSTALLED_PLUGINS : state.installedPlugins, @@ -894,7 +895,7 @@ export function FloorplanPreview({ {synchronizeNavigation ? : null}
- Floor + {t('editor.floor')} @@ -1025,28 +1026,28 @@ export function FloorplanPreview({ }} > @@ -152,7 +155,7 @@ export const ViewerSceneHeader = ({ className={`truncate transition-colors ${level ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`} onClick={() => handleBreadcrumbClick('building')} > - {building.name || 'Building'} + {building.name || t('site.building')} {level && ( @@ -162,7 +165,7 @@ export const ViewerSceneHeader = ({ className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`} onClick={() => handleBreadcrumbClick('level')} > - {getLevelDisplayName(level)} + {getLevelDisplayName(level, t)} )} @@ -182,7 +185,7 @@ export const ViewerSceneHeader = ({ <> - {getNodeName(selectedNode)} + {getNodeName(selectedNode, t)} )} @@ -195,7 +198,7 @@ export const ViewerSceneHeader = ({ {building && levels.length > 0 && (
- Levels + {t('site.levels')}
{levels.map((lvl) => { @@ -221,7 +224,7 @@ export const ViewerSceneHeader = ({
- {getLevelDisplayName(lvl)} + {getLevelDisplayName(lvl, t)}
diff --git a/packages/editor/src/components/viewer/viewer-stage-switcher.tsx b/packages/editor/src/components/viewer/viewer-stage-switcher.tsx index ea137c6556..5d6705c909 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 { useTranslations } from '../../lib/i18n' 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 = useTranslations() const enabledModes = normalizeViewerStageModes(modes) return (
} - label="3D" + label={t('editor.viewer3d')} onClick={() => onChange('3d')} /> ) : null} @@ -45,7 +47,7 @@ export function ViewerStageSwitcher({ } - label="2D" + label={t('editor.viewer2d')} onClick={() => onChange('2d')} /> ) : null} @@ -54,7 +56,7 @@ export function ViewerStageSwitcher({ active={mode === 'split'} className={hideSplitOnMobile && enabledModes.length > 1 ? 'hidden md:flex' : undefined} icon={} - label="Split" + label={t('editor.viewerSplit')} onClick={() => onChange('split')} /> ) : null} diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index a93533d076..d3d2cca9bf 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type EventSuffix, diff --git a/packages/editor/src/hooks/use-mobile.ts b/packages/editor/src/hooks/use-mobile.ts index 9a549a245d..8aef232bb7 100644 --- a/packages/editor/src/hooks/use-mobile.ts +++ b/packages/editor/src/hooks/use-mobile.ts @@ -1,3 +1,5 @@ +'use client' + import * as React from 'react' const MOBILE_BREAKPOINT = 768 diff --git a/packages/editor/src/hooks/use-reduced-motion.ts b/packages/editor/src/hooks/use-reduced-motion.ts index b46e63ddb7..9f234c83d4 100644 --- a/packages/editor/src/hooks/use-reduced-motion.ts +++ b/packages/editor/src/hooks/use-reduced-motion.ts @@ -1,3 +1,5 @@ +'use client' + import { useEffect, useState } from 'react' /** diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index ceebd342bd..0e26833af0 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -467,7 +467,7 @@ export { buildRoofSurfaceMaterialPatch, buildSingleSurfaceMaterialPatch, buildStairSurfaceMaterialPatch, - getActivePaintMaterialLabel, + getActivePaintMaterialLabelKey, hasActivePaintMaterial, } from './lib/material-paint' export { @@ -695,3 +695,12 @@ export { type WallSnapKind, type WallSnapPoint, } from './store/use-wall-snap-indicator' +export { + I18nProvider, + useLocale, + useTranslations, + defaultLocale, + type Locale, + type Translator, + messages, +} from './lib/i18n' diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index cf0bf4efc4..ac6e553849 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -3,10 +3,22 @@ export type ContextualShortcutHint = { // itself be an array of alternatives (rendered joined by "/"), e.g. // [['Cmd/Ctrl', 'Shift'], 'Left click'] → "⌘ / ⇧ + click". keys: Array + /** + * Pre-resolved label (English by convention). The contextual helper prefers + * `labelKey` when supplied and falls back to this verbatim — convenient for + * embedder-provided hints that already come pre-localised. + */ label: string + /** + * Optional i18n key for `label`. When set, the contextual helper resolves + * through the active locale at render time; otherwise `label` is used + * verbatim. Supply one of these (or both, with `label` as the fallback). + */ + labelKey?: string // Optional secondary line under the label for a terser qualifier // (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating. subtitle?: string + subtitleKey?: string active?: boolean } @@ -37,6 +49,7 @@ export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualS { keys: [SHIFT_KEY], label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely', + labelKey: shiftPressed ? 'contextualHelp.rotateHandle.active' : 'contextualHelp.rotateHandle.idle', active: shiftPressed, }, ] @@ -79,6 +92,7 @@ export function resolveSelectModeHelpHints({ hints.push({ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK], label: 'Add or remove objects from the selection', + labelKey: 'contextualHelp.select.addOrRemove', active: true, }) return hints @@ -91,22 +105,26 @@ export function resolveSelectModeHelpHints({ hints.push({ keys: [LEFT_CLICK], label: 'Click or drag the selection to move it as one', + labelKey: 'contextualHelp.select.moveAsOne', }) - hints.push({ keys: [ROTATE_KEYS], label: 'Rotate the selection ±45°' }) + hints.push({ keys: [ROTATE_KEYS], label: 'Rotate the selection ±45°', labelKey: 'contextualHelp.select.rotateSelection' }) hints.push({ keys: [COMMAND_KEY, 'G'], label: 'Group selection (session only)', + labelKey: 'contextualHelp.select.groupSelection', }) hints.push({ keys: [COMMAND_KEY, SHIFT_KEY, 'G'], label: 'Ungroup session selection', + labelKey: 'contextualHelp.select.ungroupSelection', }) hints.push({ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK], label: 'Add or remove objects from the selection', + labelKey: 'contextualHelp.select.addOrRemove', active: commandPressed || shiftPressed, }) - hints.push({ keys: [ESC_KEY], label: 'Clear the selection (or click outside)' }) + hints.push({ keys: [ESC_KEY], label: 'Clear the selection (or click outside)', labelKey: 'contextualHelp.select.clearSelection' }) return hints } @@ -116,12 +134,28 @@ export function resolveSelectModeHelpHints({ // detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with // R / T (and Alt to switch axis) for keyboard rotation. if (mepSelection === 'run') { - hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' }) - hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' }) + hints.push({ + keys: [CLICK], + label: 'Click a handle dot to show move arrows', + labelKey: 'contextualHelp.mep.run.showArrows', + }) + hints.push({ + keys: [ALT_KEY], + label: 'Detach the joint while dragging an arrow', + labelKey: 'contextualHelp.mep.run.detachJoint', + }) } else if (mepSelection === 'fitting') { - hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' }) - hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' }) - hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' }) + hints.push({ + keys: [CLICK], + label: 'Click the handle dot to show move + rotate handles', + labelKey: 'contextualHelp.mep.fitting.showHandles', + }) + hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°', labelKey: 'contextualHelp.mep.fitting.rotate45' }) + hints.push({ + keys: [ALT_KEY], + label: 'Switch the rotation axis (Y → X → Z)', + labelKey: 'contextualHelp.mep.fitting.switchAxis', + }) } // The rows are the same whatever modifier is held — guides/snapping are @@ -132,6 +166,7 @@ export function resolveSelectModeHelpHints({ hints.push({ keys: [LEFT_CLICK], label: 'Drag selected movable object', + labelKey: 'contextualHelp.single.movableDrag', }) } @@ -139,12 +174,14 @@ export function resolveSelectModeHelpHints({ hints.push({ keys: [COMMAND_KEY, RIGHT_CLICK], label: 'Drag left or right to rotate selected object', + labelKey: 'contextualHelp.single.rotatableDrag', }) } hints.push({ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK], label: 'Add or remove objects from the selection', + labelKey: 'contextualHelp.select.addOrRemove', active: commandPressed || shiftPressed, }) diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts index 420aef590c..2b1a1578b7 100644 --- a/packages/editor/src/lib/continuation.ts +++ b/packages/editor/src/lib/continuation.ts @@ -6,6 +6,9 @@ export const CONTINUATION_PROFILES: Record< { options: ContinuationMode[] default: ContinuationMode + // i18n keys — resolved by `useTranslations()` in the helper panel. Keeping + // the data i18n-key-based (rather than pre-localised strings) lets the + // active locale switch without re-rendering the parent. labels: Record icons: Record } @@ -13,16 +16,16 @@ export const CONTINUATION_PROFILES: Record< wall: { options: ['room', 'single'], default: 'room', - labels: { room: 'Room (auto-close)', single: 'Single wall' }, + labels: { room: 'continuation.wall.room', single: 'continuation.wall.single' }, icons: { room: 'lucide:square', single: 'lucide:minus' }, }, fence: { options: ['single', 'continuous', 'curved'], default: 'continuous', labels: { - continuous: 'Continuous', - single: 'Single fence', - curved: 'Curved fence', + continuous: 'continuation.fence.continuous', + single: 'continuation.fence.single', + curved: 'continuation.fence.curved', }, icons: { continuous: 'lucide:waypoints', @@ -33,19 +36,19 @@ export const CONTINUATION_PROFILES: Record< point: { options: ['once', 'repeat'], default: 'once', - labels: { once: 'Place once', repeat: 'Place multiple' }, + labels: { once: 'continuation.point.once', repeat: 'continuation.point.repeat' }, icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' }, }, cabinet: { options: ['single', 'continuous'], default: 'single', - labels: { single: 'Single cabinet', continuous: 'Continuous run' }, + labels: { single: 'continuation.cabinet.single', continuous: 'continuation.cabinet.continuous' }, icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' }, }, canopy: { options: ['single', 'continuous'], default: 'single', - labels: { single: 'Single canopy', continuous: 'Continuous canopy' }, + labels: { single: 'continuation.canopy.single', continuous: 'continuation.canopy.continuous' }, icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' }, }, } @@ -68,4 +71,4 @@ export function continuationContextOf(kind: string): ContinuationContext | null if (kind === 'cabinet') return 'cabinet' if (kind === 'lean-to-extension') return 'canopy' return POINT_KINDS.has(kind) ? 'point' : null -} +} \ No newline at end of file diff --git a/packages/editor/src/lib/i18n.tsx b/packages/editor/src/lib/i18n.tsx new file mode 100644 index 0000000000..431cd29b4b --- /dev/null +++ b/packages/editor/src/lib/i18n.tsx @@ -0,0 +1,66 @@ +'use client' + +import { createContext, useContext, useMemo, useState, useEffect, type ReactNode } from 'react' +import { IntlProvider } from 'react-intl' +import en from './i18n/en.json' +import zh from './i18n/zh.json' + +export type Locale = 'en' | 'zh' + +function getDefaultLocale(): Locale { + const browserLang = navigator.language.toLowerCase() + return browserLang.startsWith('zh') ? 'zh' : 'en' +} + +export const defaultLocale: Locale = getDefaultLocale() + +const messages: Record> = { en, zh } + +interface I18nContextType { + locale: Locale + setLocale: (locale: Locale) => void +} + +const I18nContext = createContext({ + locale: defaultLocale, + setLocale: () => {}, +}) + +export function useLocale() { + return useContext(I18nContext) +} + +export function useTranslations() { + const { locale } = useContext(I18nContext) + return useMemo( + () => + (key: string, params?: Record): string => { + const str = messages[locale][key] ?? key + if (!params) return str + return Object.entries(params).reduce( + (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)), + str, + ) + }, + [locale], + ) +} + +export type Translator = ( + key: string, + params?: Record, +) => string + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState(getDefaultLocale()) + + return ( + + + {children} + + + ) +} + +export { messages } diff --git a/packages/editor/src/lib/i18n/en.json b/packages/editor/src/lib/i18n/en.json new file mode 100644 index 0000000000..b642de5046 --- /dev/null +++ b/packages/editor/src/lib/i18n/en.json @@ -0,0 +1,2304 @@ +{ + "buildTab.tile.wall": "Wall", + "buildTab.tile.fence": "Fence", + "buildTab.tile.slab": "Slab", + "buildTab.tile.ceiling": "Ceiling", + "buildTab.tile.roof": "Roof", + "buildTab.tile.stairs": "Stairs", + "buildTab.tile.elevator": "Elevator", + "buildTab.tile.door": "Door", + "buildTab.tile.window": "Window", + "buildTab.tile.column": "Column", + "buildTab.tile.shelf": "Shelf", + "buildTab.tile.spawn": "Spawn Point", + "buildTab.tile.kitchen": "Kitchen", + "buildTab.tile.mep": "MEP", + "buildTab.tile.painting": "Painting", + "buildTab.tile.terrain": "Terrain", + "buildTab.mep.duct": "Duct", + "buildTab.mep.register": "Register", + "buildTab.mep.hvacUnit": "HVAC Unit", + "buildTab.mep.lineset": "Lineset", + "buildTab.mep.liquidLine": "Liquid Line", + "buildTab.mep.dwvPipe": "DWV Pipe", + "buildTab.mep.modularCabinet": "Modular Cabinet", + "buildTab.section.roofType": "Roof type", + "buildTab.section.createFrom": "Create from", + "buildTab.section.featuresAndExtensions": "Features & extensions", + "buildTab.roofType.hip": "Hip", + "buildTab.roofType.gable": "Gable", + "buildTab.roofType.shed": "Shed", + "buildTab.roofType.flat": "Flat", + "buildTab.roofType.gambrel": "Gambrel", + "buildTab.roofType.dutch": "Dutch", + "buildTab.roofType.mansard": "Mansard", + "buildTab.roofType.conical": "Conical", + "buildTab.roofSource.room": "Room", + "buildTab.roofSource.walls": "Wall", + "buildTab.roofSource.draw": "Draw", + "buildTab.roofSource.roomHint": "Hover a room to preview its boundary, then click to place.", + "buildTab.roofSource.wallsHint": "Select a curved wall to match its radius and arc.", + "buildTab.roofSource.drawHint": "Draw the roof footprint with two corner clicks.", + "buildTab.action.addFitting": "Add Fitting", + "buildTab.action.addTrap": "Add Trap", + "buildTab.liquidLine.followLineset": "Follow lineset", + "buildTab.liquidLine.followHintOn": "Click a lineset to lay the line beside it.", + "buildTab.liquidLine.followHintOff": "Trace a line alongside an existing lineset (F).", + "buildTab.toggle.on": "On", + "buildTab.toggle.off": "Off", + "catalog.intensity": "Intensity", + "materials.flooring": "Flooring", + "materials.other": "Other", + "materials.roof": "Roofing", + "materials.wood": "Wood", + "measurement.angle": "Angle", + "measurement.angularDimension": "Angular dimension", + "measurement.arcLength": "Arc length", + "measurement.area": "Area", + "measurement.centerMark": "Center mark", + "measurement.chordDimension": "Chord dimension", + "measurement.continuousDimension": "Continuous dimension", + "measurement.coordinateDimensions": "Coordinate dimensions", + "measurement.diameterDimension": "Diameter dimension", + "measurement.distance": "Distance", + "measurement.floorplanSection": "Floor plan", + "measurement.linearDimension": "Linear dimension", + "measurement.measurePrefix": "Measure: {label}", + "measurement.options": "Measurement options", + "measurement.perimeter": "Perimeter", + "measurement.radiusDimension": "Radius dimension", + "measurement.smart": "Smart", + "measurement.type": "Measurement type", + "measurement.volume": "Volume", + "commands.addLevel": "Add Level", + "commands.back": "back", + "commands.cameraSwitchTo": "Camera: Switch to {mode}", + "commands.ceilingTool": "Ceiling Tool", + "commands.close": "close", + "commands.commandPalette": "Command Palette", + "commands.copyShareLink": "Copy Share Link", + "commands.cutaway": "Cutaway", + "commands.deleteLevel": "Delete Level", + "commands.deleteSelection": "Delete Selection", + "commands.doorTool": "Door Tool", + "commands.down": "Down", + "commands.enterPreview": "Enter Preview", + "commands.exitPreview": "Exit Preview", + "commands.exploded": "Exploded", + "commands.export3DModel": "Export 3D Model (GLB)", + "commands.exportJSON": "Export Scene (JSON)", + "commands.exportSceneJson": "Export Scene (JSON)", + "commands.filterOptions": "Filter options…", + "commands.gotoLevel": "Go to Level", + "commands.group.exportShare": "Export & Share", + "commands.group.history": "History", + "commands.group.levels": "Levels", + "commands.group.scene": "Scene", + "commands.group.view": "View", + "commands.group.viewerControls": "Viewer Controls", + "commands.itemTool": "Item Tool", + "commands.levelMode": "Level Mode", + "commands.manual": "Manual", + "commands.materialPaint": "Material Paint", + "commands.navigate": "navigate", + "commands.noCommandsFound": "No commands found.", + "commands.orthographic": "Orthographic", + "commands.perspective": "Perspective", + "commands.redo": "Redo", + "commands.renameLevel": "Rename Level", + "commands.renameTo": "Rename to {name}", + "commands.sculptTerrain": "Sculpt Terrain", + "commands.searchActions": "Search actions…", + "commands.select": "select", + "commands.slabTool": "Slab Tool", + "commands.solo": "Solo", + "commands.stacked": "Stacked", + "commands.stairTool": "Stair Tool", + "commands.switchTo": "Switch to", + "commands.switchToRendered": "Switch to Rendered", + "commands.switchToSolid": "Switch to Solid", + "commands.takeScreenshot": "Take Screenshot", + "commands.takeSnapshot": "Take Snapshot", + "commands.toggleFullscreen": "Toggle Fullscreen", + "commands.translucent": "Translucent", + "commands.typeNewName": "Type a new name…", + "commands.typeNewNameAbove": "Type a new name above…", + "commands.undo": "Undo", + "commands.up": "Up", + "commands.wallMode": "Wall Mode", + "commands.wallTool": "Wall Tool", + "commands.windowTool": "Window Tool", + "commands.zoneTool": "Zone Tool", + "common.actions": "Actions", + "common.add": "Add", + "common.addOne": "Add one", + "common.all": "All", + "common.apply": "Apply", + "common.back": "Back", + "common.cancel": "Cancel", + "common.close": "Close", + "common.confirm": "Confirm", + "common.copy": "Copy", + "common.corners.bottomLeft": "Bottom Left", + "common.corners.bottomRight": "Bottom Right", + "common.corners.topLeft": "Top Left", + "common.corners.topRight": "Top Right", + "common.cut": "Cut", + "common.delete": "Delete", + "common.depth": "Depth", + "common.deselect": "Deselect", + "common.diameter": "Diameter", + "common.dimensions": "Dimensions", + "common.area": "Area", + "common.directions.down": "Down", + "common.directions.left": "Left", + "common.directions.right": "Right", + "common.directions.up": "Up", + "common.done": "Done", + "common.duplicate": "Duplicate", + "common.edit": "Edit", + "common.error": "Error", + "common.height": "Height", + "common.hide": "Hide", + "common.home": "Home", + "common.length": "Length", + "common.loading": "Loading...", + "common.minus45": "-45°", + "common.move": "Move", + "common.next": "Next", + "common.noResults": "No results found", + "common.objectsSelected": "objects selected", + "common.open": "Open", + "common.overhang": "Overhang", + "common.paste": "Paste", + "common.plus45": "+45°", + "common.position": "Position", + "common.redo": "Redo", + "common.remove": "Remove", + "common.reset": "Reset", + "common.rotate": "Rotate", + "common.save": "Save", + "common.search": "Search", + "common.select": "Select", + "common.settings": "Settings", + "common.show": "Show", + "common.style": "Style", + "common.success": "Success", + "common.thickness": "Thickness", + "common.undo": "Undo", + "common.untitled": "Untitled", + "common.width": "Width", + "common.x": "X", + "common.y": "Y", + "common.z": "Z", + "common.yaw": "Yaw", + "common.rotation": "Rotation", + "controlModes.boxSelect": "Box select", + "controlModes.build": "Build", + "controlModes.editSite": "Edit site", + "controlModes.exitSiteEditing": "Exit site editing", + "controlModes.furnish": "Furnish", + "controlModes.materialPaint": "Material Paint", + "controlModes.preview": "Preview", + "controlModes.select": "Select", + "controlModes.siteEditGroundOnly": "Site editing only available on ground floor", + "controlModes.zone": "Zone", + "contextualHelp.mep.fitting.rotate45": "Rotate ±45°", + "contextualHelp.mep.fitting.showHandles": "Click the handle dot to show move + rotate handles", + "contextualHelp.mep.fitting.switchAxis": "Switch the rotation axis (Y → X → Z)", + "contextualHelp.mep.run.detachJoint": "Detach the joint while dragging an arrow", + "contextualHelp.mep.run.showArrows": "Click a handle dot to show move arrows", + "contextualHelp.measurement.exit": "Exit smart measure", + "contextualHelp.measurement.inspect": "Inspect surface dimensions", + "contextualHelp.measurement.pin": "Pin measurement lens", + "contextualHelp.reshape.controlPoint": "Move control point", + "contextualHelp.reshape.corner": "Move corner", + "contextualHelp.reshape.curve": "Curve", + "contextualHelp.reshape.endpoint": "Move endpoint", + "contextualHelp.reshape.tangent": "Move tangent", + "contextualHelp.resize": "Resize", + "contextualHelp.rotateHandle.active": "Rotating freely (no angle step)", + "contextualHelp.rotateHandle.idle": "Hold to rotate freely", + "contextualHelp.sculpt.brushSize": "Brush size", + "contextualHelp.sculpt.cancelPick": "Cancel picking", + "contextualHelp.sculpt.cancelStroke": "Cancel stroke", + "contextualHelp.sculpt.flatten": "Level ground", + "contextualHelp.sculpt.lower": "Lower ground", + "contextualHelp.sculpt.pickTarget": "Pick target height", + "contextualHelp.sculpt.raise": "Raise ground", + "contextualHelp.sculpt.smooth": "Smooth ground", + "contextualHelp.select.addOrRemove": "Add or remove objects from the selection", + "contextualHelp.select.clearSelection": "Clear the selection (or click outside)", + "contextualHelp.select.groupSelection": "Group selection (session only)", + "contextualHelp.select.moveAsOne": "Click or drag the selection to move it as one", + "contextualHelp.select.rotateSelection": "Rotate the selection ±45°", + "contextualHelp.select.ungroupSelection": "Ungroup session selection", + "contextualHelp.single.movableDrag": "Drag selected movable object", + "contextualHelp.single.rotatableDrag": "Drag left or right to rotate selected object", + "dialog.deleteElements.title": "Delete {count} elements?", + "dialog.deleteElements.description": "This removes every selected element. You can undo the deletion while it remains in the editor history.", + "editor.cameraPan": "Pan", + "editor.cameraRotate": "Rotate", + "editor.cameraZoom": "Zoom", + "editor.create": "Create", + "editor.createNew": "Create new", + "editor.recent": "Recent", + "editor.delete": "Delete", + "editor.cancel": "Cancel", + "editor.clearScale": "Clear Scale", + "editor.center": "Center", + "editor.resetRotation": "Reset Rotation", + "editor.resetImageScale": "Reset Image Scale", + "editor.opacity": "Opacity", + "editor.move": "Move", + "editor.toggleSidebar": "Toggle Sidebar", + "editor.close": "Close", + "editor.duplicate": "Duplicate", + "editor.editProperties": "Edit properties", + "editor.cameraControlsHint": "Camera controls hint", + "editor.dismissCameraControlsHint": "Dismiss camera controls hint", + "editor.jump": "Jump", + "editor.sprint": "Sprint", + "editor.interact": "Interact", + "editor.clickToLookAround": "Click to look around", + "editor.exitStreetView": "Exit Street View", + "editor.referenceFloor": "Reference floor", + "editor.setCorner": "Set corner", + "editor.forcePlace": "Force place", + "editor.guidedConstraintsBypassed": "Guided constraints bypassed", + "editor.openSavedScenes": "Scenes", + "editor.place": "Place", + "editor.placeBuilding": "Place building", + "editor.rotate": "Rotate", + "editor.rotateCounterclockwise": "Rotate counterclockwise", + "editor.rotateClockwise": "Rotate clockwise", + "editor.sceneFailedToRender": "The editor scene failed to render", + "editor.setOverlayScale": "Set overlay scale", + "editor.somethingWentWrong": "Something went wrong", + "editor.addNewLevel": "Add new level", + "editor.displaysMobileSidebar": "Displays the mobile sidebar.", + "editor.material": "Material", + "editor.customMaterial": "Custom Material", + "editor.image": "Image", + "editor.3dScan": "3D Scan", + "editor.guideImage": "Guide Image", + "editor.referenceScale": "Reference Scale", + "editor.quickActions": "Quick Actions", + "editor.position": "Position", + "editor.rotation": "Rotation", + "editor.scaleAndOpacity": "Scale & Opacity", + "editor.actions": "Actions", + "editor.expandSidebar": "Expand sidebar", + "editor.custom": "Custom", + "editor.addPoint": "Add point", + "editor.viewSnapshot": "View snapshot", + "editor.reloadEditor": "Reload editor", + "editor.drawnLine": "Drawn line", + "editor.realLength": "Real length", + "editor.duplicateLevel": "Duplicate level", + "editor.tryAgain": "Try again", + "editor.dismiss": "Dismiss", + "editor.reload": "Reload", + "editor.conflictTitle": "Another session saved first — refresh?", + "editor.conflictBody": "Your changes haven't been saved. Reload to pick up the latest version.", + "editor.lightPreview": "Light preview", + "editor.lightPreviewTitle": "Skip the post-processing pipeline — lighter on the GPU, no ambient occlusion or selection outlines", + "editor.leftClick": "Left click", + "editor.localWarning": "This is a blank canvas — your saved scenes live under", + "editor.middleClick": "Middle click", + "editor.openRecent": "Open recent scenes", + "editor.rightClick": "Right click", + "editor.scrollWheel": "Scroll wheel", + "editor.space": "Space", + "furnishTools.appliance": "Appliance", + "furnishTools.bathroom": "Bathroom", + "furnishTools.furniture": "Furniture", + "furnishTools.kitchen": "Kitchen", + "furnishTools.outdoor": "Outdoor", + "functionTree.bathroom": "Bathroom", + "functionTree.bedroom": "Bedroom", + "functionTree.dining": "Dining", + "functionTree.entry": "Entry", + "functionTree.hvac": "HVAC", + "functionTree.kitchen": "Kitchen", + "functionTree.laundry": "Laundry", + "functionTree.lighting": "Lighting", + "functionTree.living": "Living", + "functionTree.office": "Office", + "functionTree.outdoor": "Outdoor", + "functionTree.plumbing": "Plumbing", + "functionTree.storage": "Storage", + "itemHelper.freePlace": "Free place", + "itemHelper.placeItem": "Place item", + "itemHelper.rotateClockwise": "Rotate clockwise", + "itemHelper.rotateCounterclockwise": "Rotate counterclockwise", + "helper.continuation.line": "Continuation: {label}", + "helper.continuation.tooltip": "Continuation — click or press C to cycle", + "helper.fence.continuationCurved": "Straight continuation is unavailable while curved fence type is active", + "helper.fence.continuationLine": "Fence continuation: {label}", + "helper.fence.continuationStraight": "Straight fence continuation — click or press C to toggle", + "helper.fence.curved": "Curved", + "helper.fence.finishCurve": "Finish curve (or double-click)", + "helper.fence.straight": "Straight", + "helper.fence.straightContinuous": "Straight: Continuous", + "helper.fence.straightSingle": "Straight: Single", + "helper.fence.typeCurved": "Type: Curved", + "helper.fence.typeLine": "Fence type: {kind}", + "helper.fence.typeStraight": "Type: Straight", + "helper.fence.typeTooltip": "Fence type — click or press T to switch between straight and curved", + "helper.gridStep.label": "Grid: {step} m", + "helper.gridStep.line": "Grid step: {step} m", + "helper.gridStep.tooltip": "Grid step — click or tap Ctrl to cycle", + "helper.paint.allMatching": "All matching", + "helper.paint.hoverSurface": "Hover a surface to paint", + "helper.paint.line": "Paint: {scope}", + "helper.paint.pickMaterial": "Select a material to paint", + "helper.paint.room": "Room", + "helper.paint.scopeLine": "Paint scope: {scope}", + "helper.paint.scopeTooltip": "Paint scope — click or press Shift to cycle", + "helper.paint.thisSurface": "This surface", + "helper.paint.wholeNoun": "Whole {noun}", + "helper.snapping.angles": "Angles", + "helper.snapping.grid": "Grid", + "helper.snapping.lines": "Lines", + "helper.snapping.line": "Snapping: {mode}", + "helper.snapping.off": "Off", + "helper.snapping.tooltip": "Snapping mode — click or press Shift to cycle", + "items.all": "All", + "items.community": "Community", + "keys.commandMac": "Command", + "keys.control": "Control", + "keys.leftClick": "Left click", + "keys.middleClick": "Middle click", + "keys.rightClick": "Right click", + "keys.shift": "Shift", + "items.library": "Library", + "items.mine": "Mine", + "items.noResults": "No results for \"{search}\"", + "items.search": "Search...", + "level.addLevelAbove": "Add level above", + "level.addLevelBelow": "Add level below", + "level.basement": "Basement {n}", + "level.cancel": "Cancel", + "level.cannotDeleteGround": "The ground level cannot be deleted", + "level.confirmDelete": "Are you sure you want to delete {name}? All walls, floors, and objects on this level will be permanently removed.", + "level.delete": "Delete", + "level.deleteLevel": "Delete level", + "level.deleteLevelTitle": "Delete level", + "level.dragToReorder": "Drag to reorder", + "level.duplicateLevel": "Duplicate level", + "level.duplicateOptions": "Duplicate with options...", + "level.floor": "Floor {n}", + "level.groundFloor": "Ground Floor", + "level.insertLevelHere": "Insert level here", + "level.levelHeight": "Level height", + "level.noElementsOnLevel": "No elements on this level", + "level.noZonesOnLevel": "No zones on this level.", + "level.thisLevel": "this level", + "level.pasteCopied": "Paste copied selection", + "level.reorder": "Reorder", + "level.reorderName": "Reorder {name}", + "level.selectLevelToView": "Select a level to view content", + "levelDuplicate.cancel": "Cancel", + "levelDuplicate.chooseWhatToCopy": "Choose what to copy from {level}.", + "levelDuplicate.duplicate": "Duplicate", + "levelDuplicate.duplicateLevel": "Duplicate Level", + "levelDuplicate.everything": "Everything", + "levelDuplicate.everythingDesc": "Structure, materials, furniture, and references.", + "levelDuplicate.structure": "Structure only", + "levelDuplicate.structureDesc": "Walls, slabs, roofs, stairs, windows, and doors without finishes.", + "levelDuplicate.structureFurniture": "Structure + furniture", + "levelDuplicate.structureFurnitureDesc": "Structure, finishes, and placed items, without guide references.", + "levelDuplicate.structureMaterials": "Structure + materials", + "levelDuplicate.structureMaterialsDesc": "Structure with the current material and finish assignments.", + "loadBuild.buildings": "Buildings", + "loadBuild.cannotImport": "Cannot import this file", + "loadBuild.ceilings": "Ceilings", + "loadBuild.doors": "Doors", + "loadBuild.errors": "{count} error", + "loadBuild.errors_plural": "{count} errors", + "loadBuild.floorArea": "Floor area", + "loadBuild.hide": "Hide", + "loadBuild.invalidJson": "File could not be parsed as JSON.", + "loadBuild.items": "Items", + "loadBuild.levels": "Levels", + "loadBuild.nodes": "{count} nodes", + "loadBuild.noRecognisedNodes": "The file contains no recognised nodes.", + "loadBuild.readyToImport": "Ready to import", + "loadBuild.replaceScene": "Replace current scene", + "loadBuild.scans": "Scans", + "loadBuild.schemaDetails": "Schema details ({count} nodes)", + "loadBuild.showMore": "Show {count} more", + "loadBuild.sites": "Sites", + "loadBuild.slabs": "Slabs", + "loadBuild.structure": "Structure", + "loadBuild.walls": "Walls", + "loadBuild.warnings": "{count} warning", + "loadBuild.warnings_plural": "{count} warnings", + "loadBuild.windows": "Windows", + "loadBuild.zones": "Zones", + "nav.home": "Home", + "nav.privacy": "Privacy", + "nav.scenes": "Scenes", + "nav.terms": "Terms", + "nodeActions.curve": "Curve", + "nodeActions.cutOut": "Cut Out", + "nodeActions.delete": "Delete", + "nodeActions.duplicate": "Duplicate", + "nodeActions.editMesh": "Edit mesh", + "nodeActions.findInCatalog": "Find in catalog", + "nodeActions.group": "Group selection", + "nodeActions.groupShortcut": "Group (Ctrl/Cmd+G)", + "nodeActions.move": "Move", + "nodeActions.ungroup": "Ungroup selection", + "nodeActions.ungroupShortcut": "Ungroup (Ctrl/Cmd+Shift+G)", + "nodeTypes.boxVent": "Box Vent", + "nodeTypes.building": "Building", + "nodeTypes.ceiling": "Ceiling", + "nodeTypes.ceilingWithArea": "Ceiling ({area}m²)", + "nodeTypes.chimney": "Chimney", + "nodeTypes.column": "Column", + "nodeTypes.door": "Door", + "nodeTypes.dormer": "Dormer", + "nodeTypes.elevator": "Elevator", + "nodeTypes.fence": "Fence", + "nodeTypes.flat": "Flat", + "nodeTypes.flight": "Flight", + "nodeTypes.gable": "Gable", + "nodeTypes.guide": "Guide", + "nodeTypes.hip": "Hip", + "nodeTypes.item": "Item", + "nodeTypes.landing": "Landing", + "nodeTypes.level": "Level", + "nodeTypes.ridgeVent": "Ridge Vent", + "nodeTypes.roof": "Roof", + "nodeTypes.roofSegment": "Roof segment", + "nodeTypes.roofSegmentWithDims": "{type} ({width}×{depth}m)", + "nodeTypes.roofWithSegments": "Roof ({count} segments)", + "nodeTypes.scan": "Scan", + "nodeTypes.selection": "Selection", + "nodeTypes.shed": "Shed", + "nodeTypes.shelf": "Shelf", + "nodeTypes.site": "Site", + "nodeTypes.skyLight": "Skylight", + "nodeTypes.slab": "Slab", + "nodeTypes.slabWithArea": "Slab ({area}m²)", + "nodeTypes.solarPanel": "Solar Panel", + "nodeTypes.spawn": "Spawn Point", + "nodeTypes.stairSegment": "Stair segment", + "nodeTypes.stairSegmentWithDims": "{type} ({width}×{depth}m)", + "nodeTypes.staircaseWithSegments": "Staircase ({count} segments)", + "nodeTypes.stairs": "Stairs", + "nodeTypes.wall": "Wall", + "nodeTypes.window": "Window", + "nodeTypes.zone": "Zone", + "nodeTypes.zoneWithArea": "Zone ({area}m²)", + "nodes.boxVent.baseHeight": "Base Height", + "nodes.boxVent.boxVent": "Box Vent", + "nodes.roofSegment.hip": "Hip", + "nodes.roofSegment.gable": "Gable", + "nodes.roofSegment.shed": "Shed", + "nodes.roofSegment.flat": "Flat", + "nodes.roofSegment.gambrel": "Gambrel", + "nodes.roofSegment.dutch": "Dutch", + "nodes.roofSegment.mansard": "Mansard", + "nodes.roofSegment.conical": "Conical", + "nodes.roofSegment.conicalShape": "Conical Shape", + "nodes.roofSegment.clippedVersion": "Clipped version", + "nodes.roofSegment.startAngle": "Start Angle", + "nodes.roofSegment.arc": "Arc", + "nodes.roofSegment.angle": "Angle", + "nodes.roofSegment.editFootprint": "Edit footprint", + "nodes.roofSegment.autoRidgeVent": "Auto ridge vent", + "nodes.roofSegment.autoGutters": "Auto gutters", + "nodes.roofSegment.kinkDepth": "Kink Depth", + "nodes.roofSegment.kinkHeight": "Kink Height", + "nodes.roofSegment.waistWidth": "Waist Width", + "nodes.roofSegment.waistHeight": "Waist Height", + "nodes.roofSegment.waistLength": "Waist Length", + "nodes.roofSegment.topRakeThickness": "Top Rake Thick.", + "nodes.roofSegment.topRakeLength": "Top Rake Length", + "nodes.roofSegment.wallThickness": "Wall Thick.", + "nodes.roofSegment.deckThickness": "Deck Thick.", + "nodes.roofSegment.shingleThickness": "Shingle Thick.", + "nodes.stairSegment.flight": "Flight", + "nodes.stairSegment.landing": "Landing", + "nodes.stairSegment.front": "Front", + "nodes.stairSegment.left": "Left", + "nodes.stairSegment.right": "Right", + "nodes.elevator.glass": "Glass", + "nodes.ridgeVent.standard": "Standard", + "nodes.ridgeVent.shingled": "Shingled", + "nodes.ridgeVent.flanged": "Flanged", + "nodes.ridgeVent.endCaps": "End Caps", + "nodes.ridgeVent.open": "Open", + "nodes.skylight.top": "Top", + "nodes.skylight.bottom": "Bottom", + "nodes.skylight.left": "Left", + "nodes.skylight.right": "Right", + "nodes.skylight.motor": "Motor", + "nodes.skylight.skylight": "Skylight", + "nodes.skylight.noMotor": "No Motor", + "nodes.skylight.alongZ": "Along Z", + "nodes.skylight.alongX": "Along X", + "nodes.skylight.yes": "Yes", + "nodes.skylight.no": "No", + "common.yes": "Yes", + "common.no": "No", + "nodes.boxVent.baseInset": "Base Inset", + "nodes.boxVent.box": "Box", + "nodes.boxVent.cap": "Cap", + "nodes.boxVent.capHeight": "Cap Height", + "nodes.boxVent.cornerBevel": "Corner Bevel", + "nodes.boxVent.dome": "Dome", + "nodes.boxVent.domeCurvature": "Dome Curvature", + "nodes.boxVent.gapHeight": "Gap Height", + "nodes.boxVent.hoodOverhang": "Hood Overhang", + "nodes.boxVent.defaultName": "Box Vent {count}", + "nodes.boxVent.topTaper": "Top Taper", + "nodes.boxVent.style": "Style", + "nodes.boxVent.toolHints.cancel": "Cancel", + "nodes.boxVent.toolHints.place": "Place box vent", + "nodes.building.description": "A building container holding one or more levels.", + "nodes.building.label": "Building", + "nodes.ceiling.addHole": "Add Hole", + "nodes.ceiling.auto": "Auto", + "nodes.ceiling.autoHoleLabel.elevator": "Auto elevator cutout", + "nodes.ceiling.autoHoleLabel.stair": "Auto stair cutout", + "nodes.ceiling.customHeight": "Custom height", + "nodes.ceiling.currently": "Currently {value}", + "nodes.ceiling.editing": "Editing", + "nodes.ceiling.followsLevel": "Follows level", + "nodes.ceiling.height": "Height", + "nodes.ceiling.heightPresets.high": "High (3.0m)", + "nodes.ceiling.heightPresets.highImperial": "High (9'0\")", + "nodes.ceiling.heightPresets.low": "Low (2.4m)", + "nodes.ceiling.heightPresets.lowImperial": "Low (8'0\")", + "nodes.ceiling.heightPresets.standard": "Standard (2.5m)", + "nodes.ceiling.heightPresets.standardImperial": "Standard (8'6\")", + "nodes.ceiling.holeLabel": "Hole {index}", + "nodes.ceiling.holes": "Holes", + "nodes.ceiling.info": "Info", + "nodes.ceiling.limitedBy": "Limited by the level to {available} — raise the level height for a taller ceiling.", + "nodes.ceiling.manual": "Manual", + "nodes.ceiling.noHoles": "No holes", + "nodes.ceiling.tooTall": "Taller than this level ({available} available). Raise the level height first.", + "nodes.chimney.aboveRidge": "Above Ridge", + "nodes.chimney.back": "Back", + "nodes.chimney.bandExtent": "Extent", + "nodes.chimney.bandHeight": "Height", + "nodes.chimney.bandOffset": "Offset", + "nodes.chimney.bandThickness": "Thickness", + "nodes.chimney.bands": "Bands", + "nodes.chimney.cap": "Cap", + "nodes.chimney.capThickness": "Thickness", + "nodes.chimney.corbeled": "Corbeled", + "nodes.chimney.cornerBevel": "Corner Bevel", + "nodes.chimney.count": "Count", + "nodes.chimney.width": "Width", + "nodes.chimney.cricket": "Cricket", + "nodes.chimney.cricketHeight": "Height", + "nodes.chimney.cricketLength": "Length", + "nodes.chimney.cutoutOffset": "Cutout Offset", + "nodes.chimney.diameter": "Diameter", + "nodes.chimney.double": "Double", + "nodes.chimney.flat": "Flat", + "nodes.chimney.flueCount": "Count", + "nodes.chimney.flueDiameter": "Diameter", + "nodes.chimney.flueHeight": "Height", + "nodes.chimney.flueShape": "Shape", + "nodes.chimney.flueSpacing": "Spacing", + "nodes.chimney.flueWallThickness": "Wall Thickness", + "nodes.chimney.wallThickness": "Wall Thickness", + "nodes.chimney.flues": "Flues", + "nodes.chimney.front": "Front", + "nodes.chimney.hollowDepth": "Hollow Depth", + "nodes.chimney.none": "None", + "nodes.chimney.overhang": "Overhang", + "nodes.chimney.panelDepth": "Depth", + "nodes.chimney.panelHeight": "Height", + "nodes.chimney.panelMargin": "Side Margin", + "nodes.chimney.panelOffsetTop": "Top Offset", + "nodes.chimney.panels": "Panels", + "nodes.chimney.rectangular": "Rectangular", + "nodes.chimney.round": "Round", + "nodes.chimney.shoulder": "Shoulder", + "nodes.chimney.shoulderExtent": "Extent", + "nodes.chimney.shoulderHeight": "Height", + "nodes.chimney.simple": "Simple", + "nodes.chimney.single": "Single", + "nodes.chimney.sloped": "Sloped", + "nodes.chimney.square": "Square", + "nodes.chimney.stepped": "Stepped", + "nodes.chimney.tapered": "Tapered", + "nodes.chimney.chimney": "Chimney", + "nodes.chimney.chimneyType": "Chimney Type", + "nodes.chimney.offset": "Offset", + "nodes.chimney.footprint": "Footprint", + "nodes.chimney.style": "Style", + "nodes.column.aFrame": "A-Frame", + "nodes.column.applyPreset": "Apply preset...", + "nodes.column.applyProportion": "Apply proportion...", + "nodes.column.preset": "Preset", + "nodes.column.shape": "Shape", + "nodes.column.transform": "Transform", + "nodes.column.bottomDepth": "Bottom Depth", + "nodes.column.bottomHeight": "Bottom Height", + "nodes.column.bottomSpread": "Bottom Spread", + "nodes.column.bottomStepSpread": "Bottom Step Spread", + "nodes.column.bottomTiers": "Bottom Tiers", + "nodes.column.bottomWidth": "Bottom Width", + "nodes.column.boxFrame": "Box Frame", + "nodes.column.braceDepth": "Brace Depth", + "nodes.column.braceWidth": "Brace Width", + "nodes.column.bulge": "Bulge", + "nodes.column.bulged": "Bulged", + "nodes.column.connectorPlates": "Connector Plates", + "nodes.column.edgeSoftness": "Edge Softness", + "nodes.column.endWidth": "End Width", + "nodes.column.forkSpread": "Fork Spread", + "nodes.column.hourglass": "Hourglass", + "nodes.column.kBrace": "K Brace", + "nodes.column.neckWidth": "Neck Width", + "nodes.column.noBottom": "No Bottom", + "nodes.column.noTop": "No Top", + "nodes.column.plinthThickness": "Plinth Thickness", + "nodes.column.portalFrame": "Portal Frame", + "nodes.column.rectangular": "Rectangular", + "nodes.column.ringPairs": "Ring Pairs", + "nodes.column.ringSpread": "Ring Spread", + "nodes.column.ringThickness": "Ring Thickness", + "nodes.column.round": "Round", + "nodes.column.roundBandWidth": "Round Band Width", + "nodes.column.roundRings": "Rounded Bottom", + "nodes.column.roundedTop": "Rounded Top", + "nodes.column.segmentTwist": "Segment Twist", + "nodes.column.shaftCornerRadius": "Shaft Corner Radius", + "nodes.column.shaftWidth": "Shaft Width", + "nodes.column.simpleBlockBottom": "Simple Block Bottom", + "nodes.column.simpleTop": "Simple Top", + "nodes.column.singleStrut": "Single Strut", + "nodes.column.square": "Square", + "nodes.column.squarePlinthBottom": "Square Plinth Bottom", + "nodes.column.steppedBottom": "Stepped Bottom", + "nodes.column.steppedTop": "Stepped Top", + "nodes.column.straight": "Straight", + "nodes.column.taper": "Taper", + "nodes.column.tapered": "Tapered", + "nodes.column.topDepth": "Top Depth", + "nodes.column.topHeight": "Top Height", + "nodes.column.topSpread": "Top Spread", + "nodes.column.topStepSpread": "Top Step Spread", + "nodes.column.topTiers": "Top Tiers", + "nodes.column.topWidth": "Top Width", + "nodes.column.trestle": "Trestle", + "nodes.column.tripod": "Tripod", + "nodes.column.twistSegments": "Twist Segments", + "nodes.column.vFrame": "V Support", + "nodes.column.vertical": "Vertical", + "nodes.column.waist": "Waist", + "nodes.column.xBrace": "X Brace", + "nodes.column.yFrame": "Y Support", + "nodes.column.yaw": "Yaw", + "nodes.column.dimensions": "Dimensions", + "nodes.column.shaft": "Shaft", + "nodes.column.ends": "Ends", + "nodes.column.slender": "Slender", + "nodes.column.standard": "Standard", + "nodes.column.heavy": "Heavy", + "nodes.column.shortStout": "Short / Stout", + "nodes.door.addSegment": "+ Add Segment", + "nodes.door.archHeight": "Arch Height", + "nodes.door.columnLabel": "C{i}", + "nodes.door.columns": "Columns", + "nodes.door.contentPaddingSection": "Content Padding", + "nodes.door.corners.topLeft": "Top Left", + "nodes.door.corners.topRight": "Top Right", + "nodes.door.defaultName": "Door {count}", + "nodes.door.description": "A door cut into a wall. Animated open/close.", + "nodes.door.direction": "Direction", + "nodes.door.divider": "Divider", + "nodes.door.doorCloser": "Door Closer", + "nodes.door.doorTypeOptions.barn": "Barn", + "nodes.door.doorTypeOptions.double": "Double", + "nodes.door.doorTypeOptions.folding": "Folding", + "nodes.door.doorTypeOptions.french": "French", + "nodes.door.doorTypeOptions.hinged": "Hinged", + "nodes.door.doorTypeOptions.pocket": "Pocket", + "nodes.door.doorTypeOptions.rollup": "Roll-up", + "nodes.door.doorTypeOptions.sectional": "Sectional", + "nodes.door.doorTypeOptions.sliding": "Sliding", + "nodes.door.doorTypeOptions.tiltup": "Tilt-up", + "nodes.door.enableHandle": "Enable Handle", + "nodes.door.enableThreshold": "Enable Threshold", + "nodes.door.fallbackTitle": "Door", + "nodes.door.flipSide": "Flip Side", + "nodes.door.fold": "Fold", + "nodes.door.frameSection": "Frame", + "nodes.door.handleSection": "Handle", + "nodes.door.handleSide": "Handle Side", + "nodes.door.hardwareSection": "Hardware", + "nodes.door.hingesSide": "Hinges Side", + "nodes.door.horizontalPadding": "Horizontal", + "nodes.door.inset": "Inset", + "nodes.door.label": "Door", + "nodes.door.open": "Open", + "nodes.door.openingShapeOptions.arch": "Arch", + "nodes.door.openingShapeOptions.rect": "Rect", + "nodes.door.openingShapeOptions.rounded": "Rounded", + "nodes.door.openingShapeSection": "Opening Shape", + "nodes.door.panels": "Panels", + "nodes.door.panicBar": "Panic Bar", + "nodes.door.panicBarHeight": "Bar Height", + "nodes.door.radiusModeOptions.all": "All", + "nodes.door.radiusModeOptions.individual": "Individual", + "nodes.door.remove": "- Remove", + "nodes.door.revealRadius": "Reveal Radius", + "nodes.door.segmentLabel": "Segment {i}", + "nodes.door.segmentTypeOptions.empty": "Empty", + "nodes.door.segmentTypeOptions.glass": "Glass", + "nodes.door.segmentTypeOptions.panel": "Panel", + "nodes.door.segmentsSection": "Segments", + "nodes.door.slide": "Slide", + "nodes.door.slideDirectionOptions.panel": "Panel", + "nodes.door.slideDirectionOptions.pocket": "Pocket", + "nodes.door.slideDirectionOptions.rail": "Rail", + "nodes.door.swingDirectionOptions.inward": "Inward", + "nodes.door.swingDirectionOptions.outward": "Outward", + "nodes.door.swingSection": "Swing", + "nodes.door.thresholdSection": "Threshold", + "nodes.door.toolHints.cancel": "Cancel", + "nodes.door.toolHints.place": "Place door on wall", + "nodes.door.topShapeOptions.arch": "Arch", + "nodes.door.topShapeOptions.rect": "Rect", + "nodes.door.topShapeOptions.rounded": "Rounded", + "nodes.door.topShapeSection": "Top Shape", + "nodes.door.type": "Type", + "nodes.door.typeOptions.door": "Door", + "nodes.door.typeOptions.garage": "Garage", + "nodes.door.typeOptions.opening": "Opening", + "nodes.door.verticalPadding": "Vertical", + "nodes.dormer.all": "All", + "nodes.dormer.arch": "Arch", + "nodes.dormer.archHeight": "Arch Height", + "nodes.dormer.bottomLeft": "Bottom Left", + "nodes.dormer.bottomRight": "Bottom Right", + "nodes.dormer.columns": "Columns", + "nodes.dormer.cornerRadius": "Corner Radius", + "nodes.dormer.depth": "Depth", + "nodes.dormer.divider": "Divider", + "nodes.dormer.dormer": "Dormer", + "nodes.dormer.dutch": "Dutch", + "nodes.dormer.defaultName": "Window {count}", + "nodes.dormer.enableSill": "Enable Sill", + "nodes.dormer.flat": "Flat", + "nodes.dormer.frameDepth": "Depth", + "nodes.dormer.frameThickness": "Thickness", + "nodes.dormer.gable": "Gable", + "nodes.dormer.gambrel": "Gambrel", + "nodes.dormer.hip": "Hip", + "nodes.dormer.hungWall": "Hung Wall", + "nodes.dormer.individual": "Individual", + "nodes.dormer.mansard": "Mansard", + "nodes.dormer.pitchDirection": "Pitch Direction", + "nodes.dormer.pitchRise": "Pitch Rise", + "nodes.dormer.rect": "Rect", + "nodes.dormer.riseBack": "Rise Back", + "nodes.dormer.riseFront": "Rise Front", + "nodes.dormer.roofHeight": "Roof Height", + "nodes.dormer.rounded": "Rounded", + "nodes.dormer.roofType": "Roof Type", + "nodes.dormer.rows": "Rows", + "nodes.dormer.section": "Section", + "nodes.dormer.shed": "Shed", + "nodes.dormer.sillDepth": "Depth", + "nodes.dormer.sillThickness": "Thickness", + "nodes.dormer.topLeft": "Top Left", + "nodes.dormer.topRight": "Top Right", + "nodes.dormer.wallHeight": "Wall Height", + "nodes.dormer.width": "Width", + "nodes.dormer.window": "Window", + "nodes.dormer.windowHeight": "Height", + "nodes.dormer.windowOffsetX": "Offset X", + "nodes.dormer.windowOffsetY": "Offset Y", + "nodes.dormer.windowWidth": "Width", + "nodes.elevator.cabDepth": "Depth", + "nodes.elevator.cabHeight": "Cab Height", + "nodes.elevator.cabWidth": "Width", + "nodes.elevator.centerOpening": "Center opening", + "nodes.elevator.defaultFloor": "Default Floor", + "nodes.elevator.disabled": "Disabled", + "nodes.elevator.doorHeight": "Door Height", + "nodes.elevator.solid": "Solid", + "nodes.elevator.doorTime": "Door Time", + "nodes.elevator.doorType": "Door Type", + "nodes.elevator.doorWidth": "Door Width", + "nodes.elevator.dwell": "Dwell", + "nodes.elevator.from": "From", + "nodes.elevator.glassFrame": "Glass frame", + "nodes.elevator.openingStyle": "Opening Style", + "nodes.elevator.segmentedPanel": "Segmented panel", + "nodes.elevator.service": "Service", + "nodes.elevator.shaftDepth": "Shaft Depth", + "nodes.elevator.shaftStyle": "Shaft Style", + "nodes.elevator.shaftWidth": "Shaft Width", + "nodes.elevator.singleLeft": "Single left", + "nodes.elevator.singleRight": "Single right", + "nodes.elevator.solidPanel": "Solid panel", + "nodes.elevator.speed": "Speed", + "nodes.elevator.to": "To", + "nodes.elevator.wallThickness": "Wall Thickness", + "nodes.elevator.yaw": "Yaw", + "nodes.elevator.access": "Access", + "nodes.elevator.cab": "Cab", + "nodes.elevator.destination": "Destination", + "nodes.elevator.doors": "Doors", + "nodes.elevator.copy": "{name} Copy", + "nodes.elevator.defaultCopy": "Elevator Copy", + "nodes.elevator.motion": "Motion", + "nodes.elevator.offset": "Offset", + "nodes.elevator.rotation": "Rotation", + "nodes.elevator.rotationPresets.negative": "-45°", + "nodes.elevator.rotationPresets.positive": "+45°", + "nodes.elevator.shaft": "Shaft", + "nodes.elevator.stopLabel": "Stop {n}", + "nodes.elevator.serviceButton": "Service", + "nodes.elevator.disabledButton": "Disabled", + "nodes.fence.description": "A straight or curved fence segment with configurable posts and infill.", + "nodes.fence.label": "Fence", + "nodes.fence.toolHints.allowAngles": "Allow non-45° angles", + "nodes.fence.toolHints.cancel": "Cancel", + "nodes.fence.toolHints.setStartEnd": "Set fence start / end", + "nodes.measurement.toolHints.finish": "Finish measurement", + "nodes.measurement.toolHints.finishContinue": "Finish and continue", + "nodes.measurement.toolHints.placePoint": "Place measurement point", + "nodes.measurement.toolHints.removeLast": "Remove last point", + "nodes.level.description": "A single floor of a building, holding walls / slabs / ceilings / items.", + "nodes.level.label": "Level", + "nodes.roof.addChimney": "Add Chimney", + "nodes.roof.addCupola": "Add Cupola", + "nodes.roof.addDormer": "Add Dormer", + "nodes.roof.addEyebrowVent": "Add Eyebrow Vent", + "nodes.roof.addGutter": "Add Gutter", + "nodes.roof.addSegment": "Add Segment", + "nodes.roof.addSkylight": "Add Skylight", + "nodes.roof.addSolarPanel": "Add Solar Panel", + "nodes.roof.addVent": "Add Vent", + "nodes.roof.box": "Box", + "nodes.roof.defaultName.boxVent": "Box Vent {count}", + "nodes.roof.defaultName.chimney": "Chimney {count}", + "nodes.roof.defaultName.dormer": "Dormer {count}", + "nodes.roof.defaultName.gutter": "Gutter {count}", + "nodes.roof.defaultName.ridgeVent": "Ridge Vent {count}", + "nodes.roof.defaultName.segment": "Segment {count}", + "nodes.roof.defaultName.skylight": "Skylight {count}", + "nodes.roof.defaultName.solarPanel": "Solar Panel {count}", + "nodes.roof.defaultName.turbineVent": "Turbine Vent {count}", + "nodes.roof.elements": "Elements", + "nodes.roof.fallbackTitle": "Roof", + "nodes.roof.kindLabel.boxVent": "box vent", + "nodes.roof.kindLabel.chimney": "chimney", + "nodes.roof.kindLabel.dormer": "dormer", + "nodes.roof.kindLabel.gutter": "gutter", + "nodes.roof.kindLabel.ridgeVent": "ridge vent", + "nodes.roof.kindLabel.skylight": "skylight", + "nodes.roof.kindLabel.solarPanel": "solar panel", + "nodes.roof.kindLabel.turbineVent": "turbine vent", + "nodes.roofSegment.fallbackTitle": "Roof Segment", + "nodes.roofSegment.roofType": "Roof Type", + "nodes.roofSegment.footprint": "Footprint", + "nodes.roofSegment.wallHeight": "Wall Height", + "nodes.roofSegment.pitch": "Pitch", + "nodes.roofSegment.shape": "Shape", + "nodes.roofSegment.structure": "Structure", + "nodes.roofSegment.trim": "Trim", + "nodes.roofSegment.drainage": "Drainage", + "nodes.roof.ridge": "Ridge", + "nodes.roof.segments": "Segments", + "nodes.roof.turbine": "Turbine", + "nodes.slab.addHole": "Add Hole", + "nodes.slab.area": "Area", + "nodes.slab.auto": "Auto", + "nodes.slab.autoHoleLabel.elevator": "Auto elevator cutout", + "nodes.slab.autoHoleLabel.stair": "Auto stair cutout", + "nodes.slab.base": "Base", + "nodes.slab.depth": "Depth", + "nodes.slab.description": "A polygon-bounded floor surface that hosts items on top.", + "nodes.slab.editing": "(Editing)", + "nodes.slab.elevationPresets.ground": "Ground (0m)", + "nodes.slab.elevationPresets.raised": "Raised (+5cm)", + "nodes.slab.elevationPresets.standard": "Standard (5cm)", + "nodes.slab.elevationPresets.step": "Step (+15cm)", + "nodes.slab.elevationPresets.sunken": "Sunken (-15cm)", + "nodes.slab.elevationPresets.thick": "Thick (15cm)", + "nodes.slab.elevationPresets.thin": "Thin (2cm)", + "nodes.slab.fallbackTitle": "Slab", + "nodes.slab.fixed": "Fixed", + "nodes.slab.floor": "Floor", + "nodes.slab.followsTerrain": "Follows terrain", + "nodes.slab.foundation": "Foundation", + "nodes.slab.holeLabel": "Hole {index}", + "nodes.slab.label": "Slab", + "nodes.slab.manual": "Manual", + "nodes.slab.noHoles": "No holes", + "nodes.slab.pts": "pts", + "nodes.slab.rim": "Rim", + "nodes.slab.surface": "Surface", + "nodes.slab.terrainDescription": "Extends the perimeter down to terrain. The flat surface, base, and thickness stay unchanged.", + "nodes.slab.toolHints.cancel": "Cancel", + "nodes.slab.toolHints.finish": "Finish slab", + "nodes.slab.toolHints.trace": "Trace slab outline", + "nodes.slab.elevation": "Elevation", + "nodes.slab.info": "Info", + "nodes.slab.holes": "Holes", + "nodes.solarPanel.columns": "Columns", + "nodes.solarPanel.customNotice": "Custom — dimensions don't match any preset", + "nodes.solarPanel.fallbackTitle": "Solar Panel", + "nodes.solarPanel.flipOrientation": "Flip orientation", + "nodes.solarPanel.flush": "Flush", + "nodes.solarPanel.frameDepth": "Frame depth", + "nodes.solarPanel.frameThickness": "Frame thickness", + "nodes.solarPanel.gapX": "Gap X", + "nodes.solarPanel.gapY": "Gap Y", + "nodes.solarPanel.rows": "Rows", + "nodes.solarPanel.setbacksTooLarge": "Setbacks too large to fit a panel.", + "nodes.solarPanel.standoff": "Standoff", + "nodes.solarPanel.tiltAngle": "Tilt angle", + "nodes.solarPanel.tilted": "Tilted", + "nodes.solarPanel.preset": "Preset", + "nodes.solarPanel.presetCompact": "Compact", + "nodes.solarPanel.presetFrameless": "Frameless", + "nodes.solarPanel.presetResidential": "Residential", + "nodes.solarPanel.presetResidentialLarge": "Residential Large", + "nodes.solarPanel.array": "Array", + "nodes.solarPanel.panel": "Panel", + "nodes.solarPanel.mounting": "Mounting", + "nodes.solarPanel.autoFitToRoof": "Auto-fit to roof", + "nodes.solarPanel.toolHints.cancel": "Cancel", + "nodes.solarPanel.toolHints.place": "Place solar panel", + "nodes.skyLight.type": "Type", + "nodes.skyLight.dimensions": "Dimensions", + "nodes.skyLight.frame": "Frame", + "nodes.skyLight.curb": "Curb", + "nodes.skyLight.fallbackTitle": "Skylight", + "nodes.ridgeVent.style": "Style", + "nodes.ridgeVent.dimensions": "Dimensions", + "nodes.ridgeVent.rotation": "Rotation", + "nodes.ridgeVent.fallbackTitle": "Ridge Vent", + "nodes.stair.addFlight": "Add flight", + "nodes.stair.addLanding": "Add landing", + "nodes.stair.autoCutout": "Auto Cutout", + "nodes.stair.both": "Both", + "nodes.stair.centerColumn": "Center Column", + "nodes.stair.curved": "Curved", + "nodes.stair.destination": "Destination", + "nodes.stair.fitToFloor": "Fit To Floor", + "nodes.stair.fallbackTitle": "Staircase", + "nodes.stair.fromLevel": "From Level", + "nodes.stair.followsDeck": "Follows deck", + "nodes.stair.customRise": "Custom rise", + "nodes.stair.currently": "Currently {height} m", + "nodes.stair.deckName": "Deck", + "nodes.stair.geometry": "Geometry", + "nodes.stair.landing": "Landing", + "nodes.stair.levelName": "Level {n}", + "nodes.stair.rise": "Rise", + "nodes.stair.segmentName": "Segment {n}", + "nodes.stair.steps": "Steps", + "nodes.stair.innerRadius": "Inner Radius", + "nodes.stair.integrated": "Integrated", + "nodes.stair.left": "Left", + "nodes.stair.none": "None", + "nodes.stair.opening": "Opening", + "nodes.stair.openingOffset": "Opening Offset", + "nodes.stair.position": "Position", + "nodes.stair.railing": "Railing", + "nodes.stair.railingHeight": "Height", + "nodes.stair.right": "Right", + "nodes.stair.rotationPresets.negative": "-45°", + "nodes.stair.rotationPresets.positive": "+45°", + "nodes.stair.segment": "Segment", + "nodes.stair.segments": "Segments", + "nodes.stair.spiral": "Spiral", + "nodes.stair.stepSupports": "Step Supports", + "nodes.stair.straight": "Straight", + "nodes.stair.sweep": "Sweep", + "nodes.stair.toLevel": "To Level", + "nodes.stair.topLanding": "Top Landing", + "nodes.stair.type": "Type", + "nodes.stairSegment.type": "Type", + "nodes.stairSegment.attachment": "Attachment", + "nodes.stairSegment.dimensions": "Dimensions", + "nodes.stairSegment.structure": "Structure", + "nodes.stairSegment.fallbackTitle": "Stair Segment", + "nodes.stairSegment.width": "Width", + "nodes.stairSegment.length": "Length", + "nodes.stairSegment.height": "Height", + "nodes.stairSegment.steps": "Steps", + "nodes.stairSegment.fillToFloor": "Fill to floor", + "nodes.stairSegment.thickness": "Thickness", + "nodes.stairSegment.move": "Move", + "nodes.stairSegment.duplicate": "Duplicate", + "nodes.wall.bands": "Wall bands", + "nodes.wall.curve": "Curve", + "nodes.wall.description": "A straight or curved wall segment. Hosts doors, windows, and wall-mounted items.", + "nodes.wall.draftName": "Draft wall", + "nodes.wall.fallbackTitle": "Wall", + "nodes.wall.followsLevel": "Follows level", + "nodes.wall.customHeight": "Custom height", + "nodes.wall.currently": "Currently {measurement}", + "nodes.wall.label": "Wall", + "nodes.wall.length": "Length", + "nodes.wall.top": "Top", + "nodes.wall.bottom": "Bottom", + "nodes.wall.auto": "Auto", + "nodes.wall.fillToTerrain": "Fill to terrain", + "nodes.wall.fillToTerrainDescription": "Extends downward to meet the terrain. Height and top stay unchanged.", + "nodes.wall.thickness": "Thickness", + "nodes.wall.bandsCount": "Bands", + "nodes.wall.bandsLower": "Lower", + "nodes.wall.bandsMiddle": "Middle", + "nodes.wall.bandsUpper": "Upper", + "nodes.wall.skirting": "Skirting", + "nodes.wall.crown": "Crown molding", + "nodes.wall.chairRail": "Chair rail", + "nodes.wall.trimHide": "Hide {trim}", + "nodes.wall.trimShow": "Show {trim}", + "nodes.wall.interior": "Interior", + "nodes.wall.exterior": "Exterior", + "nodes.wall.both": "Both", + "nodes.wall.proud": "Proud", + "nodes.wall.offset": "Offset", + "nodes.wall.trimProfile.flat": "Flat", + "nodes.wall.trimProfile.modern": "Modern", + "nodes.wall.trimProfile.colonial": "Colonial", + "nodes.wall.trimProfile.shoe": "Shoe", + "nodes.wall.trimProfile.ogee": "Ogee", + "nodes.wall.trimProfile.cove": "Cove", + "nodes.wall.trimProfile.craft": "Craft", + "nodes.wall.trimProfile.layered": "Layered", + "nodes.wall.trimProfile.round": "Round", + "nodes.wall.trimProfile.picture": "Picture", + "nodes.wall.trimProfile.step": "Step", + "nodes.wall.dimensions": "Dimensions", + "nodes.spawn.spawnPoint": "Spawn Point", + "nodes.spawn.position": "Position", + "nodes.spawn.facing": "Facing", + "nodes.spawn.yaw": "Yaw", + "nodes.wall.toolHints.allowAngles": "Allow non-45° angles", + "nodes.wall.toolHints.cancel": "Cancel", + "nodes.wall.toolHints.setStartEnd": "Set wall start / end", + "nodes.window.archHeight": "Arch Height", + "nodes.window.casementStyleOptions.french": "French", + "nodes.window.casementStyleOptions.single": "Single", + "nodes.window.colWidths": "Col Widths", + "nodes.window.columns": "Columns", + "nodes.window.cornerRadius": "Corner Radius", + "nodes.window.corners.bottomLeft": "Bottom Left", + "nodes.window.corners.bottomRight": "Bottom Right", + "nodes.window.corners.topLeft": "Top Left", + "nodes.window.corners.topRight": "Top Right", + "nodes.window.defaultName": "Window {count}", + "nodes.window.depth": "Depth", + "nodes.window.description": "A window cut into a wall. Animated open/close for opening windows.", + "nodes.window.divider": "Divider", + "nodes.window.enableSill": "Enable Sill", + "nodes.window.fallbackTitle": "Window", + "nodes.window.flipSide": "Flip Side", + "nodes.window.frame": "Frame", + "nodes.window.grid": "Grid", + "nodes.window.label": "Window", + "nodes.window.openingShape": "Opening Shape", + "nodes.window.openingShapeOptions.arch": "Arch", + "nodes.window.openingShapeOptions.rect": "Rect", + "nodes.window.openingShapeOptions.rounded": "Rounded", + "nodes.window.operationLabels.slide": "Slide", + "nodes.window.operationLabels.raise": "Raise", + "nodes.window.operationLabels.slats": "Slats", + "nodes.window.operationLabels.swing": "Swing", + "nodes.window.operationLabels.tilt": "Tilt", + "nodes.window.radiusModeOptions.all": "All", + "nodes.window.radiusModeOptions.individual": "Individual", + "nodes.window.revealRadius": "Reveal Radius", + "nodes.window.rowHeights": "Row Heights", + "nodes.window.rowLabel": "R{i}", + "nodes.window.rows": "Rows", + "nodes.window.sill": "Sill", + "nodes.window.sillDepth": "Sill Depth", + "nodes.window.sillThickness": "Sill Thickness", + "nodes.window.thickness": "Thickness", + "nodes.window.toolHints.cancel": "Cancel", + "nodes.window.toolHints.place": "Place window on wall", + "nodes.window.topShape": "Top Shape", + "nodes.window.topShapeOptions.arch": "Arch", + "nodes.window.topShapeOptions.rect": "Rect", + "nodes.window.topShapeOptions.rounded": "Rounded", + "nodes.window.type": "Type", + "nodes.window.typeOptions.awning": "Awning", + "nodes.window.typeOptions.bay": "Bay", + "nodes.window.typeOptions.bow": "Bow", + "nodes.window.typeOptions.casement": "Casement", + "nodes.window.typeOptions.doubleHung": "Double Hung", + "nodes.window.typeOptions.fixed": "Fixed", + "nodes.window.typeOptions.louvered": "Louvered", + "nodes.window.typeOptions.opening": "Opening", + "nodes.window.typeOptions.singleHung": "Single Hung", + "nodes.window.typeOptions.sliding": "Sliding", + "nodes.window.typeOptions.window": "Window", + "nodes.window.windowType": "Window Type", + "panel.deleteLevel": "Delete level", + "panel.duplicateLevel": "Duplicate level", + "panel.editScale": "Edit Scale", + "panel.hideScale": "Hide Scale", + "panel.intensity": "Intensity", + "panel.nodeType.block": "Block", + "panel.nodeType.block_plural": "Blocks", + "panel.nodeType.boxVent": "Box Vent", + "panel.nodeType.boxVent_plural": "Box Vents", + "panel.nodeType.building": "Building", + "panel.nodeType.building_plural": "Buildings", + "panel.nodeType.ceiling": "Ceiling", + "panel.nodeType.ceiling_plural": "Ceilings", + "panel.nodeType.chimney": "Chimney", + "panel.nodeType.chimney_plural": "Chimneys", + "panel.nodeType.constructionDimension": "Construction Dimension", + "panel.nodeType.constructionDimension_plural": "Construction Dimensions", + "panel.nodeType.cupola": "Cupola", + "panel.nodeType.cupola_plural": "Cupolas", + "panel.nodeType.dormer": "Dormer", + "panel.nodeType.dormer_plural": "Dormers", + "panel.nodeType.downspout": "Downspout", + "panel.nodeType.downspout_plural": "Downspouts", + "panel.nodeType.ductFitting": "Duct Fitting", + "panel.nodeType.ductFitting_plural": "Duct Fittings", + "panel.nodeType.ductSegment": "Duct", + "panel.nodeType.ductSegment_plural": "Ducts", + "panel.nodeType.ductTerminal": "Register", + "panel.nodeType.ductTerminal_plural": "Registers", + "panel.nodeType.eyebrowVent": "Eyebrow Vent", + "panel.nodeType.eyebrowVent_plural": "Eyebrow Vents", + "panel.nodeType.gutter": "Gutter", + "panel.nodeType.gutter_plural": "Gutters", + "panel.nodeType.hvacEquipment": "HVAC Unit", + "panel.nodeType.hvacEquipment_plural": "HVAC Units", + "panel.nodeType.leanToExtension": "Canopy", + "panel.nodeType.leanToExtension_plural": "Canopies", + "panel.nodeType.level": "Level", + "panel.nodeType.level_plural": "Levels", + "panel.nodeType.lineset": "Lineset", + "panel.nodeType.lineset_plural": "Linesets", + "panel.nodeType.liquidLine": "Liquid Line", + "panel.nodeType.liquidLine_plural": "Liquid Lines", + "panel.nodeType.pipeFitting": "Pipe Fitting", + "panel.nodeType.pipeFitting_plural": "Pipe Fittings", + "panel.nodeType.pipeSegment": "DWV Pipe", + "panel.nodeType.pipeSegment_plural": "DWV Pipes", + "panel.nodeType.pipeTrap": "Trap", + "panel.nodeType.pipeTrap_plural": "Traps", + "panel.nodeType.ridgeVent": "Ridge Vent", + "panel.nodeType.ridgeVent_plural": "Ridge Vents", + "panel.nodeType.shelf": "Shelf", + "panel.nodeType.shelf_plural": "Shelves", + "panel.nodeType.site": "Site", + "panel.nodeType.site_plural": "Sites", + "panel.nodeType.skylight": "Skylight", + "panel.nodeType.skylight_plural": "Skylights", + "panel.nodeType.solarPanel": "Solar Panel", + "panel.nodeType.solarPanel_plural": "Solar Panels", + "panel.nodeType.structuralGrid": "Structural Grid", + "panel.nodeType.structuralGrid_plural": "Structural Grids", + "panel.nodeType.turbineVent": "Turbine Vent", + "panel.nodeType.turbineVent_plural": "Turbine Vents", + "panel.nodeType.zone": "Zone", + "panel.nodeType.zone_plural": "Zones", + "panel.nodeType.column": "Column", + "panel.nodeType.column_plural": "Columns", + "panel.nodeType.door": "Door", + "panel.nodeType.door_plural": "Doors", + "panel.nodeType.elevator": "Elevator", + "panel.nodeType.elevator_plural": "Elevators", + "panel.nodeType.fence": "Fence", + "panel.nodeType.fence_plural": "Fences", + "panel.nodeType.guide": "Guide image", + "panel.nodeType.guide_plural": "Guide images", + "panel.nodeType.item": "Item", + "panel.nodeType.item_plural": "Items", + "panel.nodeType.roof": "Roof", + "panel.nodeType.roof_plural": "Roofs", + "panel.nodeType.roofSegment": "Roof segment", + "panel.nodeType.roofSegment_plural": "Roof segments", + "panel.nodeType.scan": "3D Scan", + "panel.nodeType.scan_plural": "3D Scans", + "panel.nodeType.slab": "Slab", + "panel.nodeType.slab_plural": "Slabs", + "panel.nodeType.stair": "Stair", + "panel.nodeType.stair_plural": "Stairs", + "panel.nodeType.stairSegment": "Stair segment", + "panel.nodeType.stairSegment_plural": "Stair segments", + "panel.nodeType.wall": "Wall", + "panel.nodeType.wall_plural": "Walls", + "panel.nodeType.window": "Window", + "panel.nodeType.window_plural": "Windows", + "panel.nodeType.measurement": "Measurement", + "panel.nodeType.measurement_plural": "Measurements", + "panel.nodeType.spawn": "Spawn", + "panel.nodeType.spawn_plural": "Spawns", + "panel.nodeType.cabinet": "Cabinet", + "panel.nodeType.cabinet_plural": "Cabinets", + "panel.nodeType.cabinetModule": "Cabinet module", + "panel.nodeType.cabinetModule_plural": "Cabinet modules", + "panel.action.group": "Group", + "panel.action.ungroup": "Ungroup", + "panel.action.duplicate": "Duplicate", + "panel.action.delete": "Delete", + "panel.multiSelection.selected": "{count} selected", + "panel.multiSelection.withGroup": "{label} · {count}", + "panel.multiSelection.sessionOnlyFooter": "{label} (session only). Plain click reselects all members. Not saved with the project.", + "panel.noEdgeLines": "No edge lines", + "panel.scaleAndOpacity": "Scale & Opacity", + "panel.selection": "Selection", + "panel.setScale": "Set Scale", + "panel.showScale": "Show Scale", + "scenes.allScenes": "All scenes", + "scenes.noScenes": "No scenes yet. Create one to get started.", + "scenes.noThumbnail": "No thumbnail", + "scenes.nodes": "{count} nodes", + "scenes.sceneCount": "{count} scene", + "scenes.sceneCount_plural": "{count} scenes", + "scenes.title": "Your scenes", + "scenes.noScenesSaved": "No scenes saved yet", + "save.conflictReload": "Conflict — scene changed elsewhere. Reload to continue.", + "save.createNewScene": "Create new scene", + "save.creating": "Creating…", + "save.failedToCreateScene": "Failed to create scene", + "save.newSceneName": "New scene name", + "save.noSceneToSave": "No scene to save", + "save.save": "Save", + "save.saveAs": "Save As", + "save.saveAsFailed": "Save as failed", + "save.saveFailed": "Save failed", + "save.saved": "Saved", + "save.saving": "Saving…", + "save.untitledScene": "Untitled Scene", + "settings.adjustVolume": "Adjust volume levels and mute settings", + "settings.audio": "Audio", + "settings.audioSettings": "Audio Settings", + "settings.clearAndStartNew": "Clear & Start New", + "settings.copied": "Copied", + "settings.copyProjectId": "Copy project ID", + "settings.dangerZone": "Danger Zone", + "settings.exploreSceneGraph": "Explore scene graph", + "settings.export": "Export", + "settings.export3DModel": "Export 3D Model", + "settings.exportedGLB": "Export GLB", + "settings.exportedOBJ": "Export OBJ", + "settings.exportedSTL": "Export STL", + "settings.exporting": "Generating...", + "settings.floorPlan": "Floor Plan", + "settings.floorplanDefault": "Default", + "settings.floorplanExpert": "Expert", + "settings.floorplanFull": "Full Plan", + "settings.floorplanStructure": "Structure Only", + "settings.generateThumbnail": "Generate Thumbnail", + "settings.keyboard": "Keyboard", + "settings.loadBuild": "Load Build", + "settings.masterVolume": "Master Volume", + "settings.muteAllSounds": "Mute All Sounds", + "settings.project": "Project", + "settings.projectId": "Project ID", + "settings.projectIdCopied": "Project ID copied", + "settings.public": "Public", + "settings.radioVolume": "Radio Volume", + "settings.saveAndLoad": "Save & Load", + "settings.saveBuild": "Save Build", + "settings.sceneGraph": "Scene Graph", + "settings.shadows": "Shadows", + "settings.show3DScans": "Show 3D Scans", + "settings.showFloorplans": "Show Floorplans", + "settings.showGrid": "Show Grid", + "settings.soundEffects": "Sound Effects", + "settings.thumbnail": "Thumbnail", + "settings.unmuteAllSounds": "Unmute All Sounds", + "settings.visibility": "Visibility", + "settings.visibilityAnyone": "Anyone", + "settings.visibilityCanView": "can view", + "settings.visibilityCastShadows": "Cast shadows from lights", + "settings.visibilityEditorOnly": "Visible only in the editor", + "settings.visibilityOnlyYou": "Only you", + "settings.visibilityPublic": "Public", + "settings.visibilityShow3DScans": "Show 3D Scans", + "settings.visibilityShowFloorplans": "Show Floorplans", + "settings.visibilityShowGrid": "Show Grid", + "settings.visibilityVisiblePublic": "Visible to public viewers", + "settings.visibleNodesOnly": "Visible nodes only", + "settings.visibleNodesOnlyDesc": "Only export nodes that are currently visible in the editor.", + "shortcuts.activateMeasurementTool": "Activate the measurement tool", + "shortcuts.addToCanvasSelection": "Add or remove from canvas selection", + "shortcuts.addToCanvasSelectionNote": "Use Shift + left click in empty space to extend or shrink the canvas-level selection.", + "shortcuts.addToSelection": "Add or remove an object from multi-selection", + "shortcuts.addToSelectionNote": "Cmd/Ctrl toggles individual objects in and out of the selection.", + "shortcuts.bypassGuidedConstraints": "Bypass guided placement constraints", + "shortcuts.bypassGuidedConstraintsNote": "While drawing walls, slabs, or ceilings, hold Shift to ignore guided snap lines.", + "shortcuts.bypassPlacementValidation": "Temporarily bypass placement validation constraints", + "shortcuts.bypassRotationSnap": "Bypass rotation snap", + "shortcuts.bypassRotationSnapNote": "While placing an item, hold Shift to rotate freely without snapping.", + "shortcuts.camera": "Camera", + "shortcuts.cancelTool": "Cancel the active tool and return to Select mode", + "shortcuts.cancelToolNote": "Equivalent to clicking the Select tool in the toolbar.", + "shortcuts.clearSelection": "Clear selection", + "shortcuts.clearSelectionNote": "Press Escape while the cursor is over the canvas.", + "shortcuts.contextAware": "Shortcuts are context-aware and depend on the current phase or tool.", + "shortcuts.copySelection": "Copy selection", + "shortcuts.copySelectionNote": "Use Cmd/Ctrl + V to paste the clipboard contents into the scene.", + "shortcuts.cutSelection": "Cut selection", + "shortcuts.cutSelectionNote": "Cut selection to the clipboard. Paste restores it at the cursor.", + "shortcuts.cycleGridStep": "Cycle grid step", + "shortcuts.cycleGridStepNote": "Hold Cmd/Ctrl while drawing to snap to a different grid step.", + "shortcuts.cycleSnapMode": "Cycle snap mode", + "shortcuts.cycleSnapModeNote": "Hold Shift while drawing to cycle between snap modes.", + "shortcuts.deleteSelected": "Delete selected objects", + "shortcuts.directManipulation": "Direct Manipulation", + "shortcuts.dragMiddleMouseOrHoldSpace": "Drag with the middle mouse button, or hold Space while dragging with the left mouse button.", + "shortcuts.dragRightMouse": "Drag with the right mouse button.", + "shortcuts.drawingTools": "Drawing Tools", + "shortcuts.editorNavigation": "Editor Navigation", + "shortcuts.groupSelection": "Group selection", + "shortcuts.groupSelectionNote": "Combines selected objects into a single transformable group.", + "shortcuts.holdWhilePlacing": "Hold while placing.", + "shortcuts.itemPlacement": "Item Placement", + "shortcuts.keyboardShortcuts": "Keyboard Shortcuts", + "shortcuts.modesAndHistory": "Modes & History", + "shortcuts.moveMultiSelection": "Move multi-selection", + "shortcuts.moveMultiSelectionNote": "Left click any selected object and drag to move the whole selection.", + "shortcuts.moveUnderCursor": "Move under cursor", + "shortcuts.moveUnderCursorNote": "Cmd/Ctrl + left click and drag any object to move it under the pointer.", + "shortcuts.orbitCamera": "Orbit camera", + "shortcuts.operateSelectedNode": "Operate selected node", + "shortcuts.panCamera": "Pan camera", + "shortcuts.panCameraMiddle": "Pan camera (middle mouse / Space)", + "shortcuts.panCameraNote": "WASD moves the camera horizontally; QE move it vertically.", + "shortcuts.pasteSelection": "Paste selection", + "shortcuts.pasteSelectionNote": "Place copied or cut items at the cursor position.", + "shortcuts.redo": "Redo", + "shortcuts.rotateFreely": "Rotate freely under cursor", + "shortcuts.rotateFreelyNote": "Cmd/Ctrl + Shift + right click and drag to rotate the object freely.", + "shortcuts.rotateItemOrToggleDoor": "Rotate item or toggle door", + "shortcuts.rotateMultiSelection": "Rotate multi-selection", + "shortcuts.rotateMultiSelectionNote": "Press R then T to enter rotate mode, then drag to rotate the selection.", + "shortcuts.rotateUnderCursor": "Rotate under cursor", + "shortcuts.rotateUnderCursorNote": "Cmd/Ctrl + right click and drag to rotate the object under the pointer.", + "shortcuts.selectNextLevel": "Select next level in the active building", + "shortcuts.selectPreviousLevel": "Select previous level in the active building", + "shortcuts.selection": "Selection", + "shortcuts.switchToBuildMode": "Switch to Build mode", + "shortcuts.switchToDeleteMode": "Switch to Delete mode", + "shortcuts.switchToFurnishLayer": "Switch to Furnish layer", + "shortcuts.switchToFurnishPhase": "Switch to Furnish phase", + "shortcuts.switchToSelectMode": "Switch to Select mode", + "shortcuts.switchToSitePhase": "Switch to Site phase", + "shortcuts.switchToStructurePhase": "Switch to Structure phase", + "shortcuts.switchToZonesLayer": "Switch to Zones layer", + "shortcuts.toggleSidebar": "Toggle sidebar", + "shortcuts.undo": "Undo", + "shortcuts.ungroupSelection": "Ungroup selection", + "shortcuts.ungroupSelectionNote": "Disband the selected group back into individual objects.", + "sidebar.build": "Build", + "sidebar.file": "File", + "sidebar.items": "Items", + "sidebar.scene": "Scene", + "sidebar.settings": "Settings", + "sidebar.site": "Site", + "site.addLevel": "Add level", + "site.area": "Area", + "site.baseElevation": "Base elevation", + "site.building": "Building", + "site.cameraSnapshot": "Camera snapshot", + "site.capture": "Capture", + "site.clearSelection": "Clear selection", + "site.delete": "Delete", + "site.duplicateLevelWithOptions": "Duplicate level with options", + "site.furnish": "Furnish", + "site.guide": "Guide", + "site.hide": "Hide", + "site.level": "Level", + "site.noBuildingsYet": "No buildings yet", + "site.noLevelsYet": "No levels yet", + "site.perimeter": "Perimeter", + "site.propertyLine": "Property Line", + "site.show": "Show", + "site.site": "Site", + "site.structure": "Structure", + "site.uploadScan": "Upload scan/floorplan", + "site.uploadingWithProgress": "Uploading {type}... {progress}%", + "site.untitled": "Untitled", + "site.levels": "Levels", + "site.addPoint": "Add point", + "site.axisX": "X", + "site.axisZ": "Z", + "site.camera.viewSnapshot": "View snapshot", + "site.camera.updateSnapshot": "Update snapshot", + "site.camera.takeSnapshot": "Take snapshot", + "site.camera.clearSnapshot": "Clear snapshot", + "site.objectsSelected": "{count} objects selected", + "site.reference.capture": "Capture", + "site.reference.guide": "Guide Image", + "site.nodeType.wall": "Wall", + "site.nodeType.fence": "Fence", + "site.nodeType.item": "Item", + "site.nodeType.slab": "Slab", + "site.nodeType.ceiling": "Ceiling", + "site.nodeType.roof": "Roof", + "site.nodeType.roofSegment": "Roof Segment", + "site.zones": "Zones", + "snapshot.area": "Area", + "snapshot.capture": "Capture", + "snapshot.capturing": "Capturing", + "snapshot.closeCapture": "Close capture mode", + "snapshot.dragArea": "Drag the area you want to capture", + "snapshot.escToCancel": "Esc to cancel", + "snapshot.saved": "Saved", + "snapshot.standard": "Standard", + "snapshot.viewport": "Viewport", + "structureTools.ceiling": "Ceiling", + "structureTools.column": "Column", + "structureTools.door": "Door", + "structureTools.duct": "Duct", + "structureTools.ductFitting": "Duct fitting", + "structureTools.dwvPipe": "DWV pipe", + "structureTools.elevator": "Elevator", + "structureTools.fence": "Fence", + "structureTools.gableRoof": "Gable roof", + "structureTools.hvacUnit": "HVAC unit", + "structureTools.lineset": "Line set", + "structureTools.liquidLine": "Liquid line", + "structureTools.pipeFitting": "Pipe fitting", + "structureTools.register": "Register", + "structureTools.shelf": "Shelf", + "structureTools.slab": "Slab", + "structureTools.spawnPoint": "Spawn point", + "structureTools.stairs": "Stairs", + "structureTools.trap": "Trap", + "structureTools.wall": "Wall", + "structureTools.window": "Window", + "structureTools.zone": "Zone", + "toolbar.cutaway": "Cutaway", + "toolbar.fullHeight": "Full Height", + "toolbar.low": "Low", + "toolbar.up": "Up", + "toolbar.walls": "Walls", + "continuation.cabinet.continuous": "Continuous run", + "continuation.cabinet.single": "Single cabinet", + "continuation.canopy.continuous": "Continuous canopy", + "continuation.canopy.single": "Single canopy", + "continuation.fence.continuous": "Continuous", + "continuation.fence.curved": "Curved fence", + "continuation.fence.single": "Single fence", + "continuation.point.once": "Place once", + "continuation.point.repeat": "Place multiple", + "continuation.wall.room": "Room (auto-close)", + "continuation.wall.single": "Single wall", + "tools.ceiling": "Ceiling", + "tools.column": "Column", + "tools.door": "Door", + "tools.elevator": "Elevator", + "tools.fence": "Fence", + "tools.gableRoof": "Gable Roof", + "tools.shelf": "Shelf", + "tools.slab": "Slab", + "tools.spawnPoint": "Spawn Point", + "tools.stairs": "Stairs", + "tools.wall": "Wall", + "tools.window": "Window", + "tools.zone": "Zone", + "treeActions.cameraSnapshot": "Camera snapshot", + "treeActions.clearSelection": "Clear selection", + "treeActions.clearSnapshot": "Clear snapshot", + "treeActions.hide": "Hide", + "treeActions.show": "Show", + "treeActions.takeSnapshot": "Take snapshot", + "treeActions.updateSnapshot": "Update snapshot", + "treeActions.viewSnapshot": "View snapshot", + "viewer.building": "Building", + "viewer.belowCount": "{count} below", + "viewer.camera": "Camera", + "viewer.collapseSidebar": "Collapse sidebar", + "viewer.couldNotAddGuideImage": "Could not add that guide image.", + "viewer.crispEdges": "Crisp, opaque edge lines", + "viewer.cutaway": "Cutaway", + "viewer.deleteGuideImage": "Delete guide image", + "viewer.deleteScan": "Delete scan", + "viewer.display": "Display", + "viewer.displaySettings": "Display settings", + "viewer.edges": "Edges", + "viewer.expandSidebar": "Expand sidebar", + "viewer.exploded": "Exploded", + "viewer.faintOutline": "Faint outline of major creases", + "viewer.fileTooLarge": "File is too large. Maximum size is 200 MB.", + "viewer.flatAndFast": "Flat and fast — no ambient occlusion", + "viewer.floorplanAnnotations": "Floor plan annotations", + "viewer.floorplanDefault": "Default", + "viewer.floorplanDefaultDetail": "Hide non-essential annotations and dimensions.", + "viewer.floorplanExpert": "Expert", + "viewer.floorplanExpertDetail": "Show every dimension, mark, and structural annotation.", + "viewer.floorplanMode": "Floor plan mode", + "viewer.faceOfStud": "Face of stud", + "viewer.faceOfStudDetail": "Measure to the inside face of the wall studs.", + "viewer.finishedFaces": "Finished faces", + "viewer.finishedFacesDetail": "Measure to the inside surface of the finished wall.", + "viewer.fullAO": "Full ambient occlusion", + "viewer.fullHeight": "Full Height", + "viewer.grid": "Grid", + "viewer.gridSnap": "Grid snap", + "viewer.guideImageDefault": "Guide image {index}", + "viewer.guideImages": "Guide images", + "viewer.guideImageSettings": "Guide image settings", + "viewer.guideImagesOnThisLevel": "{count} guide image on this level", + "viewer.guideImagesOnThisLevel_plural": "{count} guide images on this level", + "viewer.guides": "Guides", + "viewer.guidesState": "Guides: {state}", + "viewer.hidden": "Hidden", + "viewer.hideReferenceFloor": "Hide reference floor", + "viewer.imperial": "Imperial (ft)", + "viewer.levels": "Levels", + "viewer.levelsWithMode": "Levels: {mode}", + "viewer.low": "Low", + "viewer.magneticSnap": "Magnetic snap", + "viewer.manual": "Manual", + "viewer.measurements": "Measurements", + "viewer.measurements3d": "3D measurements", + "viewer.meters": "Meters", + "viewer.metric": "Metric (m)", + "viewer.millimeters": "Millimeters", + "viewer.automaticDimensions": "Automatic dimensions", + "viewer.manualDimensions": "Manual dimensions", + "viewer.openingMarks": "Opening marks", + "viewer.structuralGrids": "Structural grids", + "viewer.roomLabels": "Room labels", + "viewer.stairAnnotations": "Stair annotations", + "viewer.noEdgeLines": "No edge lines", + "viewer.noGuideImagesOnLevel": "No guide images on this level yet.", + "viewer.noLowerFloor": "No lower floor to reference.", + "viewer.noScansOnLevel": "No scans on this level yet.", + "viewer.off": "Off", + "viewer.off_edge": "Off", + "viewer.on": "On", + "viewer.opacity": "Opacity", + "viewer.hideReferenceRow": "Hide {subject}", + "viewer.showReferenceRow": "Show {subject}", + "viewer.deleteReferenceRow": "Delete {subject}", + "viewer.openProjectBeforeUpload": "Open a project before uploading a scan.", + "viewer.orbitLeft": "Orbit Left", + "viewer.orbitRight": "Orbit Right", + "viewer.orthographic": "Orthographic", + "viewer.perspective": "Perspective", + "viewer.preview": "Preview", + "viewer.previewMode": "Preview mode", + "viewer.referenceFloorSettings": "Reference floor settings", + "viewer.referenceFloorWithLevel": "Reference floor: {level}", + "viewer.referenceSettings": "Reference settings", + "viewer.referencesState": "References: {state}", + "viewer.render": "Render", + "viewer.rendered": "Rendered", + "viewer.riserDiagram": "Riser diagram", + "viewer.scanDefault": "Scan {index}", + "viewer.scanSettings": "Scan settings", + "viewer.scanUploadUnavailable": "Scan upload is unavailable.", + "viewer.scans": "Scans", + "viewer.scansOnThisLevel": "{count} scan on this level", + "viewer.scansOnThisLevel_plural": "{count} scans on this level", + "viewer.scansState": "Scans: {state}", + "viewer.sceneTheme": "Scene theme", + "viewer.shadows": "Shadows", + "viewer.showReferenceFloor": "Show reference floor", + "viewer.soft": "Soft", + "viewer.solid": "Solid", + "viewer.solo": "Solo", + "viewer.stack": "Stack", + "viewer.strong": "Strong", + "viewer.topView": "Top View", + "viewer.translucent": "Translucent", + "viewer.units": "Units", + "viewer.uploadGlbOrImage": "Upload a .glb/.gltf scan or an image.", + "viewer.uploadScanOrGuide": "Upload scan or guide image", + "viewer.uploading": "Uploading", + "viewer.viewMode2D": "2D", + "viewer.viewMode3D": "3D", + "viewer.viewModeSplit": "Split", + "viewer.visible": "Visible", + "viewer.walkthrough": "Walkthrough", + "viewer.wallsWithMode": "Walls: {mode}", + "viewer.wallCenterline": "Wall centerline", + "viewer.wallCenterlineDetail": "Measure to the structural center of the wall.", + "viewer.wallDimensions": "Wall dimensions", + "viewer.spawnPointHint": "Place a Spawn Point from the Build tab to control where walkthrough starts.", + "zone.addOne": "Add one", + "zone.noZonesOnLevel": "No zones on this level.", + "zone.selectLevelToView": "Select a level to view and create zones", + "nodes.ceiling.toolHints.trace": "Trace ceiling outline", + "nodes.ceiling.toolHints.finish": "Finish ceiling", + "nodes.item.label": "Item", + "nodes.item.description": "An item placed in the scene.", + "nodes.item.position": "Position", + "nodes.item.rotation": "Rotation", + "nodes.item.scale": "Scale", + "nodes.item.uniformScale": "Uniform Scale", + "nodes.item.info": "Info", + "nodes.item.collections": "Collections", + "nodes.item.manageCollections": "Manage collections…", + "nodes.site.label": "Site", + "nodes.site.description": "A site containing buildings.", + "nodes.dormer.toolHints.place": "Place dormer on roof", + "nodes.dormer.toolHints.rotateGhost": "Rotate ghost", + "nodes.chimney.toolHints.place": "Place chimney on roof", + "nodes.ridgeVent.toolHints.place": "Place ridge vent on roof", + "nodes.shelf.toolHints.place": "Place shelf", + "nodes.skylight.toolHints.place": "Place skylight on roof", + "nodes.spawn.toolHints.place": "Place spawn point", + "nodes.zone.toolHints.place": "Place zone", + "nodes.block.actions": "Actions", + "nodes.block.addSlot": "Add slot", + "nodes.block.blockAccent": "Block Accent", + "nodes.block.defaultMaterial": "Default material", + "nodes.block.editModeSlotActions": "Enter Edit Mode to use slot actions", + "nodes.block.editModeAssignFaces": "Enter Edit Mode to assign faces", + "nodes.block.faceSelectSwitch": "Switch to Face Select (3)", + "nodes.block.fallbackTitle": "Block", + "nodes.block.noFacesSelected": "No faces selected", + "nodes.block.readOnly": "Scene is read-only", + "nodes.block.mixedSlots": "{count} faces · Mixed slots", + "nodes.block.singleSlot": "{count} {faceLabel} · {slotLabel}", + "nodes.block.face": "face", + "nodes.block.faces": "faces", + "nodes.block.position": "Position", + "nodes.block.selectFacesFirst": "Select one or more faces first", + "nodes.block.slots": "Slots", + "nodes.block.slotApplied": "{slot} applied to {count} {faceLabel} with an accent material. Use Paint (P) to replace it.", + "nodes.block.slotApplyAria": "Apply {slot} to selected faces", + "nodes.block.slotDeleteAria": "Delete {slot} slot", + "nodes.block.slotRenameAria": "Rename {slot} slot", + "nodes.block.slotDeleteTitle": "Delete material slot and use Body on its faces", + "nodes.block.unpainted": "Unpainted", + "nodes.block.toolHints.place": "Place block", + "nodes.column.toolHints.place": "Place column", + "nodes.cupola.cupola": "Cupola", + "nodes.cupola.dome": "Dome", + "nodes.cupola.finial": "Finial", + "nodes.cupola.noFinial": "No Finial", + "nodes.cupola.pyramid": "Pyramid", + "nodes.cupola.toolHints.place": "Place cupola on roof", + "nodes.door.toolHints.flipSide": "Flip side", + "nodes.downspout.toolHints.highlightOutlet": "Highlight outlet", + "nodes.downspout.toolHints.drop": "Drop downspout from outlet", + "nodes.elevator.toolHints.place": "Place elevator", + "nodes.eyebrowVent.toolHints.place": "Place eyebrow vent on roof", + "nodes.eyebrowVent.fallbackTitle": "Eyebrow Vent", + "nodes.eyebrowVent.style": "Style", + "nodes.eyebrowVent.scoop": "Scoop", + "nodes.eyebrowVent.halfRound": "Half-round", + "nodes.eyebrowVent.slantBox": "Slant-box", + "nodes.eyebrowVent.louvers": "Louvers", + "nodes.eyebrowVent.backHeight": "Back height", + "nodes.eyebrowVent.dimensions": "Dimensions", + "nodes.gutter.toolHints.place": "Place gutter on roof eave", + "nodes.item.toolHints.place": "Place item", + "nodes.item.toolHints.cycleSnap": "Cycle snapping mode", + "nodes.roof.toolHints.setFootprint": "Set roof footprint", + "nodes.roof.toolHints.placement": "Placement", + "nodes.roof.toolHints.placementAuto": "Placement: Auto", + "nodes.roof.toolHints.placementGround": "Placement: Ground", + "nodes.roof.toolHints.placementRoof": "Placement: Roof", + "nodes.roof.toolHints.rotateDirection": "Rotate roof direction 90°", + "nodes.spawn.toolHints.rotate": "Rotate spawn point", + "nodes.stair.toolHints.place": "Place stairs", + "nodes.structuralGrid.toolHints.start": "Start grid axis", + "nodes.structuralGrid.toolHints.finish": "Finish grid axis", + "nodes.structuralGrid.toolHints.bypassSnap": "Bypass snapping", + "nodes.turbineVent.toolHints.place": "Place turbine vent on roof", + "nodes.turbineVent.fallbackTitle": "Turbine Vent", + "nodes.turbineVent.style": "Style", + "nodes.turbineVent.globe": "Globe", + "nodes.turbineVent.cylinder": "Cylinder", + "nodes.turbineVent.dimensions": "Dimensions", + "nodes.turbineVent.neckHeight": "Neck Height", + "nodes.turbineVent.vanes": "Vanes", + "nodes.turbineVent.motion": "Motion", + "nodes.turbineVent.pause": "Pause", + "nodes.turbineVent.play": "Play", + "nodes.turbineVent.spinSpeed": "Spin Speed", + "nodes.window.toolHints.flipSide": "Flip side", + "nodes.cabinet.addChimney": "Add chimney", + "nodes.cabinet.addCompartment": "Add compartment", + "nodes.cabinet.addWallCabinet": "Add wall cabinet", + "nodes.cabinet.cabinetType": "Cabinet Type", + "nodes.cabinet.carcassHeight": "Carcass height", + "nodes.cabinet.close": "Close", + "nodes.cabinet.closeCabinet": "Close cabinet", + "nodes.cabinet.compartments": "Compartments", + "nodes.cabinet.dimensions": "Dimensions", + "nodes.cabinet.fallbackTitle": "Modular Cabinet", + "nodes.cabinet.fillToCeiling": "Fill to ceiling", + "nodes.cabinet.finish": "Finish", + "nodes.cabinet.frontOverlay.full": "Overlay", + "nodes.cabinet.frontOverlay.inset": "Inset", + "nodes.cabinet.frontStyle.raisedArch": "Raised Arch", + "nodes.cabinet.frontStyle.shaker": "Shaker", + "nodes.cabinet.frontStyle.slab": "Slab", + "nodes.cabinet.fronts": "Fronts", + "nodes.cabinet.fronts.mounting": "Mounting", + "nodes.cabinet.fronts.revealGap": "Reveal gap", + "nodes.cabinet.fronts.style": "Style", + "nodes.cabinet.handle.bar": "Bar", + "nodes.cabinet.handle.cutout": "Cutout", + "nodes.cabinet.handle.hole": "Hole", + "nodes.cabinet.handle.knob": "Knob", + "nodes.cabinet.handle.none": "None", + "nodes.cabinet.handlePosition.auto": "Auto", + "nodes.cabinet.handlePosition.center": "Center", + "nodes.cabinet.handlePosition.top": "Top", + "nodes.cabinet.handles": "Handles", + "nodes.cabinet.handles.position": "Position", + "nodes.cabinet.handles.style": "Style", + "nodes.cabinet.open": "Open", + "nodes.cabinet.openAnimation": "Open Animation", + "nodes.cabinet.openCabinet": "Open cabinet", + "nodes.cabinet.play": "Play", + "nodes.cabinet.playAnimation": "Play animation", + "nodes.cabinet.planningChecks": "Planning checks", + "nodes.cabinet.presets": "Presets", + "nodes.cabinet.reflowRejected": "No space in this run. No base cabinet can shrink enough to fit this item.", + "nodes.cabinet.removeWallCabinet": "Remove wall cabinet", + "nodes.cabinet.standardWidth": "Standard width", + "nodes.cabinet.stop": "Stop", + "nodes.cabinet.stopAnimation": "Stop animation", + "nodes.cabinet.tier.base": "Base Cabinet", + "nodes.cabinet.tier.tall": "Tall Cabinet", + "nodes.cabinet.topCeiling": "Top / Ceiling", + "nodes.cabinet.topDepth": "Top depth", + "nodes.cabinet.topFinish.none": "None", + "nodes.cabinet.topFinish.topCabinet": "Top Cabinet", + "nodes.cabinet.topFinish.trim": "Trim / Soffit", + "nodes.cabinet.topHeight": "Top height", + "nodes.cabinet.toolHints.place": "Place cabinet", + "nodes.cabinet.toolHints.placementType": "Placement type", + "nodes.cabinet.toolHints.typeCabinet": "Type: Cabinet", + "nodes.cabinet.toolHints.typeIsland": "Type: Island", + "nodes.constructionDimension.actions": "Actions", + "nodes.constructionDimension.centerMark": "Center mark", + "nodes.constructionDimension.datumPolicy": "Datum policy", + "nodes.constructionDimension.datumPolicyOptions.centerline": "Centerline", + "nodes.constructionDimension.datumPolicyOptions.finishFace": "Finish face", + "nodes.constructionDimension.datumPolicyOptions.structuralFace": "Structural face", + "nodes.constructionDimension.datumPolicyOptions.wallFace": "Wall face", + "nodes.constructionDimension.defaultFoundationDimensionName": "Foundation dimension", + "nodes.constructionDimension.dimension": "Dimension", + "nodes.constructionDimension.drawingCoordination": "Drawing coordination", + "nodes.constructionDimension.extensionGap": "Extension gap", + "nodes.constructionDimension.extensionOvershoot": "Extension overshoot", + "nodes.constructionDimension.fallbackTitle": "Construction Dimension", + "nodes.constructionDimension.featureCount": "Feature count", + "nodes.constructionDimension.foundationController": "Foundation controller", + "nodes.constructionDimension.imperialPrecision": "Imperial precision", + "nodes.constructionDimension.imperialPrecisionOptions.1": "Nearest inch", + "nodes.constructionDimension.imperialPrecisionOptions.1/2": "Nearest 1/2 inch", + "nodes.constructionDimension.imperialPrecisionOptions.1/4": "Nearest 1/4 inch", + "nodes.constructionDimension.imperialPrecisionOptions.1/8": "Nearest 1/8 inch", + "nodes.constructionDimension.imperialPrecisionOptions.1/16": "Nearest 1/16 inch", + "nodes.constructionDimension.linkedDimensionsNote": "Linked dimensions reuse the controller's associative anchors and update with it.", + "nodes.constructionDimension.metricNotation": "Metric notation", + "nodes.constructionDimension.metricNotationOptions.meters": "Meters", + "nodes.constructionDimension.metricNotationOptions.millimeters": "Millimeters", + "nodes.constructionDimension.mode": "Mode", + "nodes.constructionDimension.modeOptions.angular": "Angular", + "nodes.constructionDimension.modeOptions.arc-length": "Arc length", + "nodes.constructionDimension.modeOptions.center-mark": "Center mark", + "nodes.constructionDimension.modeOptions.chord": "Chord", + "nodes.constructionDimension.modeOptions.coordinate": "Coordinate", + "nodes.constructionDimension.modeOptions.diameter": "Diameter", + "nodes.constructionDimension.modeOptions.linear": "Linear", + "nodes.constructionDimension.modeOptions.radius": "Radius", + "nodes.constructionDimension.notation": "Notation", + "nodes.constructionDimension.noFoundationDimensions": "No foundation dimensions", + "nodes.constructionDimension.presentation": "{drawing} presentation", + "nodes.constructionDimension.presentationOptions.controlled": "Controlled by foundation", + "nodes.constructionDimension.presentationOptions.omit": "Omitted", + "nodes.constructionDimension.presentationOptions.shown": "Shown", + "nodes.constructionDimension.prefix": "Prefix", + "nodes.constructionDimension.primaryDrawing": "Primary drawing", + "nodes.constructionDimension.standards": "Standards", + "nodes.constructionDimension.suffix": "Suffix", + "nodes.constructionDimension.suppressedSegments": "{drawing} suppressed segments", + "nodes.constructionDimension.suppressedSegmentsNote": "Segment numbers are one-based and apply only in this drawing view.", + "nodes.constructionDimension.suppressedSegmentsPlaceholder": "e.g. 2, 4", + "nodes.constructionDimension.terminator": "Terminator", + "nodes.constructionDimension.terminatorOptions.architectural-tick": "Architectural tick", + "nodes.constructionDimension.terminatorOptions.dot": "Dot", + "nodes.constructionDimension.terminatorOptions.filled-arrow": "Filled arrow", + "nodes.constructionDimension.terminatorOptions.open-arrow": "Open arrow", + "nodes.constructionDimension.textOverride": "Text override", + "nodes.constructionDimension.textOverridePlaceholder": "Use measured value", + "nodes.constructionDimension.textPosition": "Text position", + "nodes.constructionDimension.textPositionOptions.above": "Above line", + "nodes.constructionDimension.textPositionOptions.centered": "Centered on line", + "nodes.constructionDimension.toolHints.pickWitness": "Pick witness point", + "nodes.constructionDimension.toolHints.finishWitnesses": "Finish multi-point witnesses", + "nodes.constructionDimension.toolHints.placeLine": "Place dimension line when needed", + "nodes.constructionDimension.toolHints.removeLast": "Remove last witness", + "nodes.ductFitting.toolHints.place": "Place fitting", + "nodes.ductFitting.toolHints.snap": "Snap onto the run", + "nodes.ductSegment.toolHints.start": "Start segment", + "nodes.ductSegment.toolHints.placeContinue": "Place and continue", + "nodes.ductSegment.toolHints.vertical": "Go vertical ↕, click to place", + "nodes.ductSegment.toolHints.diameter": "Duct diameter down / up", + "nodes.ductSegment.toolHints.trunk": "Round / rect trunk", + "nodes.ductSegment.toolHints.height": "Ceiling / floor height", + "nodes.ductTerminal.toolHints.place": "Place register", + "nodes.ductTerminal.toolHints.mount": "Mount: floor / ceiling / wall", + "nodes.ductTerminal.toolHints.rotate": "Rotate ±45° (floor / ceiling)", + "nodes.hvacEquipment.toolHints.place": "Place unit", + "nodes.leanToExtension.toolHints.place": "Place canopy or set the next run point", + "nodes.leanToExtension.toolHints.rotateFlip": "Rotate or flip the run side", + "nodes.leanToExtension.toolHints.cycleStyle": "Cycle mono / gable / butterfly", + "nodes.lineset.toolHints.start": "Start lineset", + "nodes.lineset.toolHints.place": "Place it (locked to 45°)", + "nodes.lineset.toolHints.vertical": "Go vertical ↕, click to place", + "nodes.liquidLine.toolHints.start": "Start liquid line", + "nodes.liquidLine.toolHints.place": "Place it (locked to 45°)", + "nodes.liquidLine.toolHints.vertical": "Go vertical ↕, click to place", + "nodes.liquidLine.toolHints.follow": "Follow: trace a lineset", + "nodes.pipeFitting.toolHints.place": "Place fitting", + "nodes.pipeFitting.toolHints.snap": "Snap onto the run", + "nodes.pipeSegment.toolHints.start": "Start run", + "nodes.pipeSegment.toolHints.place": "Place it (waste falls ¼″/ft)", + "nodes.pipeSegment.toolHints.wasteVent": "Waste / vent", + "nodes.pipeSegment.toolHints.size": "Pipe size down / up", + "nodes.pipeSegment.toolHints.vertical": "Vertical stack ↕, click to place", + "nodes.pipeTrap.toolHints.place": "Place trap", + "paint.material.defaultName": "Material {n}", + "paint.material.copySuffix": "{name} copy", + "paint.material.usedByOne": "Used by 1 part", + "paint.material.usedByOther": "Used by {count} parts", + "paint.material.paintWith": "Paint with", + "paint.erase": "Erase", + "paint.resetAll": "Reset all", + "paint.sceneMaterials": "Scene materials", + "paint.addMaterial": "Add material", + "paint.noCustomMaterials": "No custom materials yet — add one with +.", + "materialPicker.source.pascal": "Pascal", + "materialPicker.source.workspace": "Workspace", + "materialPicker.newMaterial": "New material", + "material.color": "Color", + "material.roughness": "Roughness", + "material.metalness": "Metalness", + "material.opacity": "Opacity", + "material.side": "Side", + "material.side.front": "Front", + "material.side.back": "Back", + "material.side.double": "Double", + "materials.colors": "Colors", + "materials.stone": "Stone", + "materials.brick": "Brick", + "materials.tile": "Tile", + "materials.wallpaper": "Wallpaper", + "materials.concrete": "Concrete", + "materials.metal": "Metal", + "materials.plastic": "Plastic", + "materials.fabric": "Fabric", + "materials.carpet": "Carpet", + "materials.leather": "Leather", + "materials.glass": "Glass", + "common.mixed": "Mixed", + "terrain.verb.raise": "Raise", + "terrain.verb.lower": "Lower", + "terrain.verb.flatten": "Flatten", + "terrain.verb.smooth": "Smooth", + "terrain.hint.raise": "Drag to raise the ground. One pass moves it up to {metres} m — release and drag again to go further.", + "terrain.hint.lower": "Drag to lower the ground. One pass moves it down to {metres} m.", + "terrain.hint.flatten": "Drag to level the ground toward the target height. It never overshoots.", + "terrain.hint.smooth": "Drag to soften slopes and remove ridges. Flat ground stays flat.", + "terrain.brush.size": "Size", + "terrain.brush.strength": "Strength", + "terrain.brush.softness": "Softness", + "terrain.brush.round": "Round", + "terrain.brush.square": "Square", + "terrain.flatten.target": "Target", + "terrain.flatten.pickTarget": "Pick target height from the ground", + "terrain.flatten.samplingHint": "Click the ground to pick its height as the target.", + "terrain.flatten.noTargetHint": "No target yet — the first click samples the ground under it.", + "terrain.flatten.targetHint": "Every flatten stroke levels toward this height.", + "terrain.levelLot": "Level lot", + "terrain.clearTerrain": "Clear terrain", + "materials.concrete-drywall.label": "Prepared Drywall", + "materials.concrete-plaster.label": "Painted Plaster", + "materials.concrete-plate.label": "Concrete Plate", + "materials.concrete-polished.label": "Polished Concrete", + "materials.concrete-raw.label": "Raw Concrete", + "materials.concrete-stucco.label": "White Stucco", + "materials.fabric-boucle.label": "Bouclé", + "materials.fabric-cotton.label": "Cotton", + "materials.fabric-linen.label": "Linen", + "materials.fabric-suede.label": "Suede", + "materials.fabric-velvet.label": "Velvet", + "materials.fabric-wool.label": "Wool", + "materials.flooring-agedbrick.label": "Aged Brick", + "materials.flooring-ceramic53.label": "Ceramic Mosaic", + "materials.flooring-darkceramic22.label": "Dark Ceramic Grunge", + "materials.flooring-garagedoor.label": "Garage Panel", + "materials.flooring-greenlabradorite.label": "Green Labradorite", + "materials.flooring-greenquartzitea.label": "Green Quartzite A", + "materials.flooring-ground13.label": "Earth Ground", + "materials.flooring-lightceramic24.label": "Light Ceramic Grunge", + "materials.flooring-pooltiles.label": "Pool Tiles", + "materials.flooring-rusticbrick.label": "Rustic Brick", + "materials.flooring-statuarettowhite.label": "Statuaretto White", + "materials.flooring-terrazzo19.label": "Terrazzo", + "materials.flooring-tile20.label": "Mosaic Tile", + "materials.flooring-tile68.label": "Pattern Tile", + "materials.flooring-tile79.label": "Stone Tile", + "materials.flooring-tile85a.label": "Quarry Tile", + "materials.flooring-tile86.label": "Terracotta Tile", + "materials.flooring-tiles3.label": "Checker Tiles", + "materials.flooring-tiles4.label": "Grid Tiles", + "materials.flooring-wallstone1.label": "Stone Wall", + "materials.flooring-weatheredbrick.label": "Weathered Brick", + "materials.flooring-woodenceramic2.label": "Wooden Ceramic 2", + "materials.flooring-woodenceramic3.label": "Wooden Ceramic 3", + "materials.flooring-woodparquet76.label": "Wood Parquet", + "materials.leather-black.label": "Black Leather", + "materials.leather-calf.label": "Calf Leather", + "materials.metal-brass.label": "Brass", + "materials.metal-chrome.label": "Chrome", + "materials.metal-copper.label": "Copper", + "materials.metal-polished.label": "Polished Metal", + "materials.metal-steel.label": "Brushed Steel", + "materials.preset-aubergine.label": "Aubergine", + "materials.preset-beige.label": "Beige", + "materials.preset-berry.label": "Berry", + "materials.preset-blush.label": "Blush", + "materials.preset-brickred.label": "Brick red", + "materials.preset-burntorange.label": "Burnt orange", + "materials.preset-charcoal.label": "Charcoal", + "materials.preset-clay.label": "Clay", + "materials.preset-cream.label": "Cream", + "materials.preset-deepteal.label": "Deep teal", + "materials.preset-dustyrose.label": "Dusty Rose", + "materials.preset-espresso.label": "Espresso", + "materials.preset-forest.label": "Forest", + "materials.preset-glass.label": "Glass", + "materials.preset-gold.label": "Gold", + "materials.preset-greige.label": "Greige", + "materials.preset-lavender.label": "Lavender", + "materials.preset-lightgrey.label": "Light grey", + "materials.preset-metal.label": "Metal", + "materials.preset-midgrey.label": "Mid grey", + "materials.preset-mint.label": "Mint", + "materials.preset-mustard.label": "Mustard", + "materials.preset-navy.label": "Navy", + "materials.preset-nearblack.label": "Near-black", + "materials.preset-ochre.label": "Ochre", + "materials.preset-olive.label": "Olive", + "materials.preset-oxblood.label": "Oxblood", + "materials.preset-paleteal.label": "Pale teal", + "materials.preset-paleyellow.label": "Pale yellow", + "materials.preset-peach.label": "Peach", + "materials.preset-petal.label": "Petal", + "materials.preset-plum.label": "Plum", + "materials.preset-powderblue.label": "Powder blue", + "materials.preset-rose.label": "Rose", + "materials.preset-royalblue.label": "Royal blue", + "materials.preset-sage.label": "Sage", + "materials.preset-sand.label": "Sand", + "materials.preset-sky.label": "Sky", + "materials.preset-slateblue.label": "Slate Blue", + "materials.preset-softblue.label": "Soft Blue", + "materials.preset-softwhite.label": "Soft White", + "materials.preset-tan.label": "Tan", + "materials.preset-taupe.label": "Taupe", + "materials.preset-teal.label": "Teal", + "materials.preset-terracotta.label": "Terracotta", + "materials.preset-tomato.label": "Tomato", + "materials.preset-white.label": "White", + "materials.roof-classicshingles.label": "Classic Shingles", + "materials.roof-claytiles.label": "Clay Tiles", + "materials.roof-terracottatiles.label": "Terracotta Tiles", + "materials.roof-weatheredshingles.label": "Weathered Shingles", + "materials.wood-finewood27.label": "Finewood 27", + "materials.wood-floorplank1.label": "Floor Plank 1", + "materials.wood-hungarianparquet10.label": "Hungarian Parquet 10", + "materials.wood-hungarianparquet2.label": "Hungarian Parquet 2", + "materials.wood-squareparquet21.label": "Square Parquet 21", + "materials.wood-squareparquet23.label": "Square Parquet 23", + "materials.wood-woodenparquet11.label": "Wooden Parquet 11", + "materials.wood-woodfine1.label": "Wood Fine 1", + "materials.wood-woodfine11.label": "Wood Fine 11", + "materials.wood-woodfine13.label": "Wood Fine 13", + "materials.wood-woodfine2.label": "Wood Fine 2", + "materials.wood-woodfine22.label": "Wood Fine 22", + "materials.wood-woodfine24.label": "Wood Fine 24", + "materials.wood-woodparquet121.label": "Wood Parquet 121", + "materials.wood-woodparquet14.label": "Wood Parquet 14", + "materials.wood-woodparquet56.label": "Wood Parquet 56", + "materials.wood-woodparquet65.label": "Wood Parquet 65", + "materials.wood-woodparquet99.label": "Wood Parquet 99", + "materials.wood-woodplank19.label": "Wood Plank 19", + "materials.wood-woodplank48.label": "Wood Plank 48", + "common.placement": "Placement", + "common.construction": "Construction", + "common.drainage": "Drainage", + "common.transform": "Transform", + "common.connections": "Connections", + "common.fitting": "Fitting", + "common.advanced": "Advanced", + "common.appearance": "Appearance", + "common.mounting": "Mounting", + "nodes.chimney.body": "Body", + "nodes.door.frame": "Frame", + "nodes.dormer.dormerRoof": "Dormer Roof", + "nodes.downspout.hardware": "Hardware", + "nodes.ductSegment.air": "Air", + "nodes.ductTerminal.terminal": "Terminal", + "nodes.ductTerminal.face": "Face", + "nodes.ductTerminal.collar": "Collar", + "nodes.fence.structure": "Structure", + "nodes.gutter.profile": "Profile", + "nodes.gutter.endCaps": "End Caps", + "nodes.gutter.hangers": "Hangers", + "nodes.hvacEquipment.equipment": "Equipment", + "nodes.hvacEquipment.cabinet": "Cabinet", + "nodes.hvacEquipment.supply": "Supply", + "nodes.hvacEquipment.return": "Return", + "nodes.leanToExtension.size": "Size", + "nodes.leanToExtension.connection": "Connection", + "nodes.leanToExtension.structure": "Structure", + "nodes.lineset.lines": "Lines", + "nodes.lineset.insulation": "Insulation", + "nodes.liquidLine.line": "Line", + "nodes.pipeTrap.trap": "Trap", + "nodes.shelf.topology": "Topology", + "nodes.skylight.type": "Type", + "nodes.skylight.curb": "Curb", + "nodes.skylight.opening": "Opening", + "nodes.skylight.lantern": "Lantern", + "nodes.solarPanel.grid": "Grid", + "nodes.solarPanel.panelDimensions": "Panel Dimensions", + "nodes.solarPanel.frame": "Frame", + "editor.deleteWithContents": "Delete with contents", + "editor.saveToCatalog": "Save to catalog", + "editor.replace": "Replace", + "editor.replacing": "Replacing...", + "editor.lock": "Lock", + "editor.unlock": "Unlock", + "editor.setScale": "Set Scale", + "editor.editScale": "Edit Scale", + "editor.showScale": "Show Scale", + "editor.hideScale": "Hide Scale", + "editor.uncalibrated": "Uncalibrated", + "editor.scaled": "Scaled", + "editor.scaledHidden": "Scaled (hidden)", + "editor.chooseImage": "Choose a PNG, JPEG, or WebP image.", + "editor.couldNotReplaceImage": "Could not replace that image.", + "editor.overlayImageUnavailable": "Overlay image unavailable. Replace the image to restore it.", + "editor.clickEndsOfKnownDistance": "Click both ends of a known distance on the plan, then type its real length.", + "editor.drawLineOverKnownDimension": "Draw a line over a known dimension on the plan, then type its real length to scale the image exactly.", + "editor.zoomOut": "Zoom out", + "editor.zoomIn": "Zoom in", + "editor.fitFloorPlan": "Fit floor plan", + "editor.floor": "Floor", + "editor.alignViewNorth": "Align view to north", + "editor.topView": "Top view", + "editor.orbitLeft": "Orbit left", + "editor.orbitRight": "Orbit right", + "editor.camera": "Camera", + "editor.visibility": "Visibility", + "editor.displaySettings": "Display settings", + "editor.walkthrough": "Walkthrough", + "editor.levelsLabel": "Levels: {mode}", + "editor.wallsLabel": "Walls: {mode}", + "editor.scans": "Scans", + "editor.guides": "Guides", + "editor.shadows": "Shadows", + "editor.render": "Render", + "editor.colors": "Colors", + "editor.theme": "Theme", + "editor.edges": "Edges", + "editor.stacked": "Stacked", + "editor.exploded": "Exploded", + "editor.solo": "Solo", + "editor.manual": "Manual", + "editor.fullHeight": "Full height", + "editor.cutaway": "Cutaway", + "editor.low": "Low", + "editor.viewer3d": "3D", + "editor.viewer2d": "2D", + "editor.viewerSplit": "Split", + "editor.perspective": "Perspective", + "editor.orthographic": "Orthographic", + "editor.solid": "Solid", + "editor.rendered": "Rendered", + "editor.solidDetail": "Flat and fast — no ambient occlusion", + "editor.renderedDetail": "Full ambient occlusion", + "editor.on": "On", + "editor.off": "Off", + "editor.monochrome": "Monochrome", + "editor.colored": "Colored", + "editor.cameraSnapshot": "Camera snapshot", + "editor.takeSnapshot": "Take snapshot", + "editor.updateSnapshot": "Update snapshot", + "editor.clearSnapshot": "Clear snapshot", + "editor.closeCaptureMode": "Close capture mode", + "editor.dwvRiserDiagram": "DWV riser diagram", + "editor.extrusionHeight": "Extrusion height", + "editor.viewerLayout": "Viewer layout", + "editor.noZonesOnLevel": "No zones on this level.", + "editor.addOne": "Add one", + "editor.selectLevelFirst": "Select a level to view and create zones", + "nodes.block.toolbar.transform": "Transform selected components (G / R / S)", + "nodes.block.toolbar.vertexSelect": "Vertex select (1)", + "nodes.block.toolbar.edgeSelect": "Edge select (2)", + "nodes.block.toolbar.faceSelect": "Face select (3)", + "nodes.block.toolbar.meshOperations": "Mesh operations", + "nodes.block.toolbar.operations": "Operations", + "nodes.block.toolbar.loopCut": "LOOP CUT", + "nodes.block.toolbar.bevel": "BEVEL", + "nodes.block.toolbar.moveSelection": "Move selection", + "nodes.block.toolbar.rotateSelection": "Rotate selection", + "nodes.block.toolbar.extrudeFaces": "Extrude selected faces", + "nodes.block.toolbar.insetFaces": "Inset selected faces", + "nodes.block.toolbar.loopCutAndSlide": "Loop Cut and Slide", + "nodes.block.toolbar.mergeVertices": "Merge vertices", + "nodes.block.toolbar.dissolve": "Dissolve selection", + "nodes.block.toolbar.bevelEdges": "Bevel selected edges", + "nodes.block.toolbar.finishEdit": "Finish edit mode (Tab)", + "nodes.block.toolbar.selectionAndMore": "Selection and more", + "nodes.block.toolbar.selectionActions": "Selection actions", + "nodes.block.toolbar.selectAll": "Select all", + "nodes.block.toolbar.invertSelection": "Invert selection", + "nodes.block.toolbar.clearSelection": "Clear selection", + "nodes.block.toolbar.xraySelection": "X-ray selection", + "nodes.block.toolbar.deleteComponents": "Delete components", + "nodes.block.loopCutCount": "Loop cut count", + "nodes.block.closeLastOperationPanel": "Close last operation panel", + "nodes.block.adjustLastOp": "Adjust {label} (F9)", + "nodes.skylight.glassThickness": "Glass Thickness", + "nodes.skylight.lanternHeight": "Lantern Height", + "nodes.skylight.topScale": "Top Scale", + "nodes.skylight.open": "Open", + "nodes.skylight.openingAngle": "Opening Angle", + "nodes.skylight.motorHousing": "Motor Housing", + "nodes.skylight.trackWidth": "Track Width", + "nodes.skylight.cutoutOffset": "Cutout Offset", + "nodes.cabinet.baseFlange": "Base Flange", + "nodes.cabinet.shelvesInside": "Shelves inside", + "nodes.cabinet.burnersOn": "Burners on", + "nodes.cabinet.topGrate": "Top grate", + "nodes.cabinet.baskets": "Baskets", + "nodes.cabinet.addLeft": "Add left", + "nodes.cabinet.addRight": "Add right", + "nodes.cabinet.showPlinth": "Show plinth", + "nodes.cabinet.plinthHeight": "Plinth height", + "nodes.cabinet.showCountertop": "Show countertop", + "nodes.cabinet.countertopHeight": "Countertop height", + "nodes.cabinet.countertopDepth": "Countertop depth", + "nodes.cabinet.seatingOverhang": "Seating overhang", + "nodes.cabinet.finishedBack": "Finished back", + "nodes.cabinet.waterfallEnds": "Waterfall ends", + "nodes.cabinet.barCounter": "Bar counter", + "nodes.cabinet.barHeight": "Bar height", + "nodes.cabinet.barDepth": "Bar depth", + "nodes.cabinet.sharedPlinthCountertop": "Shared Plinth & Countertop", + "nodes.cabinet.islandAndBar": "Island & Bar", + "nodes.cabinet.standardDimensions": "Standard dimensions", + "nodes.cabinet.appliesStandardDimensions": "Applies depth, carcass, plinth, and countertop thickness to this run.", + "nodes.dormer.addWindow": "Add Window", + "nodes.dormer.editWindow": "Edit window", + "nodes.dormer.moveWindow": "Move window", + "nodes.dormer.windowsTitle": "Windows ({count})", + "nodes.dormer.noWindows": "No windows", + "nodes.dormer.increaseWidth": "Increase the dormer width to add another window.", + "nodes.zone.architecturalRoom": "Architectural room", + "nodes.zone.roomName": "Room name", + "nodes.zone.roomNumber": "Room number", + "nodes.zone.enclosure": "Enclosure", + "nodes.zone.autoDetect": "Auto-detect", + "nodes.zone.enclosed": "Enclosed", + "nodes.zone.open": "Open", + "nodes.zone.occupancy": "Occupancy / use", + "nodes.zone.floorFinish": "Floor finish", + "nodes.zone.wallFinish": "Wall finish", + "nodes.zone.ceilingFinish": "Ceiling finish", + "nodes.zone.ceilingHeight": "Ceiling height", + "nodes.zone.clearDimensions": "Clear dimensions", + "nodes.zone.none": "None", + "nodes.zone.insideFaces": "Inside faces", + "nodes.zone.finishFaces": "Finish faces", + "nodes.zone.wallSurface": "Wall surface", + "nodes.zone.floorSurface": "Floor surface", + "nodes.zone.volume": "Volume", + "nodes.zone.roomDocumentation": "Room documentation", + "nodes.zone.roomQuantities": "Room quantities", + "nodes.zone.zoneQuantities": "Zone quantities", + "nodes.zone.enclosedRoom": "Enclosed room", + "nodes.zone.footprintOnly": "Footprint only", + "nodes.zone.boundaryUnavailable": "Zone boundary unavailable", + "nodes.zone.notProven": "Not proven", + "nodes.gutter.downspouts": "Downspouts", + "nodes.gutter.addDownspout": "Add Downspout", + "nodes.gutter.removeDownspout": "Remove downspout", + "nodes.door.documentation": "Documentation", + "nodes.window.documentation": "Documentation", + "nodes.spawn.actions": "Actions", + "nodes.ductFitting.swapWH": "Swap W/H", + "nodes.ductFitting.swapWidthHeight": "Swap width and height", + "nodes.boxVent.baseFlange": "Base Flange", + "nodes.shared.mark": "Mark", + "nodes.shared.construction": "Construction", + "nodes.shared.framed": "Framed", + "nodes.shared.masonry": "Masonry", + "nodes.shared.dimensionTo": "Dimension to", + "nodes.shared.nominal": "Nominal", + "nodes.shared.roughOpening": "Rough opening", + "nodes.shared.masonryOpening": "Masonry opening", + "nodes.shared.finishOpening": "Finish opening", + "nodes.shared.roWidth": "RO Width", + "nodes.shared.roHeight": "RO Height", + "nodes.shared.moWidth": "MO Width", + "nodes.shared.moHeight": "MO Height", + "nodes.shared.foWidth": "FO Width", + "nodes.shared.foHeight": "FO Height", + "nodes.shared.leaveBlankHint": "Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade.", + "nodes.shared.autoAssigned": "Auto-assigned", + "nodes.shared.verify": "Verify", + "panel.section.actions": "Actions", + "panel.section.position": "Position", + "panel.section.rotation": "Rotation", + "panel.section.scaleOpacity": "Scale & Opacity", + "editor.edgeOff": "Off", + "editor.edgeSoft": "Soft", + "editor.edgeStrong": "Strong", + "editor.edgeOffDetail": "No edge lines", + "editor.edgeSoftDetail": "Faint outline of major creases", + "editor.edgeStrongDetail": "Crisp, opaque edge lines", + "nodes.skylight.frame": "Frame", + "nodes.cabinet.modules": "Modules", + "nodes.cabinet.style": "Style", + "nodes.cabinet.back": "Back", + "nodes.cabinet.left": "Left", + "nodes.cabinet.right": "Right", + "common.shelves": "Shelves", + "common.drawers": "Drawers", + "nodes.zone.occupancyUse": "Occupancy / use", + "nodes.zone.topViewAriaLabel": "Top view with zone edge dimensions", + "nodes.gutter.downspoutDefaultName": "Downspout {index}", + "nodes.elevator.levelFallback": "Level {n}", + "nodes.measurement.extrusionHeight": "Extrusion height", + "common.dismiss": "Dismiss", + "editor.riserDiagram": "DWV riser diagram", + "editor.followsLevel": "Follows level", + "editor.customHeight": "Custom height", + "editor.currentlyLabel": "Currently {value}", + "editor.capture": "Capture", + "editor.top": "Top", + "buildTab.roofSource.conicalHint": "Select a curved wall to match its radius and arc.", + "nodes.roof.toolHints.placementTooltip": "Placement surface — click or press P to cycle", + "nodes.roof.defaultName": "Roof {count}", + "nodes.roof.preview": "Roof preview" +} diff --git a/packages/editor/src/lib/i18n/zh.json b/packages/editor/src/lib/i18n/zh.json new file mode 100644 index 0000000000..f8face6fdb --- /dev/null +++ b/packages/editor/src/lib/i18n/zh.json @@ -0,0 +1,2304 @@ +{ + "buildTab.tile.wall": "墙体", + "buildTab.tile.fence": "围栏", + "buildTab.tile.slab": "楼板", + "buildTab.tile.ceiling": "天花板", + "buildTab.tile.roof": "屋顶", + "buildTab.tile.stairs": "楼梯", + "buildTab.tile.elevator": "电梯", + "buildTab.tile.door": "门", + "buildTab.tile.window": "窗户", + "buildTab.tile.column": "柱子", + "buildTab.tile.shelf": "搁板", + "buildTab.tile.spawn": "生成点", + "buildTab.tile.kitchen": "厨房", + "buildTab.tile.mep": "机电", + "buildTab.tile.painting": "涂装", + "buildTab.tile.terrain": "地形", + "buildTab.mep.duct": "风管", + "buildTab.mep.register": "送风口", + "buildTab.mep.hvacUnit": "空调机组", + "buildTab.mep.lineset": "冷媒管", + "buildTab.mep.liquidLine": "液管", + "buildTab.mep.dwvPipe": "排水管", + "buildTab.mep.modularCabinet": "模块化橱柜", + "buildTab.section.roofType": "屋顶类型", + "buildTab.section.createFrom": "基于", + "buildTab.section.featuresAndExtensions": "特征与延伸", + "buildTab.roofType.hip": "四坡", + "buildTab.roofType.gable": "双坡", + "buildTab.roofType.shed": "单坡", + "buildTab.roofType.flat": "平顶", + "buildTab.roofType.gambrel": "复折", + "buildTab.roofType.dutch": "荷兰式", + "buildTab.roofType.mansard": "孟莎式", + "buildTab.roofType.conical": "锥形", + "buildTab.roofSource.room": "房间", + "buildTab.roofSource.walls": "墙体", + "buildTab.roofSource.draw": "绘制", + "buildTab.roofSource.roomHint": "悬停房间以预览其轮廓,然后点击放置。", + "buildTab.roofSource.wallsHint": "选择一段弧形墙以匹配其半径和弧度。", + "buildTab.roofSource.drawHint": "用两次点击绘制屋顶轮廓。", + "buildTab.action.addFitting": "添加管件", + "buildTab.action.addTrap": "添加存水弯", + "buildTab.liquidLine.followLineset": "跟随冷媒管", + "buildTab.liquidLine.followHintOn": "点击冷媒管以在其旁边铺设线路。", + "buildTab.liquidLine.followHintOff": "在已有冷媒管旁边绘制线路(F)。", + "buildTab.toggle.on": "开", + "buildTab.toggle.off": "关", + "catalog.intensity": "强度", + "materials.flooring": "地板", + "materials.other": "其他", + "materials.roof": "屋顶", + "materials.wood": "木材", + "measurement.angle": "角度", + "measurement.angularDimension": "角度尺寸", + "measurement.arcLength": "弧长", + "measurement.area": "面积", + "measurement.centerMark": "中心标记", + "measurement.chordDimension": "弦长尺寸", + "measurement.continuousDimension": "连续尺寸", + "measurement.coordinateDimensions": "坐标尺寸", + "measurement.diameterDimension": "直径尺寸", + "measurement.distance": "距离", + "measurement.floorplanSection": "平面图", + "measurement.linearDimension": "线性尺寸", + "measurement.measurePrefix": "测量:{label}", + "measurement.options": "测量选项", + "measurement.perimeter": "周长", + "measurement.radiusDimension": "半径尺寸", + "measurement.smart": "智能", + "measurement.type": "测量类型", + "measurement.volume": "体积", + "commands.addLevel": "添加楼层", + "commands.back": "返回", + "commands.cameraSwitchTo": "相机:切换到 {mode}", + "commands.ceilingTool": "天花板工具", + "commands.close": "关闭", + "commands.commandPalette": "命令面板", + "commands.copyShareLink": "复制分享链接", + "commands.cutaway": "剖面", + "commands.deleteLevel": "删除楼层", + "commands.deleteSelection": "删除选中", + "commands.doorTool": "门工具", + "commands.down": "下", + "commands.enterPreview": "进入预览", + "commands.exitPreview": "退出预览", + "commands.exploded": "爆炸", + "commands.export3DModel": "导出3D模型 (GLB)", + "commands.exportJSON": "导出场景 (JSON)", + "commands.exportSceneJson": "导出场景 (JSON)", + "commands.filterOptions": "筛选选项…", + "commands.gotoLevel": "跳转楼层", + "commands.group.exportShare": "导出与分享", + "commands.group.history": "历史", + "commands.group.levels": "楼层", + "commands.group.scene": "场景", + "commands.group.view": "视图", + "commands.group.viewerControls": "查看器控制", + "commands.itemTool": "物品工具", + "commands.levelMode": "楼层模式", + "commands.manual": "手动", + "commands.materialPaint": "材质绘制", + "commands.navigate": "导航", + "commands.noCommandsFound": "未找到命令。", + "commands.orthographic": "正交", + "commands.perspective": "透视", + "commands.redo": "重做", + "commands.renameLevel": "重命名楼层", + "commands.renameTo": "重命名为 {name}", + "commands.sculptTerrain": "雕刻地形", + "commands.searchActions": "搜索操作…", + "commands.select": "选择", + "commands.slabTool": "楼板工具", + "commands.solo": "单独", + "commands.stacked": "堆叠", + "commands.stairTool": "楼梯工具", + "commands.switchTo": "切换到", + "commands.switchToRendered": "切换到渲染", + "commands.switchToSolid": "切换到纯色", + "commands.takeScreenshot": "截图", + "commands.takeSnapshot": "快照", + "commands.toggleFullscreen": "切换全屏", + "commands.translucent": "半透明", + "commands.typeNewName": "输入新名称…", + "commands.typeNewNameAbove": "在上方输入新名称…", + "commands.undo": "撤销", + "commands.up": "上", + "commands.wallMode": "墙模式", + "commands.wallTool": "墙工具", + "commands.windowTool": "窗工具", + "commands.zoneTool": "区域工具", + "common.actions": "操作", + "common.add": "添加", + "common.addOne": "添加一个", + "common.all": "全部", + "common.apply": "应用", + "common.back": "返回", + "common.cancel": "取消", + "common.close": "关闭", + "common.confirm": "确认", + "common.copy": "复制", + "common.corners.bottomLeft": "左下", + "common.corners.bottomRight": "右下", + "common.corners.topLeft": "左上", + "common.corners.topRight": "右上", + "common.cut": "剪切", + "common.delete": "删除", + "common.depth": "深度", + "common.deselect": "取消选择", + "common.diameter": "直径", + "common.dimensions": "尺寸", + "common.area": "面积", + "common.directions.down": "下", + "common.directions.left": "左", + "common.directions.right": "右", + "common.directions.up": "上", + "common.done": "完成", + "common.duplicate": "复制", + "common.edit": "编辑", + "common.error": "错误", + "common.height": "高度", + "common.hide": "隐藏", + "common.home": "首页", + "common.length": "长度", + "common.loading": "加载中...", + "common.minus45": "-45°", + "common.move": "移动", + "common.next": "下一步", + "common.noResults": "未找到结果", + "common.objectsSelected": "个对象已选择", + "common.open": "打开", + "common.overhang": "挑檐", + "common.paste": "粘贴", + "common.plus45": "+45°", + "common.position": "位置", + "common.redo": "重做", + "common.remove": "移除", + "common.reset": "重置", + "common.rotate": "旋转", + "common.save": "保存", + "common.search": "搜索", + "common.select": "选择", + "common.settings": "设置", + "common.show": "显示", + "common.style": "样式", + "common.success": "成功", + "common.thickness": "厚度", + "common.undo": "撤销", + "common.untitled": "未命名", + "common.width": "宽度", + "common.x": "X", + "common.y": "Y", + "common.z": "Z", + "common.yaw": "偏航", + "common.rotation": "旋转", + "controlModes.boxSelect": "框选", + "controlModes.build": "构建", + "controlModes.editSite": "编辑场地", + "controlModes.exitSiteEditing": "退出场地编辑", + "controlModes.furnish": "家具", + "controlModes.materialPaint": "材质绘制", + "controlModes.preview": "预览", + "controlModes.select": "选择", + "controlModes.siteEditGroundOnly": "场地编辑仅在地面层可用", + "controlModes.zone": "区域", + "contextualHelp.mep.fitting.rotate45": "旋转 ±45°", + "contextualHelp.mep.fitting.showHandles": "点击手柄点以显示移动 + 旋转手柄", + "contextualHelp.mep.fitting.switchAxis": "切换旋转轴(Y → X → Z)", + "contextualHelp.mep.run.detachJoint": "拖动箭头时分离接缝", + "contextualHelp.mep.run.showArrows": "点击手柄点以显示移动箭头", + "contextualHelp.measurement.exit": "退出智能测量", + "contextualHelp.measurement.inspect": "查看表面尺寸", + "contextualHelp.measurement.pin": "钉住测量镜头", + "contextualHelp.reshape.controlPoint": "移动控制点", + "contextualHelp.reshape.corner": "移动角点", + "contextualHelp.reshape.curve": "调整曲线", + "contextualHelp.reshape.endpoint": "移动端点", + "contextualHelp.reshape.tangent": "移动切线", + "contextualHelp.resize": "调整大小", + "contextualHelp.rotateHandle.active": "自由旋转中(无角度步进)", + "contextualHelp.rotateHandle.idle": "按住以自由旋转", + "contextualHelp.sculpt.brushSize": "笔刷大小", + "contextualHelp.sculpt.cancelPick": "取消取样", + "contextualHelp.sculpt.cancelStroke": "取消笔刷", + "contextualHelp.sculpt.flatten": "平整地面", + "contextualHelp.sculpt.lower": "降低地面", + "contextualHelp.sculpt.pickTarget": "选择目标高度", + "contextualHelp.sculpt.raise": "抬升地面", + "contextualHelp.sculpt.smooth": "平滑地面", + "contextualHelp.select.addOrRemove": "从选中中添加或移除对象", + "contextualHelp.select.clearSelection": "清除选中(或点击空白处)", + "contextualHelp.select.groupSelection": "编组选中(仅本次会话)", + "contextualHelp.select.moveAsOne": "点击或拖动选区以整体移动", + "contextualHelp.select.rotateSelection": "将选中旋转 ±45°", + "contextualHelp.select.ungroupSelection": "取消编组本次会话的选中", + "contextualHelp.single.movableDrag": "拖动选中的可移动对象", + "contextualHelp.single.rotatableDrag": "向左或向右拖动以旋转选中对象", + "dialog.deleteElements.title": "删除 {count} 个元素?", + "dialog.deleteElements.description": "这将删除所有选中的元素。只要删除操作仍在编辑器历史记录中,你就可以撤销。", + "editor.cameraPan": "平移", + "editor.cameraRotate": "旋转", + "editor.cameraZoom": "缩放", + "editor.create": "创建", + "editor.createNew": "创建新场景", + "editor.recent": "最近", + "editor.delete": "删除", + "editor.cancel": "取消", + "editor.clearScale": "清除比例", + "editor.center": "居中", + "editor.resetRotation": "重置旋转", + "editor.resetImageScale": "重置图片比例", + "editor.opacity": "不透明度", + "editor.move": "移动", + "editor.toggleSidebar": "切换侧边栏", + "editor.close": "关闭", + "editor.duplicate": "复制", + "editor.editProperties": "编辑属性", + "editor.cameraControlsHint": "相机控制提示", + "editor.dismissCameraControlsHint": "关闭相机控制提示", + "editor.jump": "跳跃", + "editor.sprint": "冲刺", + "editor.interact": "交互", + "editor.clickToLookAround": "点击环顾四周", + "editor.exitStreetView": "退出街景", + "editor.referenceFloor": "参考楼层", + "editor.setCorner": "设置角落", + "editor.forcePlace": "强制放置", + "editor.guidedConstraintsBypassed": "已绕过引导约束", + "editor.openSavedScenes": "场景页", + "editor.place": "放置", + "editor.placeBuilding": "放置建筑", + "editor.rotate": "旋转", + "editor.rotateCounterclockwise": "逆时针旋转", + "editor.rotateClockwise": "顺时针旋转", + "editor.sceneFailedToRender": "编辑器场景渲染失败", + "editor.setOverlayScale": "设置叠加层比例", + "editor.somethingWentWrong": "出现错误", + "editor.addNewLevel": "添加新楼层", + "editor.displaysMobileSidebar": "显示移动端侧边栏。", + "editor.material": "材质", + "editor.customMaterial": "自定义材质", + "editor.image": "图片", + "editor.3dScan": "3D扫描", + "editor.guideImage": "参考图", + "editor.referenceScale": "参考比例", + "editor.quickActions": "快捷操作", + "editor.position": "位置", + "editor.rotation": "旋转", + "editor.scaleAndOpacity": "比例与不透明度", + "editor.actions": "操作", + "editor.expandSidebar": "展开侧边栏", + "editor.custom": "自定义", + "editor.addPoint": "添加点", + "editor.viewSnapshot": "查看快照", + "editor.reloadEditor": "重新加载编辑器", + "editor.drawnLine": "绘制线", + "editor.realLength": "实际长度", + "editor.duplicateLevel": "复制楼层", + "editor.tryAgain": "重试", + "editor.dismiss": "关闭", + "editor.reload": "重新加载", + "editor.conflictTitle": "另一个会话已先保存 — 是否刷新?", + "editor.conflictBody": "您的更改尚未保存。请重新加载以获取最新版本。", + "editor.lightPreview": "轻量预览", + "editor.lightPreviewTitle": "跳过后期处理流程 — 减少 GPU 负载,无环境光遮蔽或选择高亮", + "editor.leftClick": "左键点击", + "editor.localWarning": "这里是空白画布 — 你保存的场景在", + "editor.middleClick": "中键点击", + "editor.openRecent": "打开最近场景", + "editor.rightClick": "右键点击", + "editor.scrollWheel": "滚轮", + "editor.space": "空格", + "furnishTools.appliance": "家电", + "furnishTools.bathroom": "浴室", + "furnishTools.furniture": "家具", + "furnishTools.kitchen": "厨房", + "furnishTools.outdoor": "户外", + "functionTree.bathroom": "浴室", + "functionTree.bedroom": "卧室", + "functionTree.dining": "餐厅", + "functionTree.entry": "玄关", + "functionTree.hvac": "暖通空调", + "functionTree.kitchen": "厨房", + "functionTree.laundry": "洗衣房", + "functionTree.lighting": "照明", + "functionTree.living": "客厅", + "functionTree.office": "书房", + "functionTree.outdoor": "户外", + "functionTree.plumbing": "水管", + "functionTree.storage": "储物", + "itemHelper.freePlace": "自由放置", + "itemHelper.placeItem": "放置物品", + "itemHelper.rotateClockwise": "顺时针旋转", + "itemHelper.rotateCounterclockwise": "逆时针旋转", + "helper.continuation.line": "连续:{label}", + "helper.continuation.tooltip": "连续 — 点击或按 C 切换", + "helper.fence.continuationCurved": "弯曲围栏模式下直线连续不可用", + "helper.fence.continuationLine": "围栏连续:{label}", + "helper.fence.continuationStraight": "直线围栏连续 — 点击或按 C 切换", + "helper.fence.curved": "弯曲", + "helper.fence.finishCurve": "完成曲线(或双击)", + "helper.fence.straight": "直线", + "helper.fence.straightContinuous": "直线:连续", + "helper.fence.straightSingle": "直线:单段", + "helper.fence.typeCurved": "类型:弯曲", + "helper.fence.typeLine": "围栏类型:{kind}", + "helper.fence.typeStraight": "类型:直线", + "helper.fence.typeTooltip": "围栏类型 — 点击或按 T 在直线与曲线之间切换", + "helper.gridStep.label": "网格:{step} m", + "helper.gridStep.line": "网格步长:{step} m", + "helper.gridStep.tooltip": "网格步长 — 点击或按 Ctrl 切换", + "helper.paint.allMatching": "全部匹配", + "helper.paint.hoverSurface": "悬停一个表面进行绘制", + "helper.paint.line": "绘制:{scope}", + "helper.paint.pickMaterial": "选择一种材料进行绘制", + "helper.paint.room": "房间", + "helper.paint.scopeLine": "绘制范围:{scope}", + "helper.paint.scopeTooltip": "绘制范围 — 点击或按 Shift 切换", + "helper.paint.thisSurface": "此表面", + "helper.paint.wholeNoun": "整个{noun}", + "helper.snapping.angles": "角度", + "helper.snapping.grid": "网格", + "helper.snapping.lines": "线", + "helper.snapping.line": "捕捉:{mode}", + "helper.snapping.off": "关闭", + "helper.snapping.tooltip": "捕捉模式 — 点击或按 Shift 切换", + "items.all": "全部", + "items.community": "社区", + "keys.commandMac": "Command", + "keys.control": "Control", + "keys.leftClick": "左键点击", + "keys.middleClick": "中键点击", + "keys.rightClick": "右键点击", + "keys.shift": "Shift", + "items.library": "库", + "items.mine": "我的", + "items.noResults": "未找到\"{search}\"的结果", + "items.search": "搜索...", + "level.addLevelAbove": "在上方添加楼层", + "level.addLevelBelow": "在下方添加楼层", + "level.basement": "地下 {n} 层", + "level.cancel": "取消", + "level.cannotDeleteGround": "底层无法删除", + "level.confirmDelete": "确定要删除 {name} 吗?此楼层的所有墙体、地板和物体将被永久删除。", + "level.delete": "删除", + "level.deleteLevel": "删除楼层", + "level.deleteLevelTitle": "删除楼层", + "level.dragToReorder": "拖动重新排序", + "level.duplicateLevel": "复制楼层", + "level.duplicateOptions": "带选项复制...", + "level.floor": "第 {n} 层", + "level.groundFloor": "底层", + "level.insertLevelHere": "在此处插入楼层", + "level.levelHeight": "楼层高度", + "level.noElementsOnLevel": "此楼层上没有元素", + "level.noZonesOnLevel": "此楼层上没有区域。", + "level.pasteCopied": "粘贴已复制内容", + "level.reorder": "重新排序", + "level.thisLevel": "此楼层", + "level.reorderName": "重新排序 {name}", + "level.selectLevelToView": "选择楼层以查看内容", + "levelDuplicate.cancel": "取消", + "levelDuplicate.chooseWhatToCopy": "选择要从 {level} 复制的内容。", + "levelDuplicate.duplicate": "复制", + "levelDuplicate.duplicateLevel": "复制楼层", + "levelDuplicate.everything": "全部", + "levelDuplicate.everythingDesc": "结构、材料、家具和参考。", + "levelDuplicate.structure": "仅结构", + "levelDuplicate.structureDesc": "墙壁、楼板、屋顶、楼梯、窗户和门,无饰面。", + "levelDuplicate.structureFurniture": "结构+家具", + "levelDuplicate.structureFurnitureDesc": "结构、饰面和放置的物品,无参考图。", + "levelDuplicate.structureMaterials": "结构+材料", + "levelDuplicate.structureMaterialsDesc": "带当前材料和饰面分配的结构。", + "loadBuild.buildings": "建筑", + "loadBuild.cannotImport": "无法导入此文件", + "loadBuild.ceilings": "天花板", + "loadBuild.doors": "门", + "loadBuild.errors": "{count} 个错误", + "loadBuild.errors_plural": "{count} 个错误", + "loadBuild.floorArea": "地板面积", + "loadBuild.hide": "隐藏", + "loadBuild.invalidJson": "无法将文件解析为 JSON。", + "loadBuild.items": "物品", + "loadBuild.levels": "楼层", + "loadBuild.nodes": "{count} 个节点", + "loadBuild.noRecognisedNodes": "该文件不包含可识别的节点。", + "loadBuild.readyToImport": "准备导入", + "loadBuild.replaceScene": "替换当前场景", + "loadBuild.scans": "扫描", + "loadBuild.schemaDetails": "架构详情({count} 个节点)", + "loadBuild.showMore": "显示另外 {count} 个", + "loadBuild.sites": "场地", + "loadBuild.slabs": "楼板", + "loadBuild.structure": "结构", + "loadBuild.walls": "墙体", + "loadBuild.warnings": "{count} 个警告", + "loadBuild.warnings_plural": "{count} 个警告", + "loadBuild.windows": "窗", + "loadBuild.zones": "区域", + "nav.home": "首页", + "nav.privacy": "隐私", + "nav.scenes": "场景", + "nav.terms": "条款", + "nodeActions.curve": "曲线", + "nodeActions.cutOut": "切除", + "nodeActions.delete": "删除", + "nodeActions.duplicate": "复制", + "nodeActions.editMesh": "编辑网格", + "nodeActions.findInCatalog": "在目录中查找", + "nodeActions.group": "编组所选", + "nodeActions.groupShortcut": "编组 (Ctrl/Cmd+G)", + "nodeActions.move": "移动", + "nodeActions.ungroup": "取消编组", + "nodeActions.ungroupShortcut": "取消编组 (Ctrl/Cmd+Shift+G)", + "nodeTypes.boxVent": "箱式通风口", + "nodeTypes.building": "建筑", + "nodeTypes.ceiling": "天花板", + "nodeTypes.ceilingWithArea": "天花板 ({area}m²)", + "nodeTypes.chimney": "烟囱", + "nodeTypes.column": "柱子", + "nodeTypes.door": "门", + "nodeTypes.dormer": "老虎窗", + "nodeTypes.elevator": "电梯", + "nodeTypes.fence": "围栏", + "nodeTypes.flat": "平顶", + "nodeTypes.flight": "梯段", + "nodeTypes.gable": "山墙", + "nodeTypes.guide": "参考图", + "nodeTypes.hip": "四坡", + "nodeTypes.item": "物品", + "nodeTypes.landing": "平台", + "nodeTypes.level": "楼层", + "nodeTypes.ridgeVent": "屋脊通风口", + "nodeTypes.roof": "屋顶", + "nodeTypes.roofSegment": "屋顶段", + "nodeTypes.roofSegmentWithDims": "{type} (宽{width}×深{depth}米)", + "nodeTypes.roofWithSegments": "屋顶 ({count} 段)", + "nodeTypes.scan": "扫描", + "nodeTypes.selection": "选择", + "nodeTypes.shed": "棚式", + "nodeTypes.shelf": "架子", + "nodeTypes.site": "场地", + "nodeTypes.skyLight": "天窗", + "nodeTypes.slab": "楼板", + "nodeTypes.slabWithArea": "楼板 ({area}m²)", + "nodeTypes.solarPanel": "太阳能板", + "nodeTypes.spawn": "出生点", + "nodeTypes.stairSegment": "楼梯段", + "nodeTypes.stairSegmentWithDims": "{type} (宽{width}×深{depth}米)", + "nodeTypes.staircaseWithSegments": "楼梯 ({count} 段)", + "nodeTypes.stairs": "楼梯", + "nodeTypes.wall": "墙", + "nodeTypes.window": "窗", + "nodeTypes.zone": "区域", + "nodeTypes.zoneWithArea": "区域 ({area}m²)", + "nodes.boxVent.baseHeight": "底座高度", + "nodes.boxVent.boxVent": "屋顶通风口", + "nodes.roofSegment.hip": "四坡", + "nodes.roofSegment.gable": "山墙", + "nodes.roofSegment.shed": "棚式", + "nodes.roofSegment.flat": "平顶", + "nodes.roofSegment.gambrel": "双坡", + "nodes.roofSegment.dutch": "荷兰式", + "nodes.roofSegment.mansard": "曼萨尔式", + "nodes.roofSegment.conical": "锥形", + "nodes.roofSegment.conicalShape": "锥形形状", + "nodes.roofSegment.clippedVersion": "裁剪版", + "nodes.roofSegment.startAngle": "起始角", + "nodes.roofSegment.arc": "弧度", + "nodes.roofSegment.angle": "角度", + "nodes.roofSegment.editFootprint": "编辑底部投影", + "nodes.roofSegment.autoRidgeVent": "自动屋脊通风口", + "nodes.roofSegment.autoGutters": "自动排水沟", + "nodes.roofSegment.kinkDepth": "折角深度", + "nodes.roofSegment.kinkHeight": "折角高度", + "nodes.roofSegment.waistWidth": "腰部宽度", + "nodes.roofSegment.waistHeight": "腰部高度", + "nodes.roofSegment.waistLength": "腰部长度", + "nodes.roofSegment.topRakeThickness": "顶部斜面厚度", + "nodes.roofSegment.topRakeLength": "顶部斜面长度", + "nodes.roofSegment.wallThickness": "墙厚", + "nodes.roofSegment.deckThickness": "板厚", + "nodes.roofSegment.shingleThickness": "瓦片厚度", + "nodes.stairSegment.flight": "梯段", + "nodes.stairSegment.landing": "平台", + "nodes.stairSegment.front": "前", + "nodes.stairSegment.left": "左", + "nodes.stairSegment.right": "右", + "nodes.elevator.glass": "玻璃", + "nodes.ridgeVent.standard": "标准", + "nodes.ridgeVent.shingled": "瓦片", + "nodes.ridgeVent.flanged": "法兰", + "nodes.ridgeVent.endCaps": "端帽", + "nodes.ridgeVent.open": "开放", + "nodes.skylight.top": "上", + "nodes.skylight.bottom": "下", + "nodes.skylight.left": "左", + "nodes.skylight.right": "右", + "nodes.skylight.motor": "电机", + "nodes.skylight.skylight": "天窗", + "nodes.skylight.noMotor": "无电机", + "nodes.skylight.alongZ": "沿Z轴", + "nodes.skylight.alongX": "沿X轴", + "nodes.skylight.yes": "是", + "nodes.skylight.no": "否", + "common.yes": "是", + "common.no": "否", + "nodes.boxVent.baseInset": "底座内嵌", + "nodes.boxVent.box": "箱", + "nodes.boxVent.cap": "帽", + "nodes.boxVent.capHeight": "帽高", + "nodes.boxVent.cornerBevel": "角部斜角", + "nodes.boxVent.dome": "穹顶", + "nodes.boxVent.domeCurvature": "穹顶曲率", + "nodes.boxVent.gapHeight": "间隙高度", + "nodes.boxVent.hoodOverhang": "罩悬挑", + "nodes.boxVent.defaultName": "屋顶通风口 {count}", + "nodes.boxVent.topTaper": "顶部锥度", + "nodes.boxVent.style": "样式", + "nodes.boxVent.toolHints.cancel": "取消", + "nodes.boxVent.toolHints.place": "放置通风口", + "nodes.building.description": "包含一个或多个楼层的建筑容器。", + "nodes.building.label": "建筑", + "nodes.ceiling.addHole": "添加切口", + "nodes.ceiling.auto": "自动", + "nodes.ceiling.autoHoleLabel.elevator": "自动电梯切口", + "nodes.ceiling.autoHoleLabel.stair": "自动楼梯切口", + "nodes.ceiling.customHeight": "自定义高度", + "nodes.ceiling.currently": "当前 {value}", + "nodes.ceiling.editing": "编辑中", + "nodes.ceiling.followsLevel": "跟随楼层", + "nodes.ceiling.height": "高度", + "nodes.ceiling.heightPresets.high": "高 (3.0m)", + "nodes.ceiling.heightPresets.highImperial": "高 (9英尺)", + "nodes.ceiling.heightPresets.low": "低 (2.4m)", + "nodes.ceiling.heightPresets.lowImperial": "低 (8英尺)", + "nodes.ceiling.heightPresets.standard": "标准 (2.5m)", + "nodes.ceiling.heightPresets.standardImperial": "标准 (8.5英尺)", + "nodes.ceiling.holeLabel": "切口 {index}", + "nodes.ceiling.holes": "切口", + "nodes.ceiling.info": "信息", + "nodes.ceiling.limitedBy": "受限于楼层的 {available} — 若需要更高的天花板,请先调高楼层层高。", + "nodes.ceiling.manual": "手动", + "nodes.ceiling.noHoles": "无切口", + "nodes.ceiling.tooTall": "超过本楼层上限(可用 {available})。请先调高楼层层高。", + "nodes.chimney.aboveRidge": "高过屋脊", + "nodes.chimney.back": "后", + "nodes.chimney.bandExtent": "范围", + "nodes.chimney.bandHeight": "高度", + "nodes.chimney.bandOffset": "偏移", + "nodes.chimney.bandThickness": "厚度", + "nodes.chimney.bands": "环形带", + "nodes.chimney.cap": "帽", + "nodes.chimney.capThickness": "厚度", + "nodes.chimney.corbeled": "悬砌", + "nodes.chimney.cornerBevel": "角部斜角", + "nodes.chimney.count": "数量", + "nodes.chimney.width": "宽度", + "nodes.chimney.cricket": "斜撑", + "nodes.chimney.cricketHeight": "高度", + "nodes.chimney.cricketLength": "长度", + "nodes.chimney.cutoutOffset": "切口偏移", + "nodes.chimney.diameter": "直径", + "nodes.chimney.double": "双个", + "nodes.chimney.flat": "平顶", + "nodes.chimney.flueCount": "数量", + "nodes.chimney.flueDiameter": "直径", + "nodes.chimney.flueHeight": "高度", + "nodes.chimney.flueShape": "形状", + "nodes.chimney.flueSpacing": "间距", + "nodes.chimney.flueWallThickness": "壁厚", + "nodes.chimney.wallThickness": "壁厚", + "nodes.chimney.flues": "烟道", + "nodes.chimney.front": "前", + "nodes.chimney.hollowDepth": "空心深度", + "nodes.chimney.none": "无", + "nodes.chimney.overhang": "悬挑", + "nodes.chimney.panelDepth": "深度", + "nodes.chimney.panelHeight": "高度", + "nodes.chimney.panelMargin": "侧边距", + "nodes.chimney.panelOffsetTop": "顶部偏移", + "nodes.chimney.panels": "面板", + "nodes.chimney.rectangular": "矩形", + "nodes.chimney.round": "圆形", + "nodes.chimney.shoulder": "肩部", + "nodes.chimney.shoulderExtent": "范围", + "nodes.chimney.shoulderHeight": "高度", + "nodes.chimney.simple": "简单", + "nodes.chimney.single": "单个", + "nodes.chimney.sloped": "斜面", + "nodes.chimney.square": "方形", + "nodes.chimney.stepped": "阶梯式", + "nodes.chimney.tapered": "渐缩", + "nodes.chimney.chimney": "烟囱", + "nodes.chimney.chimneyType": "烟囱类型", + "nodes.chimney.offset": "偏移", + "nodes.chimney.footprint": "底部投影", + "nodes.chimney.style": "样式", + "nodes.column.aFrame": "A型框架", + "nodes.column.applyPreset": "应用预设...", + "nodes.column.applyProportion": "应用比例...", + "nodes.column.preset": "预设", + "nodes.column.shape": "形状", + "nodes.column.transform": "变换", + "nodes.column.bottomDepth": "底部深度", + "nodes.column.bottomHeight": "底部高度", + "nodes.column.bottomSpread": "底部展开", + "nodes.column.bottomStepSpread": "底部台阶展开", + "nodes.column.bottomTiers": "底层层数", + "nodes.column.bottomWidth": "底部宽度", + "nodes.column.boxFrame": "箱式框架", + "nodes.column.braceDepth": "支撑深度", + "nodes.column.braceWidth": "支撑宽度", + "nodes.column.bulge": "鼓度", + "nodes.column.bulged": "鼓形", + "nodes.column.connectorPlates": "连接板", + "nodes.column.edgeSoftness": "边缘柔和度", + "nodes.column.endWidth": "端部宽度", + "nodes.column.forkSpread": "分叉展开", + "nodes.column.hourglass": "沙漏形", + "nodes.column.kBrace": "K型支撑", + "nodes.column.neckWidth": "颈部宽度", + "nodes.column.noBottom": "无底", + "nodes.column.noTop": "无顶", + "nodes.column.plinthThickness": "底座厚度", + "nodes.column.portalFrame": "门式框架", + "nodes.column.rectangular": "矩形", + "nodes.column.ringPairs": "环配对数", + "nodes.column.ringSpread": "环展开", + "nodes.column.ringThickness": "环厚度", + "nodes.column.round": "圆形", + "nodes.column.roundBandWidth": "圆带宽", + "nodes.column.roundRings": "圆底", + "nodes.column.roundedTop": "圆顶", + "nodes.column.segmentTwist": "分段扭曲", + "nodes.column.shaftCornerRadius": "柱身圆角半径", + "nodes.column.shaftWidth": "柱身宽度", + "nodes.column.simpleBlockBottom": "简单块体底座", + "nodes.column.simpleTop": "简单顶", + "nodes.column.singleStrut": "单支撑", + "nodes.column.square": "方形", + "nodes.column.squarePlinthBottom": "方形底座底", + "nodes.column.steppedBottom": "阶梯式底座", + "nodes.column.steppedTop": "阶梯顶", + "nodes.column.straight": "直柱", + "nodes.column.taper": "锥度", + "nodes.column.tapered": "渐缩", + "nodes.column.topDepth": "顶部深度", + "nodes.column.topHeight": "顶部高度", + "nodes.column.topSpread": "顶部展开", + "nodes.column.topStepSpread": "顶部台阶展开", + "nodes.column.topTiers": "顶层数", + "nodes.column.topWidth": "顶部宽度", + "nodes.column.trestle": "支架", + "nodes.column.tripod": "三脚架", + "nodes.column.twistSegments": "扭曲段数", + "nodes.column.vFrame": "V型支撑", + "nodes.column.vertical": "垂直", + "nodes.column.waist": "腰部", + "nodes.column.xBrace": "X型支撑", + "nodes.column.yFrame": "Y型支撑", + "nodes.column.yaw": "偏航", + "nodes.column.dimensions": "尺寸", + "nodes.column.shaft": "柱身", + "nodes.column.ends": "端部", + "nodes.column.slender": "纤细", + "nodes.column.standard": "标准", + "nodes.column.heavy": "粗壮", + "nodes.column.shortStout": "矮胖", + "nodes.door.addSegment": "+ 添加分段", + "nodes.door.archHeight": "拱高", + "nodes.door.columnLabel": "C{i}", + "nodes.door.columns": "列数", + "nodes.door.contentPaddingSection": "内容边距", + "nodes.door.corners.topLeft": "左上", + "nodes.door.corners.topRight": "右上", + "nodes.door.defaultName": "门 {count}", + "nodes.door.description": "墙体上的门,可动画开关。", + "nodes.door.direction": "方向", + "nodes.door.divider": "分隔条", + "nodes.door.doorCloser": "闭门器", + "nodes.door.doorTypeOptions.barn": "谷仓", + "nodes.door.doorTypeOptions.double": "双扇", + "nodes.door.doorTypeOptions.folding": "折叠", + "nodes.door.doorTypeOptions.french": "法式", + "nodes.door.doorTypeOptions.hinged": "铰链", + "nodes.door.doorTypeOptions.pocket": "隐藏", + "nodes.door.doorTypeOptions.rollup": "卷起", + "nodes.door.doorTypeOptions.sectional": "卷帘", + "nodes.door.doorTypeOptions.sliding": "推拉", + "nodes.door.doorTypeOptions.tiltup": "上翻", + "nodes.door.enableHandle": "启用把手", + "nodes.door.enableThreshold": "启用门槛", + "nodes.door.fallbackTitle": "门", + "nodes.door.flipSide": "翻转侧面", + "nodes.door.fold": "折叠", + "nodes.door.frameSection": "框架", + "nodes.door.handleSection": "把手", + "nodes.door.handleSide": "把手侧", + "nodes.door.hardwareSection": "五金", + "nodes.door.hingesSide": "铰链侧", + "nodes.door.horizontalPadding": "水平", + "nodes.door.inset": "内嵌", + "nodes.door.label": "门", + "nodes.door.open": "打开", + "nodes.door.openingShapeOptions.arch": "拱形", + "nodes.door.openingShapeOptions.rect": "矩形", + "nodes.door.openingShapeOptions.rounded": "圆角", + "nodes.door.openingShapeSection": "开口形状", + "nodes.door.panels": "门扇", + "nodes.door.panicBar": "逃生杆", + "nodes.door.panicBarHeight": "杆高度", + "nodes.door.radiusModeOptions.all": "全部", + "nodes.door.radiusModeOptions.individual": "单独", + "nodes.door.remove": "- 移除", + "nodes.door.revealRadius": "嵌槽半径", + "nodes.door.segmentLabel": "分段 {i}", + "nodes.door.segmentTypeOptions.empty": "空", + "nodes.door.segmentTypeOptions.glass": "玻璃", + "nodes.door.segmentTypeOptions.panel": "面板", + "nodes.door.segmentsSection": "分段", + "nodes.door.slide": "推拉", + "nodes.door.slideDirectionOptions.panel": "面板式", + "nodes.door.slideDirectionOptions.pocket": "隐藏式", + "nodes.door.slideDirectionOptions.rail": "轨道式", + "nodes.door.swingDirectionOptions.inward": "向内", + "nodes.door.swingDirectionOptions.outward": "向外", + "nodes.door.swingSection": "开启", + "nodes.door.thresholdSection": "门槛", + "nodes.door.toolHints.cancel": "取消", + "nodes.door.toolHints.place": "在墙体上放置门", + "nodes.door.topShapeOptions.arch": "拱形", + "nodes.door.topShapeOptions.rect": "矩形", + "nodes.door.topShapeOptions.rounded": "圆角", + "nodes.door.topShapeSection": "顶部形状", + "nodes.door.type": "类型", + "nodes.door.typeOptions.door": "门", + "nodes.door.typeOptions.garage": "车库门", + "nodes.door.typeOptions.opening": "开口", + "nodes.door.verticalPadding": "垂直", + "nodes.dormer.all": "全部", + "nodes.dormer.arch": "拱形", + "nodes.dormer.archHeight": "拱高", + "nodes.dormer.bottomLeft": "左下", + "nodes.dormer.bottomRight": "右下", + "nodes.dormer.columns": "列数", + "nodes.dormer.cornerRadius": "圆角半径", + "nodes.dormer.depth": "深度", + "nodes.dormer.divider": "分隔条", + "nodes.dormer.dormer": "老虎窗", + "nodes.dormer.dutch": "荷兰式", + "nodes.dormer.defaultName": "窗户 {count}", + "nodes.dormer.enableSill": "启用窗台", + "nodes.dormer.flat": "平顶", + "nodes.dormer.frameDepth": "深度", + "nodes.dormer.frameThickness": "厚度", + "nodes.dormer.gable": "山墙", + "nodes.dormer.gambrel": "双坡", + "nodes.dormer.hip": "四坡", + "nodes.dormer.hungWall": "悬墙", + "nodes.dormer.individual": "单独", + "nodes.dormer.mansard": "曼萨尔式", + "nodes.dormer.pitchDirection": "坡向", + "nodes.dormer.pitchRise": "坡度增量", + "nodes.dormer.rect": "矩形", + "nodes.dormer.riseBack": "高在后侧", + "nodes.dormer.riseFront": "高在前侧", + "nodes.dormer.roofHeight": "屋顶高度", + "nodes.dormer.rounded": "圆角", + "nodes.dormer.roofType": "屋顶类型", + "nodes.dormer.rows": "行数", + "nodes.dormer.section": "截面", + "nodes.dormer.shed": "棚式", + "nodes.dormer.sillDepth": "窗台深度", + "nodes.dormer.sillThickness": "窗台厚度", + "nodes.dormer.topLeft": "左上", + "nodes.dormer.topRight": "右上", + "nodes.dormer.wallHeight": "壁高", + "nodes.dormer.width": "宽度", + "nodes.dormer.window": "窗户", + "nodes.dormer.windowHeight": "高度", + "nodes.dormer.windowOffsetX": "X偏移", + "nodes.dormer.windowOffsetY": "Y偏移", + "nodes.dormer.windowWidth": "宽度", + "nodes.elevator.cabDepth": "轿厢深度", + "nodes.elevator.cabHeight": "轿厢高度", + "nodes.elevator.cabWidth": "宽度", + "nodes.elevator.centerOpening": "中部开口", + "nodes.elevator.defaultFloor": "默认楼层", + "nodes.elevator.disabled": "禁用", + "nodes.elevator.doorHeight": "门高", + "nodes.elevator.solid": "实体", + "nodes.elevator.doorTime": "门时间", + "nodes.elevator.doorType": "门类型", + "nodes.elevator.doorWidth": "门宽", + "nodes.elevator.dwell": "停留", + "nodes.elevator.from": "从", + "nodes.elevator.glassFrame": "玻璃框", + "nodes.elevator.openingStyle": "开口样式", + "nodes.elevator.segmentedPanel": "分段面板", + "nodes.elevator.service": "服务", + "nodes.elevator.shaftDepth": "井道深度", + "nodes.elevator.shaftStyle": "井道样式", + "nodes.elevator.shaftWidth": "井道宽度", + "nodes.elevator.singleLeft": "单左", + "nodes.elevator.singleRight": "单右", + "nodes.elevator.solidPanel": "实心面板", + "nodes.elevator.speed": "速度", + "nodes.elevator.to": "到", + "nodes.elevator.wallThickness": "壁厚", + "nodes.elevator.yaw": "偏航", + "nodes.elevator.access": "通道", + "nodes.elevator.cab": "轿厢", + "nodes.elevator.destination": "目标", + "nodes.elevator.copy": "{name}副本", + "nodes.elevator.defaultCopy": "电梯副本", + "nodes.elevator.doors": "门", + "nodes.elevator.motion": "运动", + "nodes.elevator.offset": "偏移", + "nodes.elevator.rotation": "旋转", + "nodes.elevator.rotationPresets.negative": "-45°", + "nodes.elevator.rotationPresets.positive": "+45°", + "nodes.elevator.shaft": "井道", + "nodes.elevator.stopLabel": "停靠 {n}", + "nodes.elevator.serviceButton": "服务", + "nodes.elevator.disabledButton": "禁用", + "nodes.fence.description": "具有可配置柱和填充的直栏杆或曲线栏杆段。", + "nodes.fence.label": "栏杆", + "nodes.fence.toolHints.allowAngles": "允许非45°角", + "nodes.fence.toolHints.cancel": "取消", + "nodes.fence.toolHints.setStartEnd": "设置栏杆起点/终点", + "nodes.measurement.toolHints.finish": "完成测量", + "nodes.measurement.toolHints.finishContinue": "完成并继续", + "nodes.measurement.toolHints.placePoint": "放置测量点", + "nodes.measurement.toolHints.removeLast": "移除上一个点", + "nodes.level.description": "建筑的单个楼层,包含墙体/楼板/天花板/物品。", + "nodes.level.label": "楼层", + "nodes.roof.addChimney": "添加烟囱", + "nodes.roof.addCupola": "添加穹顶天窗", + "nodes.roof.addDormer": "添加老虎窗", + "nodes.roof.addEyebrowVent": "添加弧形通风口", + "nodes.roof.addGutter": "添加檐沟", + "nodes.roof.addSegment": "添加分段", + "nodes.roof.addSkylight": "添加天窗", + "nodes.roof.addSolarPanel": "添加太阳能板", + "nodes.roof.addVent": "添加通风口", + "nodes.roof.box": "箱形", + "nodes.roof.defaultName.boxVent": "屋顶通风口 {count}", + "nodes.roof.defaultName.chimney": "烟囱 {count}", + "nodes.roof.defaultName.dormer": "老虎窗 {count}", + "nodes.roof.defaultName.gutter": "檐沟 {count}", + "nodes.roof.defaultName.ridgeVent": "屋脊通风口 {count}", + "nodes.roof.defaultName.segment": "屋顶段 {count}", + "nodes.roof.defaultName.skylight": "天窗 {count}", + "nodes.roof.defaultName.solarPanel": "太阳能板 {count}", + "nodes.roof.defaultName.turbineVent": "涡轮通风口 {count}", + "nodes.roof.elements": "附属构件", + "nodes.roof.fallbackTitle": "屋顶", + "nodes.roof.kindLabel.boxVent": "屋顶通风口", + "nodes.roof.kindLabel.chimney": "烟囱", + "nodes.roof.kindLabel.dormer": "老虎窗", + "nodes.roof.kindLabel.gutter": "檐沟", + "nodes.roof.kindLabel.ridgeVent": "屋脊通风口", + "nodes.roof.kindLabel.skylight": "天窗", + "nodes.roof.kindLabel.solarPanel": "太阳能板", + "nodes.roof.kindLabel.turbineVent": "涡轮通风口", + "nodes.roof.ridge": "屋脊", + "nodes.roof.segments": "屋顶段", + "nodes.roof.turbine": "涡轮", + "nodes.roofSegment.fallbackTitle": "屋顶段", + "nodes.roofSegment.roofType": "屋顶类型", + "nodes.roofSegment.footprint": "底部投影", + "nodes.roofSegment.wallHeight": "墙高", + "nodes.roofSegment.pitch": "坡度", + "nodes.roofSegment.shape": "形状", + "nodes.roofSegment.structure": "结构", + "nodes.roofSegment.trim": "修剪", + "nodes.roofSegment.drainage": "排水", + "nodes.slab.addHole": "添加切口", + "nodes.slab.area": "面积", + "nodes.slab.auto": "自动", + "nodes.slab.autoHoleLabel.elevator": "自动电梯切口", + "nodes.slab.autoHoleLabel.stair": "自动楼梯切口", + "nodes.slab.base": "底部", + "nodes.slab.depth": "深度", + "nodes.slab.description": "承载物品的多边形地板表面。", + "nodes.slab.editing": "(编辑中)", + "nodes.slab.elevationPresets.ground": "地面 (0m)", + "nodes.slab.elevationPresets.raised": "抬起 (+5cm)", + "nodes.slab.elevationPresets.standard": "标准 (5cm)", + "nodes.slab.elevationPresets.step": "台阶 (+15cm)", + "nodes.slab.elevationPresets.sunken": "下沉 (-15cm)", + "nodes.slab.elevationPresets.thick": "加厚 (15cm)", + "nodes.slab.elevationPresets.thin": "薄型 (2cm)", + "nodes.slab.fallbackTitle": "楼板", + "nodes.slab.fixed": "固定", + "nodes.slab.floor": "楼面", + "nodes.slab.followsTerrain": "跟随地形", + "nodes.slab.foundation": "基础", + "nodes.slab.holeLabel": "切口 {index}", + "nodes.slab.label": "楼板", + "nodes.slab.manual": "手动", + "nodes.slab.noHoles": "无切口", + "nodes.slab.pts": "点", + "nodes.slab.rim": "边缘", + "nodes.slab.surface": "表面", + "nodes.slab.terrainDescription": "将楼板轮廓沿垂直方向延伸至地形。表面、底部和厚度保持不变。", + "nodes.slab.toolHints.cancel": "取消", + "nodes.slab.toolHints.finish": "完成楼板", + "nodes.slab.toolHints.trace": "绘制楼板轮廓", + "nodes.slab.elevation": "标高", + "nodes.slab.info": "信息", + "nodes.slab.holes": "切口", + "nodes.solarPanel.columns": "列数", + "nodes.solarPanel.customNotice": "自定义 — 尺寸与任何预设都不匹配", + "nodes.solarPanel.fallbackTitle": "太阳能板", + "nodes.solarPanel.flipOrientation": "翻转方向", + "nodes.solarPanel.flush": "平齐", + "nodes.solarPanel.frameDepth": "框架深度", + "nodes.solarPanel.frameThickness": "框架厚度", + "nodes.solarPanel.gapX": "水平间距", + "nodes.solarPanel.gapY": "垂直间距", + "nodes.solarPanel.rows": "行数", + "nodes.solarPanel.setbacksTooLarge": "退缩距离过大,无法放置面板。", + "nodes.solarPanel.standoff": "支架", + "nodes.solarPanel.tiltAngle": "倾斜角度", + "nodes.solarPanel.tilted": "倾斜", + "nodes.solarPanel.preset": "预设", + "nodes.solarPanel.presetCompact": "紧凑型", + "nodes.solarPanel.presetFrameless": "无边框", + "nodes.solarPanel.presetResidential": "住宅型", + "nodes.solarPanel.presetResidentialLarge": "住宅型大尺寸", + "nodes.solarPanel.array": "阵列", + "nodes.solarPanel.panel": "面板", + "nodes.solarPanel.mounting": "安装", + "nodes.solarPanel.autoFitToRoof": "自动适应屋顶", + "nodes.solarPanel.toolHints.cancel": "取消", + "nodes.solarPanel.toolHints.place": "放置太阳能板", + "nodes.skyLight.type": "类型", + "nodes.skyLight.dimensions": "尺寸", + "nodes.skyLight.frame": "框架", + "nodes.skyLight.curb": "基座", + "nodes.skyLight.fallbackTitle": "天窗", + "nodes.ridgeVent.style": "样式", + "nodes.ridgeVent.dimensions": "尺寸", + "nodes.ridgeVent.rotation": "旋转", + "nodes.ridgeVent.fallbackTitle": "屋脊通风口", + "nodes.stair.addFlight": "添加梯段", + "nodes.stair.addLanding": "添加平台", + "nodes.stair.autoCutout": "自动切口", + "nodes.stair.both": "双侧", + "nodes.stair.centerColumn": "中心柱", + "nodes.stair.curved": "弧形梯", + "nodes.stair.destination": "目标", + "nodes.stair.fitToFloor": "适应楼层", + "nodes.stair.fallbackTitle": "楼梯", + "nodes.stair.fromLevel": "起始楼层", + "nodes.stair.followsDeck": "跟随楼板", + "nodes.stair.customRise": "自定义提升", + "nodes.stair.currently": "当前 {height} 米", + "nodes.stair.deckName": "楼板", + "nodes.stair.geometry": "几何", + "nodes.stair.landing": "平台", + "nodes.stair.levelName": "第 {n} 层", + "nodes.stair.rise": "提升", + "nodes.stair.segmentName": "段 {n}", + "nodes.stair.steps": "梯段数", + "nodes.stair.innerRadius": "内半径", + "nodes.stair.integrated": "整体", + "nodes.stair.left": "左侧", + "nodes.stair.none": "无", + "nodes.stair.opening": "开口", + "nodes.stair.openingOffset": "开口偏移", + "nodes.stair.position": "位置", + "nodes.stair.railing": "栏杆", + "nodes.stair.railingHeight": "高度", + "nodes.stair.right": "右侧", + "nodes.stair.rotationPresets.negative": "-45°", + "nodes.stair.rotationPresets.positive": "+45°", + "nodes.stair.segment": "分段", + "nodes.stair.segments": "分段", + "nodes.stair.spiral": "螺旋梯", + "nodes.stair.stepSupports": "踏步支撑", + "nodes.stair.straight": "直梯", + "nodes.stair.sweep": "旋转角度", + "nodes.stair.toLevel": "目标楼层", + "nodes.stair.topLanding": "顶部平台", + "nodes.stair.type": "类型", + "nodes.stairSegment.type": "类型", + "nodes.stairSegment.attachment": "连接", + "nodes.stairSegment.dimensions": "尺寸", + "nodes.stairSegment.structure": "结构", + "nodes.stairSegment.fallbackTitle": "楼梯段", + "nodes.stairSegment.width": "宽度", + "nodes.stairSegment.length": "长度", + "nodes.stairSegment.height": "高度", + "nodes.stairSegment.steps": "踏步数", + "nodes.stairSegment.fillToFloor": "填充到地板", + "nodes.stairSegment.thickness": "厚度", + "nodes.stairSegment.move": "移动", + "nodes.stairSegment.duplicate": "复制", + "nodes.wall.bands": "墙面分带", + "nodes.wall.curve": "弧度", + "nodes.wall.description": "直墙或弧墙。承载门、窗和壁挂物品。", + "nodes.wall.draftName": "草稿墙体", + "nodes.wall.fallbackTitle": "墙体", + "nodes.wall.followsLevel": "跟随楼层", + "nodes.wall.customHeight": "自定义高度", + "nodes.wall.currently": "当前 {measurement}", + "nodes.wall.label": "墙体", + "nodes.wall.length": "长度", + "nodes.wall.top": "顶部", + "nodes.wall.bottom": "底部", + "nodes.wall.auto": "自动", + "nodes.wall.fillToTerrain": "填充至地形", + "nodes.wall.fillToTerrainDescription": "向下延伸至地形。高度与顶部保持不变。", + "nodes.wall.thickness": "厚度", + "nodes.wall.bandsCount": "分带数", + "nodes.wall.bandsLower": "下部", + "nodes.wall.bandsMiddle": "中部", + "nodes.wall.bandsUpper": "上部", + "nodes.wall.skirting": "踢脚线", + "nodes.wall.crown": "顶角线", + "nodes.wall.chairRail": "护墙板腰线", + "nodes.wall.trimHide": "隐藏{trim}", + "nodes.wall.trimShow": "显示{trim}", + "nodes.wall.interior": "内侧", + "nodes.wall.exterior": "外侧", + "nodes.wall.both": "两侧", + "nodes.wall.proud": "凸出量", + "nodes.wall.offset": "偏移", + "nodes.wall.trimProfile.flat": "平直", + "nodes.wall.trimProfile.modern": "现代", + "nodes.wall.trimProfile.colonial": "殖民地", + "nodes.wall.trimProfile.shoe": "矮勒脚", + "nodes.wall.trimProfile.ogee": "葱形", + "nodes.wall.trimProfile.cove": "凹弧", + "nodes.wall.trimProfile.craft": "工匠", + "nodes.wall.trimProfile.layered": "分层", + "nodes.wall.trimProfile.round": "圆形", + "nodes.wall.trimProfile.picture": "挂镜", + "nodes.wall.trimProfile.step": "阶梯", + "nodes.wall.dimensions": "尺寸", + "nodes.wall.toolHints.allowAngles": "允许非45°角", + "nodes.wall.toolHints.cancel": "取消", + "nodes.wall.toolHints.setStartEnd": "设置墙体起点/终点", + "nodes.window.archHeight": "拱高", + "nodes.window.casementStyleOptions.french": "法式", + "nodes.window.casementStyleOptions.single": "单扇", + "nodes.window.colWidths": "列宽", + "nodes.window.columns": "列数", + "nodes.window.cornerRadius": "圆角半径", + "nodes.window.corners.bottomLeft": "左下", + "nodes.window.corners.bottomRight": "右下", + "nodes.window.corners.topLeft": "左上", + "nodes.window.corners.topRight": "右上", + "nodes.window.defaultName": "窗户 {count}", + "nodes.window.depth": "深度", + "nodes.window.description": "墙体上的窗户,可动画开关。", + "nodes.window.divider": "分隔条", + "nodes.window.enableSill": "启用窗台", + "nodes.window.fallbackTitle": "窗户", + "nodes.window.flipSide": "翻转侧面", + "nodes.window.frame": "框架", + "nodes.window.grid": "网格", + "nodes.window.label": "窗户", + "nodes.window.openingShape": "开口形状", + "nodes.window.openingShapeOptions.arch": "拱形", + "nodes.window.openingShapeOptions.rect": "矩形", + "nodes.window.openingShapeOptions.rounded": "圆角", + "nodes.window.operationLabels.slide": "推拉", + "nodes.window.operationLabels.raise": "升降", + "nodes.window.operationLabels.slats": "百叶", + "nodes.window.operationLabels.swing": "平开", + "nodes.window.operationLabels.tilt": "倾开", + "nodes.window.radiusModeOptions.all": "全部", + "nodes.window.radiusModeOptions.individual": "单独", + "nodes.window.revealRadius": "嵌槽半径", + "nodes.window.rowHeights": "行高", + "nodes.window.rowLabel": "R{i}", + "nodes.window.rows": "行数", + "nodes.window.sill": "窗台", + "nodes.window.sillDepth": "窗台深度", + "nodes.window.sillThickness": "窗台厚度", + "nodes.window.thickness": "厚度", + "nodes.window.toolHints.cancel": "取消", + "nodes.window.toolHints.place": "在墙体上放置窗户", + "nodes.window.topShape": "顶部形状", + "nodes.window.topShapeOptions.arch": "拱形", + "nodes.window.topShapeOptions.rect": "矩形", + "nodes.window.topShapeOptions.rounded": "圆角", + "nodes.window.type": "类型", + "nodes.window.typeOptions.awning": "上悬", + "nodes.window.typeOptions.bay": "飘窗", + "nodes.window.typeOptions.bow": "圆弧窗", + "nodes.window.typeOptions.casement": "平开", + "nodes.window.typeOptions.doubleHung": "双悬", + "nodes.window.typeOptions.fixed": "固定", + "nodes.window.typeOptions.louvered": "百叶", + "nodes.window.typeOptions.opening": "开口", + "nodes.window.typeOptions.singleHung": "单悬", + "nodes.window.typeOptions.sliding": "推拉", + "nodes.window.typeOptions.window": "窗户", + "nodes.window.windowType": "窗户类型", + "panel.deleteLevel": "删除楼层", + "panel.duplicateLevel": "复制楼层", + "panel.editScale": "编辑比例", + "panel.hideScale": "隐藏比例", + "panel.intensity": "强度", + "panel.nodeType.block": "块", + "panel.nodeType.block_plural": "块", + "panel.nodeType.boxVent": "屋顶通风口", + "panel.nodeType.boxVent_plural": "屋顶通风口", + "panel.nodeType.building": "建筑", + "panel.nodeType.building_plural": "建筑", + "panel.nodeType.ceiling": "天花板", + "panel.nodeType.ceiling_plural": "天花板", + "panel.nodeType.chimney": "烟囱", + "panel.nodeType.chimney_plural": "烟囱", + "panel.nodeType.constructionDimension": "施工尺寸", + "panel.nodeType.constructionDimension_plural": "施工尺寸", + "panel.nodeType.cupola": "屋顶通风塔", + "panel.nodeType.cupola_plural": "屋顶通风塔", + "panel.nodeType.dormer": "老虎窗", + "panel.nodeType.dormer_plural": "老虎窗", + "panel.nodeType.downspout": "落水管", + "panel.nodeType.downspout_plural": "落水管", + "panel.nodeType.ductFitting": "风管配件", + "panel.nodeType.ductFitting_plural": "风管配件", + "panel.nodeType.ductSegment": "风管", + "panel.nodeType.ductSegment_plural": "风管", + "panel.nodeType.ductTerminal": "送风口", + "panel.nodeType.ductTerminal_plural": "送风口", + "panel.nodeType.eyebrowVent": "弧形通风口", + "panel.nodeType.eyebrowVent_plural": "弧形通风口", + "panel.nodeType.gutter": "檐沟", + "panel.nodeType.gutter_plural": "檐沟", + "panel.nodeType.hvacEquipment": "暖通空调设备", + "panel.nodeType.hvacEquipment_plural": "暖通空调设备", + "panel.nodeType.leanToExtension": "雨棚", + "panel.nodeType.leanToExtension_plural": "雨棚", + "panel.nodeType.level": "楼层", + "panel.nodeType.level_plural": "楼层", + "panel.nodeType.lineset": "管线", + "panel.nodeType.lineset_plural": "管线", + "panel.nodeType.liquidLine": "液管", + "panel.nodeType.liquidLine_plural": "液管", + "panel.nodeType.pipeFitting": "管道配件", + "panel.nodeType.pipeFitting_plural": "管道配件", + "panel.nodeType.pipeSegment": "排水管", + "panel.nodeType.pipeSegment_plural": "排水管", + "panel.nodeType.pipeTrap": "存水弯", + "panel.nodeType.pipeTrap_plural": "存水弯", + "panel.nodeType.ridgeVent": "屋脊通风口", + "panel.nodeType.ridgeVent_plural": "屋脊通风口", + "panel.nodeType.shelf": "架子", + "panel.nodeType.shelf_plural": "架子", + "panel.nodeType.site": "场地", + "panel.nodeType.site_plural": "场地", + "panel.nodeType.skylight": "天窗", + "panel.nodeType.skylight_plural": "天窗", + "panel.nodeType.solarPanel": "太阳能板", + "panel.nodeType.solarPanel_plural": "太阳能板", + "panel.nodeType.structuralGrid": "结构网格", + "panel.nodeType.structuralGrid_plural": "结构网格", + "panel.nodeType.turbineVent": "涡轮通风口", + "panel.nodeType.turbineVent_plural": "涡轮通风口", + "panel.nodeType.zone": "区域", + "panel.nodeType.zone_plural": "区域", + "panel.nodeType.column": "柱子", + "panel.nodeType.column_plural": "柱子", + "panel.nodeType.door": "门", + "panel.nodeType.door_plural": "门", + "panel.nodeType.elevator": "电梯", + "panel.nodeType.elevator_plural": "电梯", + "panel.nodeType.fence": "围栏", + "panel.nodeType.fence_plural": "围栏", + "panel.nodeType.guide": "参考图", + "panel.nodeType.guide_plural": "参考图", + "panel.nodeType.item": "物品", + "panel.nodeType.item_plural": "物品", + "panel.nodeType.roof": "屋顶", + "panel.nodeType.roof_plural": "屋顶", + "panel.nodeType.roofSegment": "屋顶段", + "panel.nodeType.roofSegment_plural": "屋顶段", + "panel.nodeType.scan": "3D 扫描", + "panel.nodeType.scan_plural": "3D 扫描", + "panel.nodeType.slab": "楼板", + "panel.nodeType.slab_plural": "楼板", + "panel.nodeType.stair": "楼梯", + "panel.nodeType.stair_plural": "楼梯", + "panel.nodeType.stairSegment": "楼梯段", + "panel.nodeType.stairSegment_plural": "楼梯段", + "panel.nodeType.wall": "墙体", + "panel.nodeType.wall_plural": "墙体", + "panel.nodeType.window": "窗户", + "panel.nodeType.window_plural": "窗户", + "panel.nodeType.measurement": "测量", + "panel.nodeType.measurement_plural": "测量", + "panel.nodeType.spawn": "生成点", + "panel.nodeType.spawn_plural": "生成点", + "panel.nodeType.cabinet": "橱柜", + "panel.nodeType.cabinet_plural": "橱柜", + "panel.nodeType.cabinetModule": "橱柜模块", + "panel.nodeType.cabinetModule_plural": "橱柜模块", + "panel.action.group": "编组", + "panel.action.ungroup": "取消编组", + "panel.action.duplicate": "复制", + "panel.action.delete": "删除", + "panel.multiSelection.selected": "已选择 {count} 个", + "panel.multiSelection.withGroup": "{label} · {count}", + "panel.multiSelection.sessionOnlyFooter": "{label}(仅本次会话)。普通点击将重新选中所有成员。不会随项目保存。", + "panel.noEdgeLines": "无边缘线", + "panel.scaleAndOpacity": "比例与不透明度", + "panel.selection": "选择", + "panel.setScale": "设置比例", + "panel.showScale": "显示比例", + "scenes.allScenes": "所有场景", + "scenes.noScenes": "还没有场景。创建一个开始吧。", + "scenes.noThumbnail": "无缩略图", + "scenes.nodes": "{count} 个节点", + "scenes.sceneCount": "{count} 个场景", + "scenes.sceneCount_plural": "{count} 个场景", + "scenes.title": "您的场景", + "scenes.noScenesSaved": "暂无保存的场景", + "save.conflictReload": "冲突 — 场景在其他地方被修改。请重新加载以继续。", + "save.createNewScene": "新建场景", + "save.creating": "创建中…", + "save.failedToCreateScene": "创建场景失败", + "save.newSceneName": "新场景名称", + "save.noSceneToSave": "没有可保存的场景", + "save.save": "保存", + "save.saveAs": "另存为", + "save.saveAsFailed": "另存为失败", + "save.saveFailed": "保存失败", + "save.saved": "已保存", + "save.saving": "保存中…", + "save.untitledScene": "未命名场景", + "settings.adjustVolume": "调整音量级别和静音设置", + "settings.audio": "音频", + "settings.audioSettings": "音频设置", + "settings.clearAndStartNew": "清除并重新开始", + "settings.copied": "已复制", + "settings.copyProjectId": "复制项目ID", + "settings.dangerZone": "危险区域", + "settings.exploreSceneGraph": "探索场景图", + "settings.export": "导出", + "settings.export3DModel": "导出3D模型", + "settings.exportedGLB": "导出GLB", + "settings.exportedOBJ": "导出OBJ", + "settings.exportedSTL": "导出STL", + "settings.exporting": "生成中...", + "settings.floorPlan": "平面图", + "settings.floorplanDefault": "默认", + "settings.floorplanExpert": "高级", + "settings.floorplanFull": "完整平面图", + "settings.floorplanStructure": "仅结构", + "settings.generateThumbnail": "生成缩略图", + "settings.keyboard": "键盘", + "settings.loadBuild": "加载构建", + "settings.masterVolume": "主音量", + "settings.muteAllSounds": "静音所有声音", + "settings.project": "项目", + "settings.projectId": "项目ID", + "settings.projectIdCopied": "项目ID已复制", + "settings.public": "公开", + "settings.radioVolume": "收音机音量", + "settings.saveAndLoad": "保存与加载", + "settings.saveBuild": "保存构建", + "settings.sceneGraph": "场景图", + "settings.shadows": "阴影", + "settings.show3DScans": "显示3D扫描", + "settings.showFloorplans": "显示平面图", + "settings.showGrid": "显示网格", + "settings.soundEffects": "音效", + "settings.thumbnail": "缩略图", + "settings.unmuteAllSounds": "取消静音所有声音", + "settings.visibility": "可见性", + "settings.visibilityAnyone": "任何人", + "settings.visibilityCanView": "可以查看", + "settings.visibilityCastShadows": "从灯光投射阴影", + "settings.visibilityEditorOnly": "仅在编辑器中可见", + "settings.visibilityOnlyYou": "仅你", + "settings.visibilityPublic": "公开", + "settings.visibilityShow3DScans": "显示3D扫描", + "settings.visibilityShowFloorplans": "显示平面图", + "settings.visibilityShowGrid": "显示网格", + "settings.visibilityVisiblePublic": "对公开查看者可见", + "settings.visibleNodesOnly": "仅可见节点", + "settings.visibleNodesOnlyDesc": "仅导出当前在编辑器中可见的节点。", + "shortcuts.activateMeasurementTool": "激活测量工具", + "shortcuts.addToCanvasSelection": "添加或移除画布选中", + "shortcuts.addToCanvasSelectionNote": "在空白处按住 Shift + 左键可扩展或缩小画布级选中范围。", + "shortcuts.addToSelection": "添加或移除多选中的对象", + "shortcuts.addToSelectionNote": "Cmd/Ctrl 可逐个切换对象是否进入选中状态。", + "shortcuts.bypassGuidedConstraints": "绕过引导式放置约束", + "shortcuts.bypassGuidedConstraintsNote": "绘制墙、楼板或天花板时按住 Shift 可忽略引导吸附线。", + "shortcuts.bypassPlacementValidation": "暂时绕过放置验证约束", + "shortcuts.bypassRotationSnap": "绕过旋转吸附", + "shortcuts.bypassRotationSnapNote": "放置物品时按住 Shift 可自由旋转而不吸附。", + "shortcuts.camera": "相机", + "shortcuts.cancelTool": "取消当前工具并返回选择模式", + "shortcuts.cancelToolNote": "等同于在工具栏中点击选择工具。", + "shortcuts.clearSelection": "清除选中", + "shortcuts.clearSelectionNote": "当光标位于画布上时按 Esc。", + "shortcuts.contextAware": "快捷键是上下文感知的,取决于当前阶段或工具。", + "shortcuts.copySelection": "复制选中", + "shortcuts.copySelectionNote": "使用 Cmd/Ctrl + V 将剪贴板内容粘贴到场景中。", + "shortcuts.cutSelection": "剪切选中", + "shortcuts.cutSelectionNote": "将选中对象剪切到剪贴板,粘贴时恢复到光标处。", + "shortcuts.cycleGridStep": "循环网格步长", + "shortcuts.cycleGridStepNote": "绘制时按住 Cmd/Ctrl 可使用不同的网格步长进行吸附。", + "shortcuts.cycleSnapMode": "循环吸附模式", + "shortcuts.cycleSnapModeNote": "绘制时按住 Shift 可在吸附模式间循环切换。", + "shortcuts.deleteSelected": "删除选中对象", + "shortcuts.directManipulation": "直接操作", + "shortcuts.dragMiddleMouseOrHoldSpace": "按住中键拖动,或按住空格键同时左键拖动。", + "shortcuts.dragRightMouse": "右键拖动。", + "shortcuts.drawingTools": "绘制工具", + "shortcuts.editorNavigation": "编辑器导航", + "shortcuts.groupSelection": "编组选中", + "shortcuts.groupSelectionNote": "将所选对象合并为一个可变换的编组。", + "shortcuts.holdWhilePlacing": "放置时按住。", + "shortcuts.itemPlacement": "物品放置", + "shortcuts.keyboardShortcuts": "键盘快捷键", + "shortcuts.modesAndHistory": "模式与历史", + "shortcuts.moveMultiSelection": "移动多选中", + "shortcuts.moveMultiSelectionNote": "左键单击任意选中对象并拖动可整体移动选中范围。", + "shortcuts.moveUnderCursor": "在光标下移动", + "shortcuts.moveUnderCursorNote": "Cmd/Ctrl + 左键单击并拖动任意对象可在光标下移动它。", + "shortcuts.orbitCamera": "旋转相机", + "shortcuts.operateSelectedNode": "操作选中节点", + "shortcuts.panCamera": "平移相机", + "shortcuts.panCameraMiddle": "平移相机(中键 / 空格)", + "shortcuts.panCameraNote": "WASD 水平移动相机,QE 垂直移动。", + "shortcuts.pasteSelection": "粘贴选中", + "shortcuts.pasteSelectionNote": "将复制或剪切的物品放置在光标位置。", + "shortcuts.redo": "重做", + "shortcuts.rotateFreely": "在光标下自由旋转", + "shortcuts.rotateFreelyNote": "Cmd/Ctrl + Shift + 右键拖动可自由旋转对象。", + "shortcuts.rotateItemOrToggleDoor": "旋转物品或切换门", + "shortcuts.rotateMultiSelection": "旋转多选中", + "shortcuts.rotateMultiSelectionNote": "先按 R 再按 T 进入旋转模式,然后拖动旋转选中范围。", + "shortcuts.rotateUnderCursor": "在光标下旋转", + "shortcuts.rotateUnderCursorNote": "Cmd/Ctrl + 右键拖动可旋转光标下的对象。", + "shortcuts.selectNextLevel": "选择当前建筑的下一层", + "shortcuts.selectPreviousLevel": "选择当前建筑的上一层", + "shortcuts.selection": "选择", + "shortcuts.switchToBuildMode": "切换到构建模式", + "shortcuts.switchToDeleteMode": "切换到删除模式", + "shortcuts.switchToFurnishLayer": "切换到家具层", + "shortcuts.switchToFurnishPhase": "切换到家具阶段", + "shortcuts.switchToSelectMode": "切换到选择模式", + "shortcuts.switchToSitePhase": "切换到场地阶段", + "shortcuts.switchToStructurePhase": "切换到结构阶段", + "shortcuts.switchToZonesLayer": "切换到区域层", + "shortcuts.toggleSidebar": "切换侧边栏", + "shortcuts.undo": "撤销", + "shortcuts.ungroupSelection": "取消编组", + "shortcuts.ungroupSelectionNote": "将所选编组拆分为独立对象。", + "sidebar.build": "构建", + "sidebar.file": "文件", + "sidebar.items": "物品", + "sidebar.scene": "场景", + "sidebar.settings": "设置", + "sidebar.site": "场地", + "site.addLevel": "添加楼层", + "site.area": "面积", + "site.baseElevation": "基准标高", + "site.building": "建筑", + "site.cameraSnapshot": "相机快照", + "site.capture": "捕获", + "site.clearSelection": "清除选择", + "site.delete": "删除", + "site.duplicateLevelWithOptions": "带选项复制楼层", + "site.furnish": "家具", + "site.guide": "引导", + "site.hide": "隐藏", + "site.level": "楼层", + "site.noBuildingsYet": "暂无建筑", + "site.noLevelsYet": "暂无楼层", + "site.perimeter": "周长", + "site.propertyLine": "地界线", + "site.show": "显示", + "site.site": "场地", + "site.structure": "结构", + "site.uploadScan": "上传扫描/平面图", + "site.uploadingWithProgress": "正在上传 {type}... {progress}%", + "site.untitled": "未命名", + "site.levels": "楼层", + "site.addPoint": "添加点", + "site.axisX": "X", + "site.axisZ": "Z", + "site.camera.viewSnapshot": "查看快照", + "site.camera.updateSnapshot": "更新快照", + "site.camera.takeSnapshot": "拍摄快照", + "site.camera.clearSnapshot": "清除快照", + "site.objectsSelected": "已选择 {count} 个对象", + "site.reference.capture": "捕获", + "site.reference.guide": "引导图", + "site.nodeType.wall": "墙体", + "site.nodeType.fence": "围栏", + "site.nodeType.item": "物件", + "site.nodeType.slab": "楼板", + "site.nodeType.ceiling": "天花板", + "site.nodeType.roof": "屋顶", + "site.nodeType.roofSegment": "屋顶段", + "site.zones": "区域", + "snapshot.area": "区域", + "snapshot.capture": "捕获", + "snapshot.capturing": "捕获中", + "snapshot.closeCapture": "关闭捕获模式", + "snapshot.dragArea": "拖动选择要捕获的区域", + "snapshot.escToCancel": "Esc取消", + "snapshot.saved": "已保存", + "snapshot.standard": "标准", + "snapshot.viewport": "视口", + "structureTools.ceiling": "天花板", + "structureTools.column": "立柱", + "structureTools.door": "门", + "structureTools.duct": "风管", + "structureTools.ductFitting": "风管配件", + "structureTools.dwvPipe": "DWV 管", + "structureTools.elevator": "电梯", + "structureTools.fence": "围栏", + "structureTools.gableRoof": "山墙屋顶", + "structureTools.hvacUnit": "暖通设备", + "structureTools.lineset": "冷媒管", + "structureTools.liquidLine": "液管", + "structureTools.pipeFitting": "管件", + "structureTools.register": "出风口", + "structureTools.shelf": "搁板", + "structureTools.slab": "楼板", + "structureTools.spawnPoint": "生成点", + "structureTools.stairs": "楼梯", + "structureTools.trap": "存水弯", + "structureTools.wall": "墙体", + "structureTools.window": "窗", + "structureTools.zone": "区域", + "toolbar.cutaway": "剖面", + "toolbar.fullHeight": "全高", + "toolbar.low": "低", + "toolbar.up": "高", + "toolbar.walls": "墙体", + "continuation.cabinet.continuous": "连续排布", + "continuation.cabinet.single": "单个柜体", + "continuation.canopy.continuous": "连续雨棚", + "continuation.canopy.single": "单个雨棚", + "continuation.fence.continuous": "连续", + "continuation.fence.curved": "弯曲围栏", + "continuation.fence.single": "单段围栏", + "continuation.point.once": "放置一次", + "continuation.point.repeat": "放置多个", + "continuation.wall.room": "房间(自动闭合)", + "continuation.wall.single": "单段墙", + "tools.ceiling": "天花板", + "tools.column": "柱子", + "tools.door": "门", + "tools.elevator": "电梯", + "tools.fence": "围栏", + "tools.gableRoof": "山墙屋顶", + "tools.shelf": "架子", + "tools.slab": "楼板", + "tools.spawnPoint": "出生点", + "tools.stairs": "楼梯", + "tools.wall": "墙", + "tools.window": "窗", + "tools.zone": "区域", + "treeActions.cameraSnapshot": "相机快照", + "treeActions.clearSelection": "清除选择", + "treeActions.clearSnapshot": "清除快照", + "treeActions.hide": "隐藏", + "treeActions.show": "显示", + "treeActions.takeSnapshot": "拍摄快照", + "treeActions.updateSnapshot": "更新快照", + "treeActions.viewSnapshot": "查看快照", + "viewer.building": "建筑", + "viewer.belowCount": "下方 {count} 层", + "viewer.camera": "相机", + "viewer.collapseSidebar": "收起侧边栏", + "viewer.couldNotAddGuideImage": "无法添加该参考图。", + "viewer.crispEdges": "清晰不透明的边缘线", + "viewer.cutaway": "剖面", + "viewer.deleteGuideImage": "删除参考图", + "viewer.deleteScan": "删除扫描", + "viewer.display": "显示", + "viewer.displaySettings": "显示设置", + "viewer.edges": "边缘线", + "viewer.expandSidebar": "展开侧边栏", + "viewer.exploded": "爆炸", + "viewer.faceOfStud": "立柱面", + "viewer.faceOfStudDetail": "测量到墙内立柱的内侧。", + "viewer.faintOutline": "仅主要转折处的淡轮廓", + "viewer.fileTooLarge": "文件过大。最大支持 200 MB。", + "viewer.finishedFaces": "成品面", + "viewer.finishedFacesDetail": "测量到成品墙的内表面。", + "viewer.flatAndFast": "快速平坦 — 无环境光遮蔽", + "viewer.floorplanAnnotations": "平面图标注", + "viewer.floorplanDefault": "默认", + "viewer.floorplanDefaultDetail": "隐藏非必要的标注与尺寸。", + "viewer.floorplanExpert": "高级", + "viewer.floorplanExpertDetail": "显示所有尺寸、标记和结构标注。", + "viewer.floorplanMode": "平面图模式", + "viewer.fullAO": "完整环境光遮蔽", + "viewer.fullHeight": "全高", + "viewer.grid": "网格", + "viewer.gridSnap": "网格吸附", + "viewer.guideImageDefault": "参考图 {index}", + "viewer.guideImages": "参考图", + "viewer.guideImageSettings": "参考图设置", + "viewer.guideImagesOnThisLevel": "此楼层有 {count} 张参考图", + "viewer.guideImagesOnThisLevel_plural": "此楼层有 {count} 张参考图", + "viewer.guides": "参考图", + "viewer.guidesState": "参考图:{state}", + "viewer.hidden": "隐藏", + "viewer.hideReferenceFloor": "隐藏参考楼层", + "viewer.imperial": "英制 (ft)", + "viewer.levels": "楼层", + "viewer.levelsWithMode": "楼层:{mode}", + "viewer.low": "低", + "viewer.magneticSnap": "磁性吸附", + "viewer.manual": "手动", + "viewer.measurements": "尺寸标注", + "viewer.measurements3d": "3D 尺寸标注", + "viewer.meters": "米", + "viewer.metric": "公制 (m)", + "viewer.millimeters": "毫米", + "viewer.automaticDimensions": "自动尺寸", + "viewer.manualDimensions": "手动尺寸", + "viewer.openingMarks": "开口标记", + "viewer.structuralGrids": "结构网格", + "viewer.roomLabels": "房间标签", + "viewer.stairAnnotations": "楼梯标注", + "viewer.noEdgeLines": "无边缘线", + "viewer.noGuideImagesOnLevel": "此楼层暂无参考图。", + "viewer.noLowerFloor": "下方没有可参考的楼层。", + "viewer.noScansOnLevel": "此楼层暂无扫描。", + "viewer.off": "关", + "viewer.off_edge": "关闭", + "viewer.on": "开", + "viewer.opacity": "不透明度", + "viewer.hideReferenceRow": "隐藏{subject}", + "viewer.showReferenceRow": "显示{subject}", + "viewer.deleteReferenceRow": "删除{subject}", + "viewer.openProjectBeforeUpload": "请先打开项目再上传扫描。", + "viewer.orbitLeft": "左旋转", + "viewer.orbitRight": "右旋转", + "viewer.orthographic": "正交", + "viewer.perspective": "透视", + "viewer.preview": "预览", + "viewer.previewMode": "预览模式", + "viewer.referenceFloorSettings": "参考楼层设置", + "viewer.referenceFloorWithLevel": "参考楼层:{level}", + "viewer.referenceSettings": "参考设置", + "viewer.referencesState": "参考:{state}", + "viewer.render": "渲染", + "viewer.rendered": "渲染", + "viewer.riserDiagram": "梯段示意图", + "viewer.scanDefault": "扫描 {index}", + "viewer.scanSettings": "扫描设置", + "viewer.scanUploadUnavailable": "扫描上传不可用。", + "viewer.scans": "扫描", + "viewer.scansOnThisLevel": "此楼层有 {count} 个扫描", + "viewer.scansOnThisLevel_plural": "此楼层有 {count} 个扫描", + "viewer.scansState": "扫描:{state}", + "viewer.sceneTheme": "场景主题", + "viewer.shadows": "阴影", + "viewer.showReferenceFloor": "显示参考楼层", + "viewer.soft": "柔和", + "viewer.solid": "纯色", + "viewer.solo": "单独", + "viewer.stack": "堆叠", + "viewer.strong": "锐利", + "viewer.topView": "顶视图", + "viewer.translucent": "半透明", + "viewer.units": "单位", + "viewer.uploadGlbOrImage": "请上传 .glb/.gltf 扫描文件或图片。", + "viewer.uploadScanOrGuide": "上传扫描或参考图", + "viewer.uploading": "上传中", + "viewer.viewMode2D": "2D", + "viewer.viewMode3D": "3D", + "viewer.viewModeSplit": "分屏", + "viewer.visible": "可见", + "viewer.walkthrough": "导览", + "viewer.wallsWithMode": "墙体:{mode}", + "viewer.wallCenterline": "墙体中线", + "viewer.wallCenterlineDetail": "测量到墙体的结构中心。", + "viewer.wallDimensions": "墙体尺寸", + "viewer.spawnPointHint": "从构建选项卡放置出生点来控制导览开始位置。", + "zone.addOne": "添加", + "zone.noZonesOnLevel": "此楼层暂无区域。", + "zone.selectLevelToView": "选择楼层以查看和创建区域", + "nodes.ceiling.toolHints.trace": "绘制天花板轮廓", + "nodes.ceiling.toolHints.finish": "完成天花板", + "nodes.item.label": "物品", + "nodes.item.description": "放置在场景中的物品。", + "nodes.item.position": "位置", + "nodes.item.rotation": "旋转", + "nodes.item.scale": "缩放", + "nodes.item.uniformScale": "均匀缩放", + "nodes.item.info": "信息", + "nodes.item.collections": "集合", + "nodes.item.manageCollections": "管理集合…", + "nodes.site.label": "场地", + "nodes.site.description": "包含建筑的场地。", + "nodes.dormer.toolHints.place": "在屋顶上放置老虎窗", + "nodes.dormer.toolHints.rotateGhost": "旋转虚影", + "nodes.chimney.toolHints.place": "在屋顶上放置烟囱", + "nodes.ridgeVent.toolHints.place": "在屋顶上放置屋脊通风口", + "nodes.shelf.toolHints.place": "放置架子", + "nodes.skylight.toolHints.place": "在屋顶上放置天窗", + "nodes.spawn.toolHints.place": "放置出生点", + "nodes.spawn.spawnPoint": "出生点", + "nodes.spawn.position": "位置", + "nodes.spawn.facing": "朝向", + "nodes.spawn.yaw": "偏航", + "nodes.zone.toolHints.place": "放置区域", + "nodes.block.actions": "操作", + "nodes.block.addSlot": "添加槽", + "nodes.block.blockAccent": "块体强调色", + "nodes.block.defaultMaterial": "默认材质", + "nodes.block.editModeSlotActions": "进入编辑模式以使用槽操作", + "nodes.block.editModeAssignFaces": "进入编辑模式以分配面", + "nodes.block.faceSelectSwitch": "切换到面选择 (3)", + "nodes.block.fallbackTitle": "块", + "nodes.block.noFacesSelected": "未选中任何面", + "nodes.block.readOnly": "场景为只读", + "nodes.block.mixedSlots": "{count} 个面 · 混合槽", + "nodes.block.singleSlot": "{count} 个{faceLabel} · {slotLabel}", + "nodes.block.face": "面", + "nodes.block.faces": "面", + "nodes.block.position": "位置", + "nodes.block.selectFacesFirst": "请先选择一个或多个面", + "nodes.block.slots": "槽", + "nodes.block.slotApplied": "已使用强调色材质将 {slot} 应用到 {count} 个{faceLabel}。使用 Paint (P) 可替换。", + "nodes.block.slotApplyAria": "将 {slot} 应用到所选面", + "nodes.block.slotDeleteAria": "删除 {slot} 槽", + "nodes.block.slotRenameAria": "重命名 {slot} 槽", + "nodes.block.slotDeleteTitle": "删除材质槽并对其面使用主体", + "nodes.block.unpainted": "未涂装", + "nodes.block.toolHints.place": "放置块", + "nodes.column.toolHints.place": "放置柱子", + "nodes.cupola.cupola": "屋顶通风塔", + "nodes.cupola.dome": "圆顶", + "nodes.cupola.finial": "顶尖装饰", + "nodes.cupola.noFinial": "无顶尖装饰", + "nodes.cupola.pyramid": "金字塔顶", + "nodes.cupola.toolHints.place": "在屋顶上放置屋顶通风塔", + "nodes.door.toolHints.flipSide": "翻转侧面", + "nodes.downspout.toolHints.highlightOutlet": "高亮出水口", + "nodes.downspout.toolHints.drop": "从出水口放下水落管", + "nodes.elevator.toolHints.place": "放置电梯", + "nodes.eyebrowVent.toolHints.place": "在屋顶上放置老虎窗通风口", + "nodes.eyebrowVent.fallbackTitle": "老虎窗通风口", + "nodes.eyebrowVent.style": "样式", + "nodes.eyebrowVent.scoop": "勺形", + "nodes.eyebrowVent.halfRound": "半圆", + "nodes.eyebrowVent.slantBox": "斜箱", + "nodes.eyebrowVent.louvers": "百叶", + "nodes.eyebrowVent.backHeight": "背面高度", + "nodes.eyebrowVent.dimensions": "尺寸", + "nodes.gutter.toolHints.place": "在屋顶檐口放置檐沟", + "nodes.item.toolHints.place": "放置物品", + "nodes.item.toolHints.cycleSnap": "循环切换捕捉模式", + "nodes.roof.toolHints.setFootprint": "设置屋顶底部投影", + "nodes.roof.toolHints.placement": "放置方式", + "nodes.roof.toolHints.placementAuto": "放置方式:自动", + "nodes.roof.toolHints.placementGround": "放置方式:地面", + "nodes.roof.toolHints.placementRoof": "放置方式:屋顶", + "nodes.roof.toolHints.rotateDirection": "旋转屋顶方向 90°", + "nodes.spawn.toolHints.rotate": "旋转出生点", + "nodes.stair.toolHints.place": "放置楼梯", + "nodes.structuralGrid.toolHints.start": "开始网格轴", + "nodes.structuralGrid.toolHints.finish": "完成网格轴", + "nodes.structuralGrid.toolHints.bypassSnap": "绕过捕捉", + "nodes.turbineVent.toolHints.place": "在屋顶上放置涡轮通风口", + "nodes.turbineVent.fallbackTitle": "涡轮通风口", + "nodes.turbineVent.style": "样式", + "nodes.turbineVent.globe": "球形", + "nodes.turbineVent.cylinder": "圆柱", + "nodes.turbineVent.dimensions": "尺寸", + "nodes.turbineVent.neckHeight": "颈部高度", + "nodes.turbineVent.vanes": "叶片数", + "nodes.turbineVent.motion": "运动", + "nodes.turbineVent.pause": "暂停", + "nodes.turbineVent.play": "播放", + "nodes.turbineVent.spinSpeed": "旋转速度", + "nodes.window.toolHints.flipSide": "翻转侧面", + "nodes.cabinet.addChimney": "添加烟道罩", + "nodes.cabinet.addCompartment": "添加隔间", + "nodes.cabinet.addWallCabinet": "添加吊柜", + "nodes.cabinet.cabinetType": "柜体类型", + "nodes.cabinet.carcassHeight": "柜体高度", + "nodes.cabinet.close": "关闭", + "nodes.cabinet.closeCabinet": "关闭柜门", + "nodes.cabinet.compartments": "隔间", + "nodes.cabinet.dimensions": "尺寸", + "nodes.cabinet.fallbackTitle": "组合柜", + "nodes.cabinet.fillToCeiling": "填充至天花板", + "nodes.cabinet.finish": "饰面", + "nodes.cabinet.frontOverlay.full": "全覆盖", + "nodes.cabinet.frontOverlay.inset": "内嵌", + "nodes.cabinet.frontStyle.raisedArch": "拱形凸起", + "nodes.cabinet.frontStyle.shaker": "平板镶框", + "nodes.cabinet.frontStyle.slab": "平板", + "nodes.cabinet.fronts": "门板", + "nodes.cabinet.fronts.mounting": "安装方式", + "nodes.cabinet.fronts.revealGap": "门缝间隙", + "nodes.cabinet.fronts.style": "风格", + "nodes.cabinet.handle.bar": "条形", + "nodes.cabinet.handle.cutout": "挖手", + "nodes.cabinet.handle.hole": "孔", + "nodes.cabinet.handle.knob": "旋钮", + "nodes.cabinet.handle.none": "无", + "nodes.cabinet.handlePosition.auto": "自动", + "nodes.cabinet.handlePosition.center": "居中", + "nodes.cabinet.handlePosition.top": "顶部", + "nodes.cabinet.handles": "拉手", + "nodes.cabinet.handles.position": "位置", + "nodes.cabinet.handles.style": "风格", + "nodes.cabinet.open": "开", + "nodes.cabinet.openAnimation": "开关动画", + "nodes.cabinet.openCabinet": "打开柜门", + "nodes.cabinet.play": "播放", + "nodes.cabinet.playAnimation": "播放动画", + "nodes.cabinet.planningChecks": "规划检查", + "nodes.cabinet.presets": "预设", + "nodes.cabinet.reflowRejected": "本段内无可用空间。无法通过缩小地柜来容纳此项目。", + "nodes.cabinet.removeWallCabinet": "移除吊柜", + "nodes.cabinet.standardWidth": "标准宽度", + "nodes.cabinet.stop": "停止", + "nodes.cabinet.stopAnimation": "停止动画", + "nodes.cabinet.tier.base": "地柜", + "nodes.cabinet.tier.tall": "高柜", + "nodes.cabinet.topCeiling": "顶部 / 天花板", + "nodes.cabinet.topDepth": "顶部深度", + "nodes.cabinet.topFinish.none": "无", + "nodes.cabinet.topFinish.topCabinet": "顶柜", + "nodes.cabinet.topFinish.trim": "封边 / 吊顶", + "nodes.cabinet.topHeight": "顶部高度", + "nodes.cabinet.toolHints.place": "放置柜体", + "nodes.cabinet.toolHints.placementType": "放置类型", + "nodes.cabinet.toolHints.typeCabinet": "类型:柜体", + "nodes.cabinet.toolHints.typeIsland": "类型:岛台", + "nodes.constructionDimension.actions": "操作", + "nodes.constructionDimension.centerMark": "中心标记", + "nodes.constructionDimension.datumPolicy": "基准策略", + "nodes.constructionDimension.datumPolicyOptions.centerline": "中心线", + "nodes.constructionDimension.datumPolicyOptions.finishFace": "完成面", + "nodes.constructionDimension.datumPolicyOptions.structuralFace": "结构面", + "nodes.constructionDimension.datumPolicyOptions.wallFace": "墙面", + "nodes.constructionDimension.defaultFoundationDimensionName": "基础标注", + "nodes.constructionDimension.dimension": "标注", + "nodes.constructionDimension.drawingCoordination": "图纸协同", + "nodes.constructionDimension.extensionGap": "延伸间隙", + "nodes.constructionDimension.extensionOvershoot": "延伸超长", + "nodes.constructionDimension.fallbackTitle": "施工标注", + "nodes.constructionDimension.featureCount": "要素数量", + "nodes.constructionDimension.foundationController": "基础控制器", + "nodes.constructionDimension.imperialPrecision": "英制精度", + "nodes.constructionDimension.imperialPrecisionOptions.1": "整英寸", + "nodes.constructionDimension.imperialPrecisionOptions.1/2": "1/2 英寸", + "nodes.constructionDimension.imperialPrecisionOptions.1/4": "1/4 英寸", + "nodes.constructionDimension.imperialPrecisionOptions.1/8": "1/8 英寸", + "nodes.constructionDimension.imperialPrecisionOptions.1/16": "1/16 英寸", + "nodes.constructionDimension.linkedDimensionsNote": "关联标注将复用控制器的关联锚点,并随其同步更新。", + "nodes.constructionDimension.metricNotation": "公制单位", + "nodes.constructionDimension.metricNotationOptions.meters": "米", + "nodes.constructionDimension.metricNotationOptions.millimeters": "毫米", + "nodes.constructionDimension.mode": "模式", + "nodes.constructionDimension.modeOptions.angular": "角度", + "nodes.constructionDimension.modeOptions.arc-length": "弧长", + "nodes.constructionDimension.modeOptions.center-mark": "中心标记", + "nodes.constructionDimension.modeOptions.chord": "弦长", + "nodes.constructionDimension.modeOptions.coordinate": "坐标", + "nodes.constructionDimension.modeOptions.diameter": "直径", + "nodes.constructionDimension.modeOptions.linear": "线性", + "nodes.constructionDimension.modeOptions.radius": "半径", + "nodes.constructionDimension.notation": "文本", + "nodes.constructionDimension.noFoundationDimensions": "无基础标注", + "nodes.constructionDimension.presentation": "{drawing} 显示方式", + "nodes.constructionDimension.presentationOptions.controlled": "由基础控制", + "nodes.constructionDimension.presentationOptions.omit": "省略", + "nodes.constructionDimension.presentationOptions.shown": "显示", + "nodes.constructionDimension.prefix": "前缀", + "nodes.constructionDimension.primaryDrawing": "主图纸", + "nodes.constructionDimension.standards": "标准", + "nodes.constructionDimension.suffix": "后缀", + "nodes.constructionDimension.suppressedSegments": "{drawing} 隐藏段", + "nodes.constructionDimension.suppressedSegmentsNote": "段号从 1 起算,仅作用于当前图纸视图。", + "nodes.constructionDimension.suppressedSegmentsPlaceholder": "例如 2, 4", + "nodes.constructionDimension.terminator": "端点符号", + "nodes.constructionDimension.terminatorOptions.architectural-tick": "建筑斜线", + "nodes.constructionDimension.terminatorOptions.dot": "圆点", + "nodes.constructionDimension.terminatorOptions.filled-arrow": "实心箭头", + "nodes.constructionDimension.terminatorOptions.open-arrow": "空心箭头", + "nodes.constructionDimension.textOverride": "文字覆盖", + "nodes.constructionDimension.textOverridePlaceholder": "使用实测值", + "nodes.constructionDimension.textPosition": "文字位置", + "nodes.constructionDimension.textPositionOptions.above": "线上方", + "nodes.constructionDimension.textPositionOptions.centered": "居中", + "nodes.constructionDimension.toolHints.pickWitness": "选择参考点", + "nodes.constructionDimension.toolHints.finishWitnesses": "完成多点参考", + "nodes.constructionDimension.toolHints.placeLine": "需要时放置尺寸线", + "nodes.constructionDimension.toolHints.removeLast": "移除上一个参考点", + "nodes.ductFitting.toolHints.place": "放置配件", + "nodes.ductFitting.toolHints.snap": "吸附到管线", + "nodes.ductSegment.toolHints.start": "开始管段", + "nodes.ductSegment.toolHints.placeContinue": "放置并继续", + "nodes.ductSegment.toolHints.vertical": "垂直 ↕,点击放置", + "nodes.ductSegment.toolHints.diameter": "风管直径 减小 / 增大", + "nodes.ductSegment.toolHints.trunk": "圆形 / 方形主管", + "nodes.ductSegment.toolHints.height": "天花板 / 地板高度", + "nodes.ductTerminal.toolHints.place": "放置出风口", + "nodes.ductTerminal.toolHints.mount": "安装:地板 / 天花板 / 墙", + "nodes.ductTerminal.toolHints.rotate": "旋转 ±45°(地板 / 天花板)", + "nodes.hvacEquipment.toolHints.place": "放置设备", + "nodes.leanToExtension.toolHints.place": "放置雨棚或设置下一个连续点", + "nodes.leanToExtension.toolHints.rotateFlip": "旋转或翻转连续侧", + "nodes.leanToExtension.toolHints.cycleStyle": "循环切换 单坡 / 山墙 / 蝶形", + "nodes.lineset.toolHints.start": "开始冷媒管", + "nodes.lineset.toolHints.place": "放置(锁定 45°)", + "nodes.lineset.toolHints.vertical": "垂直 ↕,点击放置", + "nodes.liquidLine.toolHints.start": "开始液管", + "nodes.liquidLine.toolHints.place": "放置(锁定 45°)", + "nodes.liquidLine.toolHints.vertical": "垂直 ↕,点击放置", + "nodes.liquidLine.toolHints.follow": "跟随:追踪冷媒管", + "nodes.pipeFitting.toolHints.place": "放置管件", + "nodes.pipeFitting.toolHints.snap": "吸附到管线", + "nodes.pipeSegment.toolHints.start": "开始管段", + "nodes.pipeSegment.toolHints.place": "放置(污水坡降 ¼″/英尺)", + "nodes.pipeSegment.toolHints.wasteVent": "污水 / 通气", + "nodes.pipeSegment.toolHints.size": "管径 减小 / 增大", + "nodes.pipeSegment.toolHints.vertical": "垂直立管 ↕,点击放置", + "nodes.pipeTrap.toolHints.place": "放置存水弯", + "paint.material.defaultName": "材质 {n}", + "paint.material.copySuffix": "{name} 副本", + "paint.material.usedByOne": "被 1 个部件使用", + "paint.material.usedByOther": "被 {count} 个部件使用", + "paint.material.paintWith": "使用此材质绘制", + "paint.erase": "擦除", + "paint.resetAll": "全部重置", + "paint.sceneMaterials": "场景材质", + "paint.addMaterial": "添加材质", + "paint.noCustomMaterials": "尚无自定义材质 — 点击 + 添加。", + "materialPicker.source.pascal": "Pascal", + "materialPicker.source.workspace": "工作区", + "materialPicker.newMaterial": "新建材质", + "material.color": "颜色", + "material.roughness": "粗糙度", + "material.metalness": "金属度", + "material.opacity": "不透明度", + "material.side": "面", + "material.side.front": "正面", + "material.side.back": "背面", + "material.side.double": "双面", + "materials.colors": "颜色", + "materials.stone": "石材", + "materials.brick": "砖", + "materials.tile": "瓷砖", + "materials.wallpaper": "墙纸", + "materials.concrete": "混凝土", + "materials.metal": "金属", + "materials.plastic": "塑料", + "materials.fabric": "织物", + "materials.carpet": "地毯", + "materials.leather": "皮革", + "materials.glass": "玻璃", + "common.mixed": "混合", + "terrain.verb.raise": "抬升", + "terrain.verb.lower": "降低", + "terrain.verb.flatten": "平整", + "terrain.verb.smooth": "平滑", + "terrain.hint.raise": "拖动以抬升地面。单次最多抬升 {metres} 米 — 松开后再次拖动以继续。", + "terrain.hint.lower": "拖动以降低地面。单次最多降低 {metres} 米。", + "terrain.hint.flatten": "拖动以将地面平整至目标高度。不会过度平整。", + "terrain.hint.smooth": "拖动以柔化坡度并去除山脊。平坦地面保持平坦。", + "terrain.brush.size": "大小", + "terrain.brush.strength": "强度", + "terrain.brush.softness": "柔软度", + "terrain.brush.round": "圆形", + "terrain.brush.square": "方形", + "terrain.flatten.target": "目标", + "terrain.flatten.pickTarget": "从地面选取目标高度", + "terrain.flatten.samplingHint": "点击地面以将其高度作为目标。", + "terrain.flatten.noTargetHint": "尚无目标 — 首次点击将采样其下方的地面。", + "terrain.flatten.targetHint": "每次平整笔刷都会向此高度平整。", + "terrain.levelLot": "平整地块", + "terrain.clearTerrain": "清除地形", + "materials.concrete-drywall.label": "已处理石膏板", + "materials.concrete-plaster.label": "彩色石膏", + "materials.concrete-plate.label": "混凝土板", + "materials.concrete-polished.label": "抛光混凝土", + "materials.concrete-raw.label": "素混凝土", + "materials.concrete-stucco.label": "白灰泥", + "materials.fabric-boucle.label": "圈圈纱", + "materials.fabric-cotton.label": "棉", + "materials.fabric-linen.label": "亚麻", + "materials.fabric-suede.label": "麂皮", + "materials.fabric-velvet.label": "天鹅绒", + "materials.fabric-wool.label": "羊毛", + "materials.flooring-agedbrick.label": "做旧砖", + "materials.flooring-ceramic53.label": "陶瓷马赛克", + "materials.flooring-darkceramic22.label": "深色做旧瓷砖", + "materials.flooring-garagedoor.label": "车库面板", + "materials.flooring-greenlabradorite.label": "绿拉长石", + "materials.flooring-greenquartzitea.label": "绿石英岩 A", + "materials.flooring-ground13.label": "土地面", + "materials.flooring-lightceramic24.label": "浅色做旧瓷砖", + "materials.flooring-pooltiles.label": "泳池砖", + "materials.flooring-rusticbrick.label": "复古砖", + "materials.flooring-statuarettowhite.label": "白色雕像石", + "materials.flooring-terrazzo19.label": "水磨石", + "materials.flooring-tile20.label": "马赛克砖", + "materials.flooring-tile68.label": "图案砖", + "materials.flooring-tile79.label": "石材砖", + "materials.flooring-tile85a.label": "方砖", + "materials.flooring-tile86.label": "赤陶砖", + "materials.flooring-tiles3.label": "棋盘砖", + "materials.flooring-tiles4.label": "网格砖", + "materials.flooring-wallstone1.label": "石墙", + "materials.flooring-weatheredbrick.label": "风化砖", + "materials.flooring-woodenceramic2.label": "木纹瓷砖 2", + "materials.flooring-woodenceramic3.label": "木纹瓷砖 3", + "materials.flooring-woodparquet76.label": "木拼花", + "materials.leather-black.label": "黑皮革", + "materials.leather-calf.label": "小牛皮革", + "materials.metal-brass.label": "黄铜", + "materials.metal-chrome.label": "铬", + "materials.metal-copper.label": "铜", + "materials.metal-polished.label": "抛光金属", + "materials.metal-steel.label": "拉丝钢", + "materials.preset-aubergine.label": "茄紫", + "materials.preset-beige.label": "米色", + "materials.preset-berry.label": "莓果", + "materials.preset-blush.label": "腮红", + "materials.preset-brickred.label": "砖红", + "materials.preset-burntorange.label": "焦橙", + "materials.preset-charcoal.label": "炭灰", + "materials.preset-clay.label": "陶土色", + "materials.preset-cream.label": "奶油色", + "materials.preset-deepteal.label": "深青", + "materials.preset-dustyrose.label": "灰玫瑰", + "materials.preset-espresso.label": "浓缩咖啡", + "materials.preset-forest.label": "森林绿", + "materials.preset-glass.label": "玻璃", + "materials.preset-gold.label": "金色", + "materials.preset-greige.label": "灰咖", + "materials.preset-lavender.label": "薰衣草紫", + "materials.preset-lightgrey.label": "浅灰", + "materials.preset-metal.label": "金属", + "materials.preset-midgrey.label": "中灰", + "materials.preset-mint.label": "薄荷绿", + "materials.preset-mustard.label": "芥末黄", + "materials.preset-navy.label": "海军蓝", + "materials.preset-nearblack.label": "近黑", + "materials.preset-ochre.label": "赭石", + "materials.preset-olive.label": "橄榄绿", + "materials.preset-oxblood.label": "牛血红", + "materials.preset-paleteal.label": "浅青", + "materials.preset-paleyellow.label": "浅黄", + "materials.preset-peach.label": "蜜桃色", + "materials.preset-petal.label": "花瓣粉", + "materials.preset-plum.label": "梅紫", + "materials.preset-powderblue.label": "粉蓝", + "materials.preset-rose.label": "玫瑰粉", + "materials.preset-royalblue.label": "宝蓝", + "materials.preset-sage.label": "鼠尾草绿", + "materials.preset-sand.label": "沙色", + "materials.preset-sky.label": "天空蓝", + "materials.preset-slateblue.label": "石板蓝", + "materials.preset-softblue.label": "柔蓝", + "materials.preset-softwhite.label": "柔白", + "materials.preset-tan.label": "棕褐", + "materials.preset-taupe.label": "灰褐", + "materials.preset-teal.label": "青绿", + "materials.preset-terracotta.label": "赤陶色", + "materials.preset-tomato.label": "番茄红", + "materials.preset-white.label": "白色", + "materials.roof-classicshingles.label": "经典木瓦", + "materials.roof-claytiles.label": "陶土瓦", + "materials.roof-terracottatiles.label": "赤陶瓦", + "materials.roof-weatheredshingles.label": "风化木瓦", + "materials.wood-finewood27.label": "细木 27", + "materials.wood-floorplank1.label": "地板木 1", + "materials.wood-hungarianparquet10.label": "匈牙利拼花 10", + "materials.wood-hungarianparquet2.label": "匈牙利拼花 2", + "materials.wood-squareparquet21.label": "方形拼花 21", + "materials.wood-squareparquet23.label": "方形拼花 23", + "materials.wood-woodenparquet11.label": "木质拼花 11", + "materials.wood-woodfine1.label": "细木纹 1", + "materials.wood-woodfine11.label": "细木纹 11", + "materials.wood-woodfine13.label": "细木纹 13", + "materials.wood-woodfine2.label": "细木纹 2", + "materials.wood-woodfine22.label": "细木纹 22", + "materials.wood-woodfine24.label": "细木纹 24", + "materials.wood-woodparquet121.label": "木拼花 121", + "materials.wood-woodparquet14.label": "木拼花 14", + "materials.wood-woodparquet56.label": "木拼花 56", + "materials.wood-woodparquet65.label": "木拼花 65", + "materials.wood-woodparquet99.label": "木拼花 99", + "materials.wood-woodplank19.label": "木板 19", + "materials.wood-woodplank48.label": "木板 48", + "common.placement": "布置", + "common.construction": "构造", + "common.drainage": "排水", + "common.transform": "变换", + "common.connections": "连接", + "common.fitting": "配件", + "common.advanced": "高级", + "common.appearance": "外观", + "common.mounting": "安装", + "nodes.chimney.body": "主体", + "nodes.door.frame": "门框", + "nodes.dormer.dormerRoof": "老虎窗屋顶", + "nodes.downspout.hardware": "五金件", + "nodes.ductSegment.air": "气流", + "nodes.ductTerminal.terminal": "末端", + "nodes.ductTerminal.face": "面板", + "nodes.ductTerminal.collar": "接口环", + "nodes.fence.structure": "结构", + "nodes.gutter.profile": "型材", + "nodes.gutter.endCaps": "端盖", + "nodes.gutter.hangers": "吊架", + "nodes.hvacEquipment.equipment": "设备", + "nodes.hvacEquipment.cabinet": "机箱", + "nodes.hvacEquipment.supply": "送风", + "nodes.hvacEquipment.return": "回风", + "nodes.leanToExtension.size": "尺寸", + "nodes.leanToExtension.connection": "连接", + "nodes.leanToExtension.structure": "结构", + "nodes.lineset.lines": "管线", + "nodes.lineset.insulation": "保温", + "nodes.liquidLine.line": "管线", + "nodes.pipeTrap.trap": "存水弯", + "nodes.shelf.topology": "结构", + "nodes.skylight.type": "类型", + "nodes.skylight.curb": "边框", + "nodes.skylight.opening": "开启", + "nodes.skylight.lantern": "穹顶", + "nodes.solarPanel.grid": "阵列", + "nodes.solarPanel.panelDimensions": "板尺寸", + "nodes.solarPanel.frame": "边框", + "editor.deleteWithContents": "删除(含内容)", + "editor.saveToCatalog": "保存到目录", + "editor.replace": "替换", + "editor.replacing": "替换中...", + "editor.lock": "锁定", + "editor.unlock": "解锁", + "editor.setScale": "设置比例", + "editor.editScale": "编辑比例", + "editor.showScale": "显示比例", + "editor.hideScale": "隐藏比例", + "editor.uncalibrated": "未校准", + "editor.scaled": "已校准", + "editor.scaledHidden": "已校准(隐藏)", + "editor.chooseImage": "请选择 PNG、JPEG 或 WebP 图片。", + "editor.couldNotReplaceImage": "无法替换该图片。", + "editor.overlayImageUnavailable": "叠加图片不可用。请替换图片以恢复。", + "editor.clickEndsOfKnownDistance": "在平面上点击已知距离的两端,然后输入实际长度。", + "editor.drawLineOverKnownDimension": "在已知尺寸上画一条线,然后输入实际长度以精确缩放图像。", + "editor.zoomOut": "缩小", + "editor.zoomIn": "放大", + "editor.fitFloorPlan": "适配平面图", + "editor.floor": "楼层", + "editor.alignViewNorth": "对齐到北方", + "editor.topView": "顶视图", + "editor.orbitLeft": "向左旋转", + "editor.orbitRight": "向右旋转", + "editor.camera": "相机", + "editor.visibility": "可见性", + "editor.displaySettings": "显示设置", + "editor.walkthrough": "漫游", + "editor.levelsLabel": "楼层:{mode}", + "editor.wallsLabel": "墙体:{mode}", + "editor.scans": "扫描", + "editor.guides": "参考", + "editor.shadows": "阴影", + "editor.render": "渲染", + "editor.colors": "颜色", + "editor.theme": "主题", + "editor.edges": "边线", + "editor.stacked": "堆叠", + "editor.exploded": "分解", + "editor.solo": "单层", + "editor.manual": "手动", + "editor.fullHeight": "全高", + "editor.cutaway": "剖切", + "editor.low": "矮墙", + "editor.viewer3d": "3D", + "editor.viewer2d": "2D", + "editor.viewerSplit": "分屏", + "editor.perspective": "透视", + "editor.orthographic": "正交", + "editor.solid": "实体", + "editor.rendered": "渲染", + "editor.solidDetail": "平面快速,无环境光遮蔽", + "editor.renderedDetail": "完整环境光遮蔽", + "editor.on": "开", + "editor.off": "关", + "editor.monochrome": "单色", + "editor.colored": "彩色", + "editor.cameraSnapshot": "相机快照", + "editor.takeSnapshot": "拍摄快照", + "editor.updateSnapshot": "更新快照", + "editor.clearSnapshot": "清除快照", + "editor.closeCaptureMode": "关闭捕获模式", + "editor.dwvRiserDiagram": "排水立管图", + "editor.extrusionHeight": "拉伸高度", + "editor.viewerLayout": "查看器布局", + "editor.noZonesOnLevel": "此楼层没有区域。", + "editor.addOne": "新增", + "editor.selectLevelFirst": "请先选择一个楼层", + "nodes.block.toolbar.transform": "变换选中组件(G / R / S)", + "nodes.block.toolbar.vertexSelect": "顶点选择(1)", + "nodes.block.toolbar.edgeSelect": "边选择(2)", + "nodes.block.toolbar.faceSelect": "面选择(3)", + "nodes.block.toolbar.meshOperations": "网格操作", + "nodes.block.toolbar.operations": "操作", + "nodes.block.toolbar.loopCut": "循环切割", + "nodes.block.toolbar.bevel": "倒角", + "nodes.block.toolbar.moveSelection": "移动选择", + "nodes.block.toolbar.rotateSelection": "旋转选择", + "nodes.block.toolbar.extrudeFaces": "挤出所选面", + "nodes.block.toolbar.insetFaces": "内插所选面", + "nodes.block.toolbar.loopCutAndSlide": "循环切割并滑动", + "nodes.block.toolbar.mergeVertices": "合并顶点", + "nodes.block.toolbar.dissolve": "溶解选择", + "nodes.block.toolbar.bevelEdges": "倒角所选边", + "nodes.block.toolbar.finishEdit": "完成编辑模式(Tab)", + "nodes.block.toolbar.selectionAndMore": "选择及其他", + "nodes.block.toolbar.selectionActions": "选择操作", + "nodes.block.toolbar.selectAll": "全选", + "nodes.block.toolbar.invertSelection": "反选", + "nodes.block.toolbar.clearSelection": "清除选择", + "nodes.block.toolbar.xraySelection": "X 光选择", + "nodes.block.toolbar.deleteComponents": "删除组件", + "nodes.block.loopCutCount": "循环切割数量", + "nodes.block.closeLastOperationPanel": "关闭最后一个操作面板", + "nodes.block.adjustLastOp": "调整 {label}(F9)", + "nodes.skylight.glassThickness": "玻璃厚度", + "nodes.skylight.lanternHeight": "穹顶高度", + "nodes.skylight.topScale": "顶部比例", + "nodes.skylight.open": "打开", + "nodes.skylight.openingAngle": "开启角度", + "nodes.skylight.motorHousing": "电机外壳", + "nodes.skylight.trackWidth": "轨道宽度", + "nodes.skylight.cutoutOffset": "开孔偏移", + "nodes.cabinet.baseFlange": "底部法兰", + "nodes.cabinet.shelvesInside": "内部搁板", + "nodes.cabinet.burnersOn": "炉头启用", + "nodes.cabinet.topGrate": "顶部栅格", + "nodes.cabinet.baskets": "篮筐", + "nodes.cabinet.addLeft": "左侧添加", + "nodes.cabinet.addRight": "右侧添加", + "nodes.cabinet.showPlinth": "显示踢脚", + "nodes.cabinet.plinthHeight": "踢脚高度", + "nodes.cabinet.showCountertop": "显示台面", + "nodes.cabinet.countertopHeight": "台面高度", + "nodes.cabinet.countertopDepth": "台面深度", + "nodes.cabinet.seatingOverhang": "座椅悬挑", + "nodes.cabinet.finishedBack": "成品背板", + "nodes.cabinet.waterfallEnds": "瀑布端", + "nodes.cabinet.barCounter": "吧台", + "nodes.cabinet.barHeight": "吧台高度", + "nodes.cabinet.barDepth": "吧台深度", + "nodes.cabinet.sharedPlinthCountertop": "共享踢脚与台面", + "nodes.cabinet.islandAndBar": "岛台与吧台", + "nodes.cabinet.standardDimensions": "标准尺寸", + "nodes.cabinet.appliesStandardDimensions": "将深度、柜体、踢脚和台面厚度应用到该组合。", + "nodes.dormer.addWindow": "添加窗户", + "nodes.dormer.editWindow": "编辑窗户", + "nodes.dormer.moveWindow": "移动窗户", + "nodes.dormer.windowsTitle": "窗户({count})", + "nodes.dormer.noWindows": "没有窗户", + "nodes.dormer.increaseWidth": "增加老虎窗宽度以添加另一扇窗。", + "nodes.zone.architecturalRoom": "建筑房间", + "nodes.zone.roomName": "房间名称", + "nodes.zone.roomNumber": "房间编号", + "nodes.zone.enclosure": "围合", + "nodes.zone.autoDetect": "自动检测", + "nodes.zone.enclosed": "围合", + "nodes.zone.open": "敞开", + "nodes.zone.occupancy": "用途", + "nodes.zone.floorFinish": "地面饰面", + "nodes.zone.wallFinish": "墙面饰面", + "nodes.zone.ceilingFinish": "顶面饰面", + "nodes.zone.ceilingHeight": "吊顶高度", + "nodes.zone.clearDimensions": "净空", + "nodes.zone.none": "无", + "nodes.zone.insideFaces": "内表面", + "nodes.zone.finishFaces": "饰面表面", + "nodes.zone.wallSurface": "墙面", + "nodes.zone.floorSurface": "地面", + "nodes.zone.volume": "体积", + "nodes.zone.roomDocumentation": "房间文档", + "nodes.zone.roomQuantities": "房间工程量", + "nodes.zone.zoneQuantities": "区域工程量", + "nodes.zone.enclosedRoom": "围合房间", + "nodes.zone.footprintOnly": "仅轮廓", + "nodes.zone.boundaryUnavailable": "区域边界不可用", + "nodes.zone.notProven": "未计算", + "nodes.gutter.downspouts": "落水管", + "nodes.gutter.addDownspout": "添加落水管", + "nodes.gutter.removeDownspout": "移除落水管", + "nodes.door.documentation": "文档", + "nodes.window.documentation": "文档", + "nodes.spawn.actions": "操作", + "nodes.ductFitting.swapWH": "交换宽/高", + "nodes.ductFitting.swapWidthHeight": "交换宽度和高度", + "nodes.boxVent.baseFlange": "底部法兰", + "nodes.shared.mark": "标记", + "nodes.shared.construction": "构造", + "nodes.shared.framed": "框架", + "nodes.shared.masonry": "砌体", + "nodes.shared.dimensionTo": "尺寸基准", + "nodes.shared.nominal": "标称", + "nodes.shared.roughOpening": "毛口", + "nodes.shared.masonryOpening": "砌体口", + "nodes.shared.finishOpening": "完成口", + "nodes.shared.roWidth": "毛口宽度", + "nodes.shared.roHeight": "毛口高度", + "nodes.shared.moWidth": "砌体口宽度", + "nodes.shared.moHeight": "砌体口高度", + "nodes.shared.foWidth": "完成口宽度", + "nodes.shared.foHeight": "完成口高度", + "nodes.shared.leaveBlankHint": "在相关制造商或专业承包商验证前,请将 RO、MO 和 FO 值留空。", + "nodes.shared.autoAssigned": "自动分配", + "nodes.shared.verify": "验证", + "panel.section.actions": "操作", + "panel.section.position": "位置", + "panel.section.rotation": "旋转", + "panel.section.scaleOpacity": "缩放与不透明度", + "editor.edgeOff": "关", + "editor.edgeSoft": "柔和", + "editor.edgeStrong": "明显", + "editor.edgeOffDetail": "无边线", + "editor.edgeSoftDetail": "仅主要折痕有淡轮廓", + "editor.edgeStrongDetail": "清晰、不透明的边线", + "nodes.skylight.frame": "边框", + "nodes.cabinet.modules": "模块", + "nodes.cabinet.style": "样式", + "nodes.cabinet.back": "后侧", + "nodes.cabinet.left": "左侧", + "nodes.cabinet.right": "右侧", + "common.shelves": "搁板", + "common.drawers": "抽屉", + "nodes.zone.occupancyUse": "功能 / 用途", + "nodes.zone.topViewAriaLabel": "区域边界尺寸的顶视图", + "nodes.gutter.downspoutDefaultName": "落水管 {index}", + "nodes.elevator.levelFallback": "楼层 {n}", + "nodes.measurement.extrusionHeight": "拉伸高度", + "common.dismiss": "关闭", + "editor.riserDiagram": "排水/排污/通气立管图", + "editor.followsLevel": "跟随楼层", + "editor.customHeight": "自定义高度", + "editor.currentlyLabel": "当前 {value}", + "editor.capture": "采集", + "editor.top": "顶部", + "buildTab.roofSource.conicalHint": "选择一面弧形墙以匹配其半径和弧度。", + "nodes.roof.toolHints.placementTooltip": "放置面 — 点击或按 P 键切换", + "nodes.roof.defaultName": "屋顶 {count}", + "nodes.roof.preview": "屋顶预览" +} diff --git a/packages/editor/src/lib/level-display.ts b/packages/editor/src/lib/level-display.ts new file mode 100644 index 0000000000..11a8aa4def --- /dev/null +++ b/packages/editor/src/lib/level-display.ts @@ -0,0 +1,29 @@ +import type { LevelNode } from '@pascal-app/core' + +/** + * Resolve a human-readable label for a level in the active locale. + * + * If the user has named the level, their name wins. Otherwise the + * ordinal-shaped defaults (`level.groundFloor`, `level.floor` with the + * ordinal parameter, `level.basement` with the magnitude) keep the + * HUD in sync with the active language. The old `getLevelDisplayName` + * helper in `@pascal-app/core` always returned English defaults and + * was removed in favor of this locale-aware version. + * + * Pass the result of `useTranslations()` in as `t` so the helper stays + * callable from non-component contexts (e.g. command palette option + * lists, view toggles) without reaching into a hook. + */ +export function localizedLevelName( + level: LevelNode, + t: (key: string, params?: Record) => string, +): string { + return ( + level.name || + (level.level === 0 + ? t('level.groundFloor') + : level.level > 0 + ? t('level.floor', { n: level.level }) + : t('level.basement', { n: -level.level })) + ) +} \ No newline at end of file diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index f0b097bc77..2e213965df 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -75,8 +75,8 @@ function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | n return getCatalogMaterialById(catalogId) } -export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) { - return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' +export function getActivePaintMaterialLabelKey(material: ActivePaintMaterial | null | undefined) { + return getCatalogEntryForActivePaintMaterial(material)?.labelKey ?? null } export function buildRoofSurfaceMaterialPatch( diff --git a/packages/editor/src/lib/paint-scope.test.ts b/packages/editor/src/lib/paint-scope.test.ts index 768fdbe11f..0588a0a4da 100644 --- a/packages/editor/src/lib/paint-scope.test.ts +++ b/packages/editor/src/lib/paint-scope.test.ts @@ -55,6 +55,18 @@ describe('cyclePaintScope', () => { }) describe('paintScopeLabel', () => { + // Identity translator — tests assert against the i18n key (with any + // interpolation substituted), which keeps the suite locale-independent. + // The real English/Zh strings live next to the rest of the i18n keys. + const identityT = ( + key: string, + vars?: Record, + ): string => { + if (!vars) return key + return `${key} ${Object.entries(vars) + .map(([k, v]) => `${k}=${v}`) + .join(' ')}` + } const info = (over: Partial): PaintHoverInfo => ({ scopes: ['single'], slotLabel: 'Seat cushion', @@ -62,17 +74,23 @@ describe('paintScopeLabel', () => { ...over, }) it('single shows the hovered slot label', () => { - expect(paintScopeLabel('single', info({ slotLabel: 'Seat cushion' }))).toBe('Seat cushion') + expect(paintScopeLabel('single', info({ slotLabel: 'Seat cushion' }), identityT)).toBe( + 'Seat cushion', + ) }) it('single falls back when there is no slot label', () => { - expect(paintScopeLabel('single', info({ slotLabel: '' }))).toBe('This surface') + expect(paintScopeLabel('single', info({ slotLabel: '' }), identityT)).toBe( + 'helper.paint.thisSurface', + ) }) it('object reads "Whole "', () => { - expect(paintScopeLabel('object', info({ nodeNoun: 'shelf' }))).toBe('Whole shelf') + expect(paintScopeLabel('object', info({ nodeNoun: 'shelf' }), identityT)).toBe( + 'helper.paint.wholeNoun noun=shelf', + ) }) it('matching / room are kind-agnostic', () => { - expect(paintScopeLabel('matching', info({}))).toBe('All matching') - expect(paintScopeLabel('room', info({}))).toBe('Room') + expect(paintScopeLabel('matching', info({}), identityT)).toBe('helper.paint.allMatching') + expect(paintScopeLabel('room', info({}), identityT)).toBe('helper.paint.room') }) }) diff --git a/packages/editor/src/lib/paint-scope.ts b/packages/editor/src/lib/paint-scope.ts index eb7c893923..c9fb71993d 100644 --- a/packages/editor/src/lib/paint-scope.ts +++ b/packages/editor/src/lib/paint-scope.ts @@ -66,16 +66,22 @@ export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintS return list[(index + 1) % list.length] ?? 'single' } -export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string { +export type Translator = (key: string, vars?: Record) => string + +export function paintScopeLabel( + scope: PaintScope, + info: PaintHoverInfo, + t: Translator, +): string { switch (scope) { case 'object': - return `Whole ${info.nodeNoun}` + return t('helper.paint.wholeNoun', { noun: info.nodeNoun }) case 'matching': - return 'All matching' + return t('helper.paint.allMatching') case 'room': - return 'Room' + return t('helper.paint.room') default: - return info.slotLabel || 'This surface' + return info.slotLabel || t('helper.paint.thisSurface') } } diff --git a/packages/editor/src/store/use-command-registry.ts b/packages/editor/src/store/use-command-registry.ts index d3393b2049..3d6ef1d980 100644 --- a/packages/editor/src/store/use-command-registry.ts +++ b/packages/editor/src/store/use-command-registry.ts @@ -1,3 +1,5 @@ +'use client' + import type { ReactNode } from 'react' import { create } from 'zustand' diff --git a/packages/editor/src/store/use-palette-view-registry.ts b/packages/editor/src/store/use-palette-view-registry.ts index 339522304f..075aed5782 100644 --- a/packages/editor/src/store/use-palette-view-registry.ts +++ b/packages/editor/src/store/use-palette-view-registry.ts @@ -1,3 +1,5 @@ +'use client' + import type { ComponentType } from 'react' import { create } from 'zustand' diff --git a/packages/nodes/src/block/definition.ts b/packages/nodes/src/block/definition.ts index 4ec7bdacd2..c30e8899a4 100644 --- a/packages/nodes/src/block/definition.ts +++ b/packages/nodes/src/block/definition.ts @@ -121,11 +121,12 @@ export const blockDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Place block' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place block', labelKey: 'nodes.block.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Block', + labelKey: 'panel.nodeType.block', description: 'A topology-backed solid edited directly in the canvas.', icon: { kind: 'url', src: '/icons/cube.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/block/panel.tsx b/packages/nodes/src/block/panel.tsx index 0e0de80149..492a65c7a7 100644 --- a/packages/nodes/src/block/panel.tsx +++ b/packages/nodes/src/block/panel.tsx @@ -19,6 +19,8 @@ import { SliderControl, triggerSFX, useInteractionScope, + useTranslations, + type Translator, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Check, Move, Plus, Trash2 } from 'lucide-react' @@ -54,12 +56,14 @@ const NEW_BLOCK_SLOT_MATERIAL = { function materialRefLabel( ref: string | undefined, sceneMaterials: ReturnType['materials'], + t: Translator, ): string { const parsed = parseMaterialRef(ref) - if (!parsed) return 'Default material' + if (!parsed) return t('nodes.block.defaultMaterial') if (parsed.kind === 'scene') return sceneMaterials[parsed.id as keyof typeof sceneMaterials]?.name ?? ref ?? parsed.id - return getCatalogMaterialById(parsed.id)?.label ?? ref ?? parsed.id + const catalogMaterial = getCatalogMaterialById(parsed.id) + return catalogMaterial ? t(catalogMaterial.labelKey) : ref ?? parsed.id } function materialRefPreview( @@ -84,6 +88,7 @@ function materialRefPreview( } export default function BlockPanel() { + const t = useTranslations() const selectedId = useViewer((state) => state.selection.selectedIds[0]) const setViewerSelection = useViewer((state) => state.setSelection) const node = useScene((state) => { @@ -150,9 +155,9 @@ export default function BlockPanel() { const slotDeclarations = blockSlots(node) const canOperateOnFaces = editing && selection.mode === 'face' const slotEditTitle = !editing - ? 'Enter Edit Mode to use slot actions' + ? t('nodes.block.editModeSlotActions') : readOnly - ? 'Scene is read-only' + ? t('nodes.block.readOnly') : undefined const faceCountBySlot = new Map() for (const face of node.topology.faces) { @@ -185,7 +190,7 @@ export default function BlockPanel() { ...current.materials, [resolution.newSceneMaterial.id as SceneMaterialId]: { ...resolution.newSceneMaterial, - name: 'Block Accent', + name: t('nodes.block.blockAccent'), }, } : current.materials, @@ -202,10 +207,15 @@ export default function BlockPanel() { }) if (!committed) return useScene.getState().markDirty(node.id) - const faceLabel = selectedFaceIds.length === 1 ? 'face' : 'faces' + const faceLabel = + selectedFaceIds.length === 1 ? t('nodes.block.face') : t('nodes.block.faces') setSlotNotice({ nodeId: node.id, - text: `${result.slotNames[result.slotId] ?? result.slotId} applied to ${selectedFaceIds.length} ${faceLabel} with an accent material. Use Paint (P) to replace it.`, + text: t('nodes.block.slotApplied', { + slot: result.slotNames[result.slotId] ?? result.slotId, + count: selectedFaceIds.length, + faceLabel, + }), }) triggerSFX('sfx:menu-click') } @@ -255,23 +265,30 @@ export default function BlockPanel() { } const selectionLabel = !editing - ? 'Enter Edit Mode to assign faces' + ? t('nodes.block.editModeAssignFaces') : selection.mode !== 'face' - ? 'Switch to Face Select (3)' + ? t('nodes.block.faceSelectSwitch') : materialSelection.kind === 'empty' - ? 'No faces selected' + ? t('nodes.block.noFacesSelected') : materialSelection.kind === 'mixed' - ? `${selectedFaceIds.length} faces · Mixed slots` - : `${selectedFaceIds.length} ${selectedFaceIds.length === 1 ? 'face' : 'faces'} · ${slotDeclarations.find((slot) => slot.slotId === materialSelection.slotId)?.label ?? materialSelection.slotId}` + ? t('nodes.block.mixedSlots', { count: selectedFaceIds.length }) + : t('nodes.block.singleSlot', { + count: selectedFaceIds.length, + faceLabel: + selectedFaceIds.length === 1 ? t('nodes.block.face') : t('nodes.block.faces'), + slotLabel: + slotDeclarations.find((slot) => slot.slotId === materialSelection.slotId) + ?.label ?? materialSelection.slotId, + }) return ( - - + + {( [ - { axis: 0, label: 'X', onChange: updatePositionX }, - { axis: 1, label: 'Y', onChange: updatePositionY }, - { axis: 2, label: 'Z', onChange: updatePositionZ }, + { axis: 0, label: t('common.x'), onChange: updatePositionX }, + { axis: 1, label: t('common.y'), onChange: updatePositionY }, + { axis: 2, label: t('common.z'), onChange: updatePositionZ }, ] as const ).map(({ axis, label, onChange }) => ( - +
{selectionLabel}
@@ -296,11 +313,11 @@ export default function BlockPanel() { className={SLOT_DISABLED_ACTION_CLASS} disabled={!canOperateOnFaces || selectedFaceIds.length === 0 || readOnly} icon={} - label="Add slot" + label={t('nodes.block.addSlot')} onClick={addMaterialSlot} title={ slotEditTitle ?? - (selectedFaceIds.length === 0 ? 'Select one or more faces first' : undefined) + (selectedFaceIds.length === 0 ? t('nodes.block.selectFacesFirst') : undefined) } />
@@ -323,8 +340,8 @@ export default function BlockPanel() { const faceCount = faceCountBySlot.get(slot.slotId) ?? 0 const materialLabel = ref || slot.slotId === BLOCK_BODY_SLOT_ID - ? materialRefLabel(ref, sceneMaterials) - : 'Unpainted' + ? materialRefLabel(ref, sceneMaterials, t) + : t('nodes.block.unpainted') return (
- + - } label="Move" onClick={move} /> + } label={t('common.move')} onClick={move} /> } - label="Delete" + label={t('common.delete')} onClick={() => { useScene.getState().deleteNode(node.id) setViewerSelection({ selectedIds: [] }) diff --git a/packages/nodes/src/block/parametrics.ts b/packages/nodes/src/block/parametrics.ts index 195e24286c..2e457fb8af 100644 --- a/packages/nodes/src/block/parametrics.ts +++ b/packages/nodes/src/block/parametrics.ts @@ -5,6 +5,7 @@ export const blockParametrics: ParametricDescriptor = { groups: [ { label: 'Position', + labelKey: 'common.position', fields: [{ key: 'position', kind: 'vec3' }], }, ], diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 87d420ebfe..03fa17aa49 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -23,6 +23,7 @@ import { triggerSFX, useEditor, useInteractionScope, + useTranslations, } from '@pascal-app/editor' import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' @@ -1449,6 +1450,7 @@ function LastOperationPanel({ onClose: () => void onRepeat: () => void }) { + const t = useTranslations() return (
{operation.label}
-
Adjust Last Operation · F9
+
+ {t('nodes.block.adjustLastOp')} +
{toolbarPanel === 'operations' ? ( - +
beginKeyboardTransformModal('translate')} shortcut="G" > @@ -3739,7 +3744,7 @@ function BlockEditor({ beginKeyboardTransformModal('rotate')} shortcut="R" > @@ -3747,7 +3752,7 @@ function BlockEditor({ @@ -3755,7 +3760,7 @@ function BlockEditor({ @@ -3765,7 +3770,7 @@ function BlockEditor({ active={loopCutActive} controls={ } - label="Loop Cut and Slide" + label={t('nodes.block.toolbar.loopCutAndSlide')} onClick={() => { playBlockSfx('tool-select') setTransformTool('loop-cut') @@ -3791,7 +3796,7 @@ function BlockEditor({ @@ -3799,7 +3804,7 @@ function BlockEditor({ @@ -3808,7 +3813,7 @@ function BlockEditor({ { playBlockSfx('tool-select') setBevelSegments(DEFAULT_BEVEL_SEGMENTS) @@ -3834,14 +3839,14 @@ function BlockEditor({ ) : null} - +
setToolbarPanel((current) => (current === 'selection' ? null : 'selection')) } @@ -3850,12 +3855,12 @@ function BlockEditor({ {toolbarPanel === 'selection' ? (
setXray((value) => !value)} > {xray ? : } @@ -3890,7 +3895,7 @@ function BlockEditor({ = { move: () => import('./move-tool'), }, toolHints: [ - { key: 'Left click', label: 'Place box vent on roof' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place box vent', labelKey: 'nodes.boxVent.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Box Vent', + labelKey: 'panel.nodeType.boxVent', description: 'Small louvered exhaust vent that sits on a roof slope.', icon: { kind: 'url', src: '/icons/box-vent.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/box-vent/geometry.ts b/packages/nodes/src/box-vent/geometry.ts index dee0ec1521..6ff7e3b1ee 100644 --- a/packages/nodes/src/box-vent/geometry.ts +++ b/packages/nodes/src/box-vent/geometry.ts @@ -1,3 +1,5 @@ +'use client' + import { type BoxVentNode, getActiveRoofHeight, type RoofType } from '@pascal-app/core' import * as THREE from 'three' import { copyUvToSecondaryChannel } from '../shared/primitive-uv' diff --git a/packages/nodes/src/box-vent/panel.tsx b/packages/nodes/src/box-vent/panel.tsx index 537496152f..786b2fbc4c 100644 --- a/packages/nodes/src/box-vent/panel.tsx +++ b/packages/nodes/src/box-vent/panel.tsx @@ -18,6 +18,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' @@ -37,6 +38,7 @@ import type { BoxVentNode } from './schema' * ghost commits; on Esc it cancels and the original mesh is restored. */ export default function BoxVentPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -167,24 +169,24 @@ export default function BoxVentPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Box Vent'} + title={node.name || t('nodes.boxVent.boxVent')} width={300} > - + handleUpdate({ style: v as BoxVentNode['style'] })} options={[ - { label: 'Box', value: 'box' }, - { label: 'Cap', value: 'cap' }, - { label: 'Dome', value: 'dome' }, + { label: t('nodes.boxVent.box'), value: 'box' }, + { label: t('nodes.boxVent.cap'), value: 'cap' }, + { label: t('nodes.boxVent.dome'), value: 'dome' }, ]} value={node.style ?? 'cap'} /> - + previewProp({ width: v })} @@ -196,7 +198,7 @@ export default function BoxVentPanel() { value={Math.round(node.width * 100) / 100} /> previewProp({ depth: v })} @@ -208,7 +210,7 @@ export default function BoxVentPanel() { value={Math.round(node.depth * 100) / 100} /> previewProp({ height: v })} @@ -225,7 +227,7 @@ export default function BoxVentPanel() { flare further past the body. */} {node.style === 'cap' && ( previewProp({ hoodOverhang: v })} @@ -240,7 +242,7 @@ export default function BoxVentPanel() { {node.style === 'box' && ( <> previewProp({ baseInset: v })} @@ -252,7 +254,7 @@ export default function BoxVentPanel() { value={Math.round((node.baseInset ?? 0.06) * 1000) / 1000} /> previewProp({ baseHeight: v })} @@ -264,7 +266,7 @@ export default function BoxVentPanel() { value={Math.round((node.baseHeight ?? 0.04) * 1000) / 1000} /> previewProp({ capHeight: v })} @@ -295,7 +297,7 @@ export default function BoxVentPanel() { value={Math.round((node.capHeight ?? 0.07) * 1000) / 1000} /> previewProp({ capGap: v })} @@ -307,7 +309,7 @@ export default function BoxVentPanel() { value={Math.round((node.capGap ?? 0) * 1000) / 1000} /> previewProp({ topTaper: v })} @@ -323,7 +325,7 @@ export default function BoxVentPanel() { {node.style === 'dome' && ( <> previewProp({ domeCurvature: v })} @@ -335,7 +337,7 @@ export default function BoxVentPanel() { value={Math.round((node.domeCurvature ?? 1.0) * 100) / 100} /> previewProp({ hoodOverhang: v })} @@ -350,9 +352,9 @@ export default function BoxVentPanel() { )} - + @@ -372,7 +374,7 @@ export default function BoxVentPanel() { value={Math.round((node.position[0] ?? 0) * 100) / 100} /> @@ -415,7 +417,7 @@ export default function BoxVentPanel() { value={Math.round((node.position[2] ?? 0) * 100) / 100} /> previewProp({ rotation: (deg * Math.PI) / 180 })} @@ -428,18 +430,18 @@ export default function BoxVentPanel() { /> - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/box-vent/parametrics.ts b/packages/nodes/src/box-vent/parametrics.ts index b9d1d16563..43aeae487e 100644 --- a/packages/nodes/src/box-vent/parametrics.ts +++ b/packages/nodes/src/box-vent/parametrics.ts @@ -16,17 +16,22 @@ export const boxVentParametrics: ParametricDescriptor = { groups: [ { label: 'Style', + labelKey: 'common.style', fields: [ { key: 'style', kind: 'enum', options: ['standard', 'low-profile', 'dome'], + optionLabelKeys: { + "dome": "nodes.boxVent.dome" + }, display: 'segmented', }, ], }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.15, max: 1000, step: 0.01 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.15, max: 1000, step: 0.01 }, diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 27dd2da61f..62a1e4a4c4 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -9,7 +9,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { triggerSFX, useTranslations } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' @@ -37,6 +37,7 @@ const worldPoint = new THREE.Vector3() * roof + segment rotation stack. */ const BoxVentTool = () => { + const t = useTranslations() const activeBuildingId = useViewer((s) => s.selection.buildingId) const setSelection = useViewer((s) => s.setSelection) @@ -111,10 +112,11 @@ const BoxVentTool = () => { ) if (!hit) return const state = useScene.getState() + const ventCount = Object.values(state.nodes).filter((n) => (n as any).type === 'boxVent').length const vent = BoxVentNode.parse({ ...boxVentDefinition.defaults(), - name: 'Box Vent', + name: t('nodes.boxVent.defaultName', { count: ventCount + 1 }), roofSegmentId: hit.segment.id, position: [hit.localX, hit.localY, hit.localZ], rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index fc76d61ff3..1167acc58a 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -49,6 +49,7 @@ export const buildingDefinition: NodeDefinition = { presentation: { label: 'Building', + labelKey: 'panel.nodeType.building', description: 'A building container holding one or more levels.', icon: { kind: 'url', src: '/icons/building.webp' }, paletteSection: 'site', diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx index d98ddf3213..eb86e075c6 100644 --- a/packages/nodes/src/cabinet/compartment-card.tsx +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -1,6 +1,6 @@ 'use client' -import { SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor' +import { SegmentedControl, SliderControl, ToggleControl, useTranslations } from '@pascal-app/editor' import { ArrowDown, ArrowUp, Minus, Plus, Trash } from 'lucide-react' import { type CabinetCompartment, @@ -208,6 +208,7 @@ export function CompartmentCard({ allowHood?: boolean wallCabinet?: boolean }) { + const t = useTranslations() const type = compartment.type as CabinetCompartmentType const isFridge = isFridgeCompartmentType(type) const isHood = isHoodCompartmentType(type) @@ -278,7 +279,7 @@ export function CompartmentCard({ {total > 1 && !isHood && !isCooktop && type !== 'sink' && (
onReplace(patchCompartment(compartment, { shelfCount: value }))} @@ -302,7 +303,7 @@ export function CompartmentCard({ {type === 'drawer' && ( onReplace(patchCompartment(compartment, { drawerCount: value }))} @@ -326,7 +327,7 @@ export function CompartmentCard({ />
onReplace(patchCompartment(compartment, { shelfCount: value }))} @@ -410,7 +411,7 @@ export function CompartmentCard({
{ const count = compartmentCooktopElementCount( compartment, @@ -430,7 +431,7 @@ export function CompartmentCard({ {type === 'cooktop-gas' && ( onReplace(patchCompartment(compartment, { cooktopShowGrate: checked })) } @@ -458,7 +459,7 @@ export function CompartmentCard({ {type === 'pull-out-pantry' && (
onReplace(patchCompartment(compartment, { shelfCount: value }))} diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 3e7245c2be..ff0fe43460 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -2209,26 +2209,31 @@ export const cabinetDefinition: NodeDefinition = { }, tool: () => import('./tool'), toolHints: [ - { key: 'Click', label: 'Place cabinet' }, + { key: 'Click', label: 'Place cabinet', labelKey: 'nodes.cabinet.toolHints.place' }, { key: 'I', label: 'Placement type', + labelKey: 'nodes.cabinet.toolHints.placementType', chip: { subscribe: (onChange) => useCabinetPlacementType.subscribe(onChange), value: () => useCabinetPlacementType.getState().type, cycle: () => void useCabinetPlacementType.getState().cycleType(), - labels: { cabinet: 'Type: Cabinet', island: 'Type: Island' }, + labels: { + cabinet: 'nodes.cabinet.toolHints.typeCabinet', + island: 'nodes.cabinet.toolHints.typeIsland', + }, icons: { cabinet: 'lucide:rectangle-horizontal', island: 'lucide:table-2' }, tooltip: 'Placement type — click or press I to toggle', }, }, - { key: 'Alt', label: 'Force place' }, - { key: 'R / T', label: 'Rotate' }, - { key: 'Esc', label: 'Cancel run / exit' }, + { key: 'Alt', label: 'Force place', labelKey: 'editor.forcePlace' }, + { key: 'R / T', label: 'Rotate', labelKey: 'editor.rotate' }, + { key: 'Esc', label: 'Cancel run / exit', labelKey: 'common.cancel' }, ], presentation: { label: 'Modular Cabinet', + labelKey: 'panel.nodeType.cabinet', description: 'A configurable parametric base cabinet.', icon: { kind: 'url', src: '/icons/item.webp' }, paletteSection: 'furnish', @@ -2393,6 +2398,7 @@ export const cabinetModuleDefinition: NodeDefinition = presentation: { label: 'Cabinet Module', + labelKey: 'panel.nodeType.cabinetModule', description: 'An editable module inside a modular cabinet run.', icon: { kind: 'url', src: '/icons/item.webp' }, paletteSection: 'furnish', diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 3bd828137f..c8226af89e 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -13,6 +13,7 @@ import { PanelWrapper, SegmentedControl, SliderControl, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { AlertTriangle, Pause, Play, Plus } from 'lucide-react' @@ -81,39 +82,39 @@ import { } from './widths' const HANDLE_STYLE_OPTIONS = [ - { value: 'bar', label: 'Bar' }, - { value: 'knob', label: 'Knob' }, - { value: 'cutout', label: 'Cutout' }, - { value: 'hole', label: 'Hole' }, - { value: 'none', label: 'None' }, + { value: 'bar', labelKey: 'nodes.cabinet.handle.bar' }, + { value: 'knob', labelKey: 'nodes.cabinet.handle.knob' }, + { value: 'cutout', labelKey: 'nodes.cabinet.handle.cutout' }, + { value: 'hole', labelKey: 'nodes.cabinet.handle.hole' }, + { value: 'none', labelKey: 'nodes.cabinet.handle.none' }, ] as const const HANDLE_POSITION_OPTIONS = [ - { value: 'auto', label: 'Auto' }, - { value: 'top', label: 'Top' }, - { value: 'center', label: 'Center' }, + { value: 'auto', labelKey: 'nodes.cabinet.handlePosition.auto' }, + { value: 'top', labelKey: 'nodes.cabinet.handlePosition.top' }, + { value: 'center', labelKey: 'nodes.cabinet.handlePosition.center' }, ] as const const FRONT_OVERLAY_OPTIONS = [ - { value: 'full', label: 'Overlay' }, - { value: 'inset', label: 'Inset' }, + { value: 'full', labelKey: 'nodes.cabinet.frontOverlay.full' }, + { value: 'inset', labelKey: 'nodes.cabinet.frontOverlay.inset' }, ] as const const FRONT_STYLE_OPTIONS = [ - { value: 'slab', label: 'Slab' }, - { value: 'shaker', label: 'Shaker' }, - { value: 'raised-arch', label: 'Raised Arch' }, + { value: 'slab', labelKey: 'nodes.cabinet.frontStyle.slab' }, + { value: 'shaker', labelKey: 'nodes.cabinet.frontStyle.shaker' }, + { value: 'raised-arch', labelKey: 'nodes.cabinet.frontStyle.raisedArch' }, ] as const const CABINET_TIER_OPTIONS = [ - { value: 'base', label: 'Base Cabinet' }, - { value: 'tall', label: 'Tall Cabinet' }, + { value: 'base', labelKey: 'nodes.cabinet.tier.base' }, + { value: 'tall', labelKey: 'nodes.cabinet.tier.tall' }, ] as const const TOP_FINISH_OPTIONS = [ - { value: 'none', label: 'None' }, - { value: 'top-cabinet', label: 'Top Cabinet' }, - { value: 'trim', label: 'Trim / Soffit' }, + { value: 'none', labelKey: 'nodes.cabinet.topFinish.none' }, + { value: 'top-cabinet', labelKey: 'nodes.cabinet.topFinish.topCabinet' }, + { value: 'trim', labelKey: 'nodes.cabinet.topFinish.trim' }, ] as const const EMPTY_MODULES: CabinetModuleNodeType[] = [] @@ -121,10 +122,10 @@ const EMPTY_MODULE_IDS: AnyNodeId[] = [] const PRESET_BUTTON_CLASS = 'flex h-9 items-center justify-center rounded-md border border-border/40 bg-[#252527] px-3 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border/70 hover:bg-[#303033]' -const REFLOW_REJECTED_MESSAGE = - 'No space in this run. No base cabinet can shrink enough to fit this item.' +const REFLOW_REJECTED_KEY = 'nodes.cabinet.reflowRejected' export default function CabinetPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const [isAnimating, setIsAnimating] = useState(false) @@ -180,8 +181,8 @@ export default function CabinetPanel() { }) const showReflowRejected = useCallback(() => { - setReflowNotice({ message: REFLOW_REJECTED_MESSAGE }) - }, []) + setReflowNotice({ message: t(REFLOW_REJECTED_KEY) }) + }, [t]) useEffect(() => { if (selectedId) setReflowNotice(null) @@ -577,13 +578,13 @@ export default function CabinetPanel() { icon="/icons/item.webp" onBack={node.type === 'cabinet-module' ? backToRun : undefined} onClose={close} - title={node.name || 'Modular Cabinet'} + title={node.name || t('nodes.cabinet.fallbackTitle')} width={320} > {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && cabinetModuleSupportsPresets(node) && ( - +
{CABINET_PRESETS.map((preset) => (
)} - + {reflowNotice ? (

} - label="Add compartment" + label={t('nodes.cabinet.addCompartment')} onClick={addCompartment} />

@@ -877,11 +882,11 @@ export default function CabinetPanel() { {!isHoodOnlyNode && ( <> - +
- Style + {t('nodes.cabinet.fronts.style')}
@@ -889,14 +894,14 @@ export default function CabinetPanel() { } options={FRONT_STYLE_OPTIONS.map((option) => ({ value: option.value, - label: option.label, + label: t(option.labelKey), }))} value={node.frontStyle ?? 'slab'} />
- Mounting + {t('nodes.cabinet.fronts.mounting')}
@@ -904,14 +909,14 @@ export default function CabinetPanel() { } options={FRONT_OVERLAY_OPTIONS.map((option) => ({ value: option.value, - label: option.label, + label: t(option.labelKey), }))} value={node.frontOverlay ?? 'full'} />
- Reveal gap + {t('nodes.cabinet.fronts.revealGap')}
- +
- Style + {t('nodes.cabinet.handles.style')}
@@ -946,7 +951,7 @@ export default function CabinetPanel() { } options={HANDLE_STYLE_OPTIONS.map((option) => ({ value: option.value, - label: option.label, + label: t(option.labelKey), }))} value={node.handleStyle} /> @@ -954,7 +959,7 @@ export default function CabinetPanel() { {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && (
- Position + {t('nodes.cabinet.handles.position')}
@@ -962,7 +967,7 @@ export default function CabinetPanel() { } options={HANDLE_POSITION_OPTIONS.map((option) => ({ value: option.value, - label: option.label, + label: t(option.labelKey), }))} value={node.handlePosition ?? 'auto'} /> diff --git a/packages/nodes/src/cabinet/parametrics.ts b/packages/nodes/src/cabinet/parametrics.ts index ce253b1752..3f7970a21c 100644 --- a/packages/nodes/src/cabinet/parametrics.ts +++ b/packages/nodes/src/cabinet/parametrics.ts @@ -5,6 +5,7 @@ export const cabinetParametrics: ParametricDescriptor = { groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, @@ -13,6 +14,7 @@ export const cabinetParametrics: ParametricDescriptor = { }, { label: 'Position', + labelKey: 'common.position', fields: [{ key: 'position', kind: 'vec3' }], }, ], @@ -30,6 +32,7 @@ export const cabinetModuleParametrics: ParametricDescriptor = groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, @@ -38,6 +41,7 @@ export const cabinetModuleParametrics: ParametricDescriptor = }, { label: 'Position', + labelKey: 'common.position', fields: [{ key: 'position', kind: 'vec3' }], }, ], diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 94b62e8e0b..d309c7fa1c 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -14,6 +14,7 @@ import { SegmentedControl, SliderControl, ToggleControl, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Plus, Trash } from 'lucide-react' @@ -404,6 +405,7 @@ export function CabinetRunPanel({ modules: CabinetModuleNodeType[] onClose: () => void }) { + const t = useTranslations() const setSelection = useViewer((s) => s.setSelection) const sortedModules = useMemo( () => [...modules].sort((a, b) => a.position[0] - b.position[0]), @@ -464,7 +466,7 @@ export function CabinetRunPanel({ title={node.name || 'Modular Cabinet'} width={320} > - +
{sortedModules.map((module, index) => (
} - label="Add left" + label={t('nodes.cabinet.addLeft')} onClick={() => addModule('left')} /> } - label="Add right" + label={t('nodes.cabinet.addRight')} onClick={() => addModule('right')} />
- +
{node.runTier === 'base' && (
- Standard dimensions + {t('nodes.cabinet.standardDimensions')}

- Applies depth, carcass, plinth, and countertop thickness to this run. + {t('nodes.cabinet.appliesStandardDimensions')}

)} updateRun({ depth: value })} @@ -542,7 +544,7 @@ export function CabinetRunPanel({ value={node.depth} /> minCabinetCarcassHeightForStack(module)))} onChange={(value) => updateRun({ carcassHeight: value })} @@ -553,12 +555,12 @@ export function CabinetRunPanel({ /> updateRun({ showPlinth: checked })} /> {node.showPlinth && ( updateRun({ plinthHeight: value })} @@ -570,13 +572,13 @@ export function CabinetRunPanel({ )} updateRun({ withCountertop: checked })} /> {node.withCountertop && ( <> updateRun({ countertopThickness: value })} @@ -586,7 +588,7 @@ export function CabinetRunPanel({ value={node.countertopThickness} /> updateRun({ countertopOverhang: value })} @@ -600,11 +602,11 @@ export function CabinetRunPanel({
- +
{node.withCountertop && node.barLedge?.edge !== 'back' && ( updateRun({ countertopBackOverhang: value })} @@ -616,19 +618,19 @@ export function CabinetRunPanel({ )} updateRun({ withFinishedBack: checked })} /> {node.withCountertop && ( updateRun({ withWaterfall: checked })} /> )} updateRun({ barLedge: checked ? { edge: 'back', height: 1.06, depth: 0.35 } : undefined, @@ -644,14 +646,14 @@ export function CabinetRunPanel({ }) } options={[ - { value: 'back', label: 'Back' }, - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' }, + { value: 'back', label: t('nodes.cabinet.back') }, + { value: 'left', label: t('nodes.cabinet.left') }, + { value: 'right', label: t('nodes.cabinet.right') }, ]} value={node.barLedge.edge} /> updateRun({ barLedge: { ...node.barLedge!, height: value } })} @@ -661,7 +663,7 @@ export function CabinetRunPanel({ value={node.barLedge.height} /> updateRun({ barLedge: { ...node.barLedge!, depth: value } })} @@ -675,11 +677,11 @@ export function CabinetRunPanel({
- +
- Style + {t('nodes.cabinet.style')}
@@ -732,7 +734,7 @@ export function CabinetRunPanel({
- +
diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index f362f83166..851a81e7a6 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -214,13 +214,14 @@ export const ceilingDefinition: NodeDefinition = { }, toolHints: [ - { key: 'Left click', label: 'Trace ceiling outline' }, - { key: 'Enter', label: 'Finish ceiling', minDraftVertices: 3 }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Trace ceiling outline', labelKey: 'nodes.ceiling.toolHints.trace' }, + { key: 'Enter', label: 'Finish ceiling', labelKey: 'nodes.ceiling.toolHints.finish', minDraftVertices: 3 }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Ceiling', + labelKey: 'panel.nodeType.ceiling', description: 'A polygon-bounded ceiling surface that hosts ceiling-mounted items.', icon: { kind: 'url', src: '/icons/ceiling.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index 1f8772954d..f5fff38d82 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -20,6 +20,7 @@ import { useEditingHole, useEditor, useInteractionScope, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' @@ -34,6 +35,7 @@ import { useCallback, useEffect, useRef } from 'react' * panel can collapse into auto-derived groups. */ export function CeilingPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) @@ -227,39 +229,41 @@ export function CeilingPanel() { const heightPresets = unit === 'imperial' ? [ - { label: 'Low (8\'0")', height: 2.4384 }, - { label: 'Standard (8\'6")', height: 2.5908 }, - { label: 'High (9\'0")', height: 2.7432 }, + { labelKey: 'nodes.ceiling.heightPresets.lowImperial', height: 2.4384 }, + { labelKey: 'nodes.ceiling.heightPresets.standardImperial', height: 2.5908 }, + { labelKey: 'nodes.ceiling.heightPresets.highImperial', height: 2.7432 }, ] : [ - { label: 'Low (2.4m)', height: 2.4 }, - { label: 'Standard (2.5m)', height: 2.5 }, - { label: 'High (3.0m)', height: 3.0 }, + { labelKey: 'nodes.ceiling.heightPresets.low', height: 2.4 }, + { labelKey: 'nodes.ceiling.heightPresets.standard', height: 2.5 }, + { labelKey: 'nodes.ceiling.heightPresets.high', height: 3.0 }, ] return ( - + {isFollows ? (
- Currently {formatLinearMeasurement(resolvedHeight, unit, metricNotation)} + {t('nodes.ceiling.currently', { + value: formatLinearMeasurement(resolvedHeight, unit, metricNotation), + })}
) : ( handleHeightChange(preset.height)} title={ fits ? undefined - : `Taller than this level (${formatLinearMeasurement(maxHeight, unit, metricNotation)} available). Raise the level height first.` + : t('nodes.ceiling.tooTall', { + available: formatLinearMeasurement(maxHeight, unit, metricNotation), + }) } /> ) @@ -295,20 +301,21 @@ export function CeilingPanel() {
{Number.isFinite(maxHeight) && (
- Limited by the level to {formatLinearMeasurement(maxHeight, unit, metricNotation)} — - raise the level height for a taller ceiling. + {t('nodes.ceiling.limitedBy', { + available: formatLinearMeasurement(maxHeight, unit, metricNotation), + })}
)} - +
- Area + {t('common.area')} {area.toFixed(2)} m²
- + {node.holes && node.holes.length > 0 ? (
{node.holes.map((hole, index) => { @@ -317,7 +324,10 @@ export function CeilingPanel() { editingHole?.nodeId === selectedId && editingHole?.holeIndex === index const source = node.holeMetadata?.[index]?.source ?? 'manual' const isAutoHole = source !== 'manual' - const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout' + const autoLabel = + source === 'elevator' + ? t('nodes.ceiling.autoHoleLabel.elevator') + : t('nodes.ceiling.autoHoleLabel.stair') return (
- Hole {index + 1} {isEditing && '(Editing)'} + {t('nodes.ceiling.holeLabel', { index: index + 1 })}{' '} + {isEditing && `(${t('nodes.ceiling.editing')})`}

{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} - {isAutoHole ? autoLabel : 'Manual'} + {isAutoHole ? autoLabel : t('nodes.ceiling.manual')}

{isEditing ? ( useInteractionScope .getState() @@ -353,7 +364,7 @@ export function CeilingPanel() { /> ) : isAutoHole ? (
- Auto + {t('nodes.ceiling.auto')}
) : ( <> @@ -379,7 +390,9 @@ export function CeilingPanel() { })}
) : ( -
No holes
+
+ {t('nodes.ceiling.noHoles')} +
)}
@@ -387,14 +400,18 @@ export function CeilingPanel() { className="w-full" disabled={editingHole?.nodeId === selectedId} icon={} - label="Add Hole" + label={t('nodes.ceiling.addHole')} onClick={handleAddHole} />
- } label="Move" onClick={handleMove} /> + } + label={t('common.move')} + onClick={handleMove} + /> ) diff --git a/packages/nodes/src/ceiling/parametrics.ts b/packages/nodes/src/ceiling/parametrics.ts index 894e362f81..76405fb2c9 100644 --- a/packages/nodes/src/ceiling/parametrics.ts +++ b/packages/nodes/src/ceiling/parametrics.ts @@ -12,6 +12,7 @@ export const ceilingParametrics: ParametricDescriptor = { groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [{ key: 'height', kind: 'number', unit: 'm', min: 0.1, max: 1000, step: 0.05 }], }, ], diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index a1bd20615e..20127eacf0 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -403,12 +403,13 @@ export const chimneyDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Place chimney on roof' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place chimney on roof', labelKey: 'nodes.chimney.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Chimney', + labelKey: 'panel.nodeType.chimney', description: 'Vertical masonry stack on a roof segment.', icon: { kind: 'url', src: '/icons/chimney.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/chimney/geometry.ts b/packages/nodes/src/chimney/geometry.ts index 540b8d6807..6139303123 100644 --- a/packages/nodes/src/chimney/geometry.ts +++ b/packages/nodes/src/chimney/geometry.ts @@ -1,3 +1,5 @@ +'use client' + import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' diff --git a/packages/nodes/src/chimney/holes.ts b/packages/nodes/src/chimney/holes.ts index a260f06fd0..d825bd28e6 100644 --- a/packages/nodes/src/chimney/holes.ts +++ b/packages/nodes/src/chimney/holes.ts @@ -1,3 +1,5 @@ +'use client' + import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core' import { Brush, diff --git a/packages/nodes/src/chimney/panel.tsx b/packages/nodes/src/chimney/panel.tsx index d706dbd5e8..a7e42035e9 100644 --- a/packages/nodes/src/chimney/panel.tsx +++ b/packages/nodes/src/chimney/panel.tsx @@ -19,6 +19,7 @@ import { SegmentedControl, SliderControl, triggerSFX, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Trash2 } from 'lucide-react' @@ -39,16 +40,17 @@ const cn = (...classes: Array): string => type ChimneyType = 'cap' | 'flues' | 'shoulder' | 'bands' | 'cricket' | 'panels' -const CHIMNEY_TYPE_OPTIONS: Array<{ label: string; value: ChimneyType }> = [ - { label: 'Cap', value: 'cap' }, - { label: 'Flues', value: 'flues' }, - { label: 'Shoulder', value: 'shoulder' }, - { label: 'Bands', value: 'bands' }, - { label: 'Cricket', value: 'cricket' }, - { label: 'Panels', value: 'panels' }, +const CHIMNEY_TYPE_OPTIONS: Array<{ labelKey: string; value: ChimneyType }> = [ + { labelKey: 'nodes.chimney.cap', value: 'cap' }, + { labelKey: 'nodes.chimney.flues', value: 'flues' }, + { labelKey: 'nodes.chimney.shoulder', value: 'shoulder' }, + { labelKey: 'nodes.chimney.bands', value: 'bands' }, + { labelKey: 'nodes.chimney.cricket', value: 'cricket' }, + { labelKey: 'nodes.chimney.panels', value: 'panels' }, ] export default function ChimneyPanel() { + const t = useTranslations() const [chimneyType, setChimneyType] = useState('cap') const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -342,10 +344,10 @@ export default function ChimneyPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Chimney'} + title={node.name || t('nodes.chimney.chimney')} width={300} > - + applyPreset(v as ChimneyPresetKey)} options={CHIMNEY_PRESET_KEYS.map((k) => ({ @@ -358,17 +360,17 @@ export default function ChimneyPanel() { /> - + handleUpdate({ bodyShape: v })} options={[ - { label: 'Square', value: 'square' }, - { label: 'Round', value: 'round' }, + { label: t('nodes.chimney.square'), value: 'square' }, + { label: t('nodes.chimney.round'), value: 'round' }, ]} value={node.bodyShape ?? 'square'} /> previewProp({ width: v })} @@ -381,7 +383,7 @@ export default function ChimneyPanel() { /> {(node.bodyShape ?? 'square') !== 'round' && ( previewProp({ depth: v })} @@ -394,7 +396,7 @@ export default function ChimneyPanel() { /> )} previewProp({ bodyHollowDepth: v })} @@ -406,7 +408,7 @@ export default function ChimneyPanel() { value={Math.round((node.bodyHollowDepth ?? 0.6) * 100) / 100} /> previewProp({ bodyHollowMargin: v })} @@ -419,7 +421,7 @@ export default function ChimneyPanel() { /> {(node.bodyShape ?? 'square') !== 'round' && ( previewProp({ cornerBevel: v })} @@ -433,9 +435,9 @@ export default function ChimneyPanel() { )} - + previewProp({ heightAboveRidge: v })} @@ -447,7 +449,7 @@ export default function ChimneyPanel() { value={Math.round(node.heightAboveRidge * 100) / 100} /> previewProp({ cutoutOffset: v })} @@ -460,9 +462,9 @@ export default function ChimneyPanel() { /> - + { @@ -481,7 +483,7 @@ export default function ChimneyPanel() { value={Math.round(worldX_now * 100) / 100} /> { @@ -497,7 +499,7 @@ export default function ChimneyPanel() { value={Math.round(worldZ_now * 100) / 100} /> { @@ -523,7 +525,7 @@ export default function ChimneyPanel() { /> - +
{CHIMNEY_TYPE_OPTIONS.filter((option) => { // Cricket and Panels both rely on a flat face — hide them for @@ -546,7 +548,7 @@ export default function ChimneyPanel() { onClick={() => setChimneyType(option.value)} type="button" > - {option.label} + {t(option.labelKey)} ) })} @@ -558,17 +560,17 @@ export default function ChimneyPanel() { className="mt-2" onChange={(v) => handleUpdate({ cap: v !== 'none', capShape: v })} options={[ - { label: 'None', value: 'none' }, - { label: 'Sloped', value: 'sloped' }, - { label: 'Flat', value: 'flat' }, - { label: 'Stepped', value: 'stepped' }, + { label: t('nodes.chimney.none'), value: 'none' }, + { label: t('nodes.chimney.sloped'), value: 'sloped' }, + { label: t('nodes.chimney.flat'), value: 'flat' }, + { label: t('nodes.chimney.stepped'), value: 'stepped' }, ]} value={node.capShape ?? 'sloped'} /> {(node.capShape ?? 'sloped') !== 'none' && ( <> previewProp({ capOverhang: v })} @@ -580,7 +582,7 @@ export default function ChimneyPanel() { value={Math.round((node.capOverhang ?? 0.04) * 1000) / 1000} /> previewProp({ capThickness: v })} @@ -602,16 +604,16 @@ export default function ChimneyPanel() { className="mt-2" onChange={(v) => handleUpdate({ shoulderStyle: v })} options={[ - { label: 'None', value: 'none' }, - { label: 'Tapered', value: 'tapered' }, - { label: 'Corbeled', value: 'corbeled' }, + { label: t('nodes.chimney.none'), value: 'none' }, + { label: t('nodes.chimney.tapered'), value: 'tapered' }, + { label: t('nodes.chimney.corbeled'), value: 'corbeled' }, ]} value={node.shoulderStyle ?? 'none'} /> {(node.shoulderStyle ?? 'none') !== 'none' && ( <> previewProp({ shoulderHeight: v })} @@ -623,7 +625,7 @@ export default function ChimneyPanel() { value={Math.round((node.shoulderHeight ?? 0.5) * 100) / 100} /> previewProp({ shoulderExtent: v })} @@ -642,7 +644,7 @@ export default function ChimneyPanel() { {chimneyType === 'flues' && ( <> previewProp({ flueCount: Math.round(v) })} @@ -658,13 +660,13 @@ export default function ChimneyPanel() { handleUpdate({ flueShape: v })} options={[ - { label: 'Round', value: 'round' }, - { label: 'Square', value: 'square' }, + { label: t('nodes.chimney.round'), value: 'round' }, + { label: t('nodes.chimney.square'), value: 'square' }, ]} value={node.flueShape ?? 'round'} /> previewProp({ flueDiameter: v })} @@ -676,7 +678,7 @@ export default function ChimneyPanel() { value={Math.round((node.flueDiameter ?? 0.22) * 100) / 100} /> previewProp({ flueHeight: v })} @@ -689,7 +691,7 @@ export default function ChimneyPanel() { /> {(node.flueCount ?? 1) > 1 && ( previewProp({ flueSpacing: v })} @@ -701,7 +703,7 @@ export default function ChimneyPanel() { /> )} previewProp({ flueWallThickness: v })} @@ -723,16 +725,16 @@ export default function ChimneyPanel() { className="mt-2" onChange={(v) => handleUpdate({ bandStyle: v })} options={[ - { label: 'None', value: 'none' }, - { label: 'Single', value: 'single' }, - { label: 'Double', value: 'double' }, + { label: t('nodes.chimney.none'), value: 'none' }, + { label: t('nodes.chimney.single'), value: 'single' }, + { label: t('nodes.chimney.double'), value: 'double' }, ]} value={node.bandStyle ?? 'none'} /> {(node.bandStyle ?? 'none') !== 'none' && ( <> previewProp({ bandHeight: v })} @@ -744,7 +746,7 @@ export default function ChimneyPanel() { value={Math.round((node.bandHeight ?? 0.1) * 100) / 100} /> previewProp({ bandExtent: v })} @@ -756,7 +758,7 @@ export default function ChimneyPanel() { value={Math.round((node.bandExtent ?? 0.04) * 1000) / 1000} /> previewProp({ bandOffset: v })} @@ -778,8 +780,8 @@ export default function ChimneyPanel() { className="mt-2" onChange={(v) => handleUpdate({ cricketStyle: v })} options={[ - { label: 'None', value: 'none' }, - { label: 'Simple', value: 'simple' }, + { label: t('nodes.chimney.none'), value: 'none' }, + { label: t('nodes.chimney.simple'), value: 'simple' }, ]} value={node.cricketStyle ?? 'none'} /> @@ -789,13 +791,13 @@ export default function ChimneyPanel() { className="mt-2" onChange={(v) => handleUpdate({ cricketSide: v })} options={[ - { label: 'Front', value: 'front' }, - { label: 'Back', value: 'back' }, + { label: t('nodes.chimney.front'), value: 'front' }, + { label: t('nodes.chimney.back'), value: 'back' }, ]} value={node.cricketSide ?? 'front'} /> previewProp({ cricketLength: v })} @@ -807,7 +809,7 @@ export default function ChimneyPanel() { value={Math.round((node.cricketLength ?? 0.6) * 100) / 100} /> previewProp({ cricketHeight: v })} @@ -829,15 +831,15 @@ export default function ChimneyPanel() { className="mt-2" onChange={(v) => handleUpdate({ panelStyle: v })} options={[ - { label: 'None', value: 'none' }, - { label: 'Rectangular', value: 'rectangular' }, + { label: t('nodes.chimney.none'), value: 'none' }, + { label: t('nodes.chimney.rectangular'), value: 'rectangular' }, ]} value={node.panelStyle ?? 'none'} /> {(node.panelStyle ?? 'none') !== 'none' && ( <> previewProp({ panelDepth: v })} @@ -849,7 +851,7 @@ export default function ChimneyPanel() { value={Math.round((node.panelDepth ?? 0.03) * 1000) / 1000} /> previewProp({ panelHeight: v })} @@ -861,7 +863,7 @@ export default function ChimneyPanel() { value={Math.round((node.panelHeight ?? 0.8) * 100) / 100} /> previewProp({ panelOffsetTop: v })} @@ -873,7 +875,7 @@ export default function ChimneyPanel() { value={Math.round((node.panelOffsetTop ?? 0.15) * 100) / 100} /> previewProp({ panelMargin: v })} @@ -890,12 +892,12 @@ export default function ChimneyPanel() { )} - + } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/chimney/parametrics.ts b/packages/nodes/src/chimney/parametrics.ts index fafe128386..1e417e46a1 100644 --- a/packages/nodes/src/chimney/parametrics.ts +++ b/packages/nodes/src/chimney/parametrics.ts @@ -11,11 +11,16 @@ export const chimneyParametrics: ParametricDescriptor = { groups: [ { label: 'Body', + labelKey: 'nodes.chimney.body', fields: [ { key: 'bodyShape', kind: 'enum', options: ['square', 'round'], + optionLabelKeys: { + "square": "nodes.chimney.square", + "round": "nodes.chimney.round" + }, display: 'segmented', }, { key: 'width', kind: 'number', unit: 'm', min: 0.2, max: 1000, step: 0.05 }, @@ -42,11 +47,17 @@ export const chimneyParametrics: ParametricDescriptor = { }, { label: 'Shoulder', + labelKey: 'nodes.chimney.shoulder', fields: [ { key: 'shoulderStyle', kind: 'enum', options: ['none', 'tapered', 'corbeled'], + optionLabelKeys: { + "none": "nodes.chimney.none", + "tapered": "nodes.chimney.tapered", + "corbeled": "nodes.chimney.corbeled" + }, display: 'segmented', }, { @@ -71,12 +82,19 @@ export const chimneyParametrics: ParametricDescriptor = { }, { label: 'Cap', + labelKey: 'nodes.chimney.cap', fields: [ { key: 'cap', kind: 'boolean' }, { key: 'capShape', kind: 'enum', options: ['none', 'sloped', 'flat', 'stepped'], + optionLabelKeys: { + "none": "nodes.chimney.none", + "sloped": "nodes.chimney.sloped", + "flat": "nodes.chimney.flat", + "stepped": "nodes.chimney.stepped" + }, display: 'segmented', visibleIf: (n) => n.cap === true, }, @@ -102,12 +120,17 @@ export const chimneyParametrics: ParametricDescriptor = { }, { label: 'Flues', + labelKey: 'nodes.chimney.flues', fields: [ { key: 'flueCount', kind: 'number', min: 0, max: 4, step: 1 }, { key: 'flueShape', kind: 'enum', options: ['round', 'square'], + optionLabelKeys: { + "round": "nodes.chimney.round", + "square": "nodes.chimney.square" + }, display: 'segmented', visibleIf: (n) => n.flueCount > 0, }, @@ -141,11 +164,16 @@ export const chimneyParametrics: ParametricDescriptor = { }, { label: 'Cricket', + labelKey: 'nodes.chimney.cricket', fields: [ { key: 'cricketStyle', kind: 'enum', options: ['none', 'simple'], + optionLabelKeys: { + "none": "nodes.chimney.none", + "simple": "nodes.chimney.simple" + }, display: 'segmented', visibleIf: (n) => n.bodyShape === 'square', }, @@ -153,6 +181,10 @@ export const chimneyParametrics: ParametricDescriptor = { key: 'cricketSide', kind: 'enum', options: ['front', 'back'], + optionLabelKeys: { + "front": "nodes.chimney.front", + "back": "nodes.chimney.back" + }, display: 'segmented', visibleIf: (n) => n.bodyShape === 'square' && n.cricketStyle !== 'none', }, diff --git a/packages/nodes/src/chimney/roof-trim.ts b/packages/nodes/src/chimney/roof-trim.ts index 225b0c96d9..b311a8eae6 100644 --- a/packages/nodes/src/chimney/roof-trim.ts +++ b/packages/nodes/src/chimney/roof-trim.ts @@ -1,3 +1,5 @@ +'use client' + import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core' import { Brush, diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index b07d4090b5..f44b3567ed 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -411,8 +411,8 @@ export const columnDefinition: NodeDefinition = { // registry-first path mounts this and skips the legacy ``. tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Place column' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place column', labelKey: 'nodes.column.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], computeFloorplanLevelData: computeColumnFloorplanLevelData, floorplanDependsOnSiblings: true, @@ -435,6 +435,7 @@ export const columnDefinition: NodeDefinition = { presentation: { label: 'Column', + labelKey: 'panel.nodeType.column', description: 'A parametric column with configurable cross-section, base, and capital.', icon: { kind: 'url', src: '/icons/column.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/column/panel.tsx b/packages/nodes/src/column/panel.tsx index 519c5474b6..a98335a093 100644 --- a/packages/nodes/src/column/panel.tsx +++ b/packages/nodes/src/column/panel.tsx @@ -17,6 +17,7 @@ import { ToggleControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Move, Trash2 } from 'lucide-react' @@ -46,6 +47,7 @@ const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset const COLUMN_PROPORTION_PRESETS = { slender: { label: 'Slender', + labelKey: 'nodes.column.slender' as const, height: 3.6, width: 0.34, baseHeight: 0.18, @@ -56,6 +58,7 @@ const COLUMN_PROPORTION_PRESETS = { }, standard: { label: 'Standard', + labelKey: 'nodes.column.standard' as const, height: 2.9, width: 0.44, baseHeight: 0.22, @@ -66,6 +69,7 @@ const COLUMN_PROPORTION_PRESETS = { }, heavy: { label: 'Heavy', + labelKey: 'nodes.column.heavy' as const, height: 3, width: 0.58, baseHeight: 0.28, @@ -76,6 +80,7 @@ const COLUMN_PROPORTION_PRESETS = { }, stout: { label: 'Short / Stout', + labelKey: 'nodes.column.shortStout' as const, height: 2.2, width: 0.62, baseHeight: 0.3, @@ -92,21 +97,22 @@ const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map( ([value, preset]) => ({ value: value as ColumnProportionPresetId, label: preset.label, + labelKey: preset.labelKey, }), ) -const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportStyle'] }> = [ - { label: 'Vertical', value: 'vertical' }, - { label: 'A-Frame', value: 'a-frame' }, - { label: 'Y Support', value: 'y-frame' }, - { label: 'V Support', value: 'v-frame' }, - { label: 'X Brace', value: 'x-brace' }, - { label: 'K Brace', value: 'k-brace' }, - { label: 'Single Strut', value: 'single-strut' }, - { label: 'Tripod', value: 'tripod' }, - { label: 'Trestle', value: 'trestle' }, - { label: 'Portal Frame', value: 'portal-frame' }, - { label: 'Box Frame', value: 'box-frame' }, +const SUPPORT_STYLE_OPTIONS: Array<{ labelKey: string; value: ColumnNode['supportStyle'] }> = [ + { labelKey: 'nodes.column.vertical', value: 'vertical' }, + { labelKey: 'nodes.column.aFrame', value: 'a-frame' }, + { labelKey: 'nodes.column.yFrame', value: 'y-frame' }, + { labelKey: 'nodes.column.vFrame', value: 'v-frame' }, + { labelKey: 'nodes.column.xBrace', value: 'x-brace' }, + { labelKey: 'nodes.column.kBrace', value: 'k-brace' }, + { labelKey: 'nodes.column.singleStrut', value: 'single-strut' }, + { labelKey: 'nodes.column.tripod', value: 'tripod' }, + { labelKey: 'nodes.column.trestle', value: 'trestle' }, + { labelKey: 'nodes.column.portalFrame', value: 'portal-frame' }, + { labelKey: 'nodes.column.boxFrame', value: 'box-frame' }, ] type NonVerticalSupportStyle = Exclude @@ -287,6 +293,7 @@ function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial< } export default function ColumnPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) @@ -347,10 +354,10 @@ export default function ColumnPanel() { - + - +
{SUPPORT_STYLE_OPTIONS.map((option) => { const isSelected = supportStyle === option.value @@ -404,7 +411,7 @@ export default function ColumnPanel() { }} type="button" > - {option.label} + {t(option.labelKey)} ) })} @@ -412,7 +419,7 @@ export default function ColumnPanel() { {isBraceSupport ? ( <> handleUpdate({ braceWidth: value, width: value })} @@ -422,7 +429,7 @@ export default function ColumnPanel() { value={node.braceWidth ?? node.width} /> handleUpdate({ braceDepth: value, depth: value })} @@ -439,7 +446,7 @@ export default function ColumnPanel() { [ { value: 'round', - label: 'Round', + labelKey: 'nodes.column.round' as const, icon: ( - {option.label} + {t(option.labelKey)} ) })}
handleUpdate({ edgeSoftness: value })} @@ -544,7 +551,7 @@ export default function ColumnPanel() { /> {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( handleUpdate({ shaftCornerRadius: value })} @@ -558,7 +565,7 @@ export default function ColumnPanel() { )}
- + {managedByLeanTo && (

Height and footprint are controlled by the lean-to extension. Rotate or change the @@ -574,17 +581,17 @@ export default function ColumnPanel() { }} value="" > - + {COLUMN_PROPORTION_OPTIONS.map((option) => ( ))} )} {!managedByLeanTo && ( handleUpdate({ height: value })} @@ -605,7 +612,7 @@ export default function ColumnPanel() { supportStyle === 'portal-frame' || supportStyle === 'box-frame') && ( @@ -624,7 +631,7 @@ export default function ColumnPanel() { /> )} handleUpdate({ bracePlateEnabled: checked })} /> ) : !managedByLeanTo ? ( <> @@ -683,7 +690,7 @@ export default function ColumnPanel() { /> {node.crossSection === 'rectangular' && ( handleUpdate({ depth: value })} @@ -698,7 +705,7 @@ export default function ColumnPanel() { {!isBraceSupport && ( - + {shaftProfile === 'straight' && ( handleUpdate({ shaftStartScale: value, shaftEndScale: value })} @@ -725,7 +732,7 @@ export default function ColumnPanel() { {shaftProfile === 'tapered' && ( <> handleUpdate({ shaftStartScale: value })} @@ -734,7 +741,7 @@ export default function ColumnPanel() { value={node.shaftStartScale ?? 0.82} /> handleUpdate({ shaftEndScale: value })} @@ -743,7 +750,7 @@ export default function ColumnPanel() { value={node.shaftEndScale ?? 0.72} /> handleUpdate({ shaftTaper: value })} @@ -756,7 +763,7 @@ export default function ColumnPanel() { {shaftProfile === 'bulged' && ( <> handleUpdate({ shaftStartScale: value, shaftEndScale: value })} @@ -765,7 +772,7 @@ export default function ColumnPanel() { value={node.shaftStartScale ?? 0.68} /> handleUpdate({ shaftBulge: value })} @@ -778,7 +785,7 @@ export default function ColumnPanel() { {shaftProfile === 'hourglass' && ( <> handleUpdate({ shaftStartScale: value, shaftEndScale: value })} @@ -787,7 +794,7 @@ export default function ColumnPanel() { value={node.shaftStartScale ?? 0.84} /> handleUpdate({ shaftBulge: value })} @@ -798,7 +805,7 @@ export default function ColumnPanel() { )} @@ -816,7 +823,7 @@ export default function ColumnPanel() { /> {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( handleUpdate({ shaftSegmentCount: Math.round(value) })} @@ -826,7 +833,7 @@ export default function ColumnPanel() { /> )} @@ -843,7 +850,7 @@ export default function ColumnPanel() { /> {(node.ringCount ?? 0) > 0 && ( handleUpdate({ ringThickness: value })} @@ -855,7 +862,7 @@ export default function ColumnPanel() { )} {(node.ringCount ?? 0) > 0 && ( handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} @@ -868,7 +875,7 @@ export default function ColumnPanel() { )} {!isBraceSupport && ( - + {node.capitalStyle !== 'none' && ( handleUpdate({ capitalHeight: value })} @@ -919,7 +926,7 @@ export default function ColumnPanel() { )} {node.capitalStyle !== 'none' && ( @@ -935,7 +942,7 @@ export default function ColumnPanel() { )} {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( handleUpdate({ capitalDepthScale: value })} @@ -946,7 +953,7 @@ export default function ColumnPanel() { )} {node.capitalStyle === 'stepped' && ( handleUpdate({ capitalTierCount: Math.round(value) })} @@ -957,7 +964,7 @@ export default function ColumnPanel() { )} {node.capitalStyle === 'stepped' && ( handleUpdate({ capitalStepSpread: value })} @@ -1009,15 +1016,15 @@ export default function ColumnPanel() { }} value={node.baseStyle ?? 'square-plinth'} > - - - - - + + + + + {node.baseStyle !== 'none' && ( handleUpdate({ baseHeight: value })} @@ -1029,7 +1036,7 @@ export default function ColumnPanel() { )} {node.baseStyle !== 'none' && ( @@ -1045,7 +1052,7 @@ export default function ColumnPanel() { )} {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( handleUpdate({ baseDepthScale: value })} @@ -1056,7 +1063,7 @@ export default function ColumnPanel() { )} {node.baseStyle === 'round-rings' && ( handleUpdate({ basePlinthHeightRatio: value })} @@ -1067,7 +1074,7 @@ export default function ColumnPanel() { )} {node.baseStyle === 'round-rings' && ( handleUpdate({ baseRoundBandScale: value })} @@ -1078,7 +1085,7 @@ export default function ColumnPanel() { )} {node.baseStyle === 'round-rings' && ( handleUpdate({ baseNeckScale: value })} @@ -1089,7 +1096,7 @@ export default function ColumnPanel() { )} {node.baseStyle === 'stepped-square' && ( handleUpdate({ baseTierCount: Math.round(value) })} @@ -1100,7 +1107,7 @@ export default function ColumnPanel() { )} {node.baseStyle === 'stepped-square' && ( handleUpdate({ baseStepSpread: value })} @@ -1112,9 +1119,9 @@ export default function ColumnPanel() { )} - + handleUpdate({ rotation: (value * Math.PI) / 180 })} @@ -1125,13 +1132,13 @@ export default function ColumnPanel() { /> - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/column/parametrics.ts b/packages/nodes/src/column/parametrics.ts index 0c3ace670b..26ddc6696c 100644 --- a/packages/nodes/src/column/parametrics.ts +++ b/packages/nodes/src/column/parametrics.ts @@ -15,6 +15,7 @@ export const columnParametrics: ParametricDescriptor = { groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'height', kind: 'number', unit: 'm', min: 0.5, max: 1000, step: 0.05 }, { key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 1000, step: 0.01 }, diff --git a/packages/nodes/src/construction-dimension/definition.ts b/packages/nodes/src/construction-dimension/definition.ts index c42fce7dd7..650ce320e3 100644 --- a/packages/nodes/src/construction-dimension/definition.ts +++ b/packages/nodes/src/construction-dimension/definition.ts @@ -70,15 +70,16 @@ export const constructionDimensionDefinition: NodeDefinition = { - linear: 'Linear', - radius: 'Radius', - diameter: 'Diameter', - 'center-mark': 'Center mark', - chord: 'Chord', - 'arc-length': 'Arc length', - angular: 'Angular', - coordinate: 'Coordinate', +const MODE_LABEL_KEYS: Record = { + linear: 'nodes.constructionDimension.modeOptions.linear', + radius: 'nodes.constructionDimension.modeOptions.radius', + diameter: 'nodes.constructionDimension.modeOptions.diameter', + 'center-mark': 'nodes.constructionDimension.modeOptions.center-mark', + chord: 'nodes.constructionDimension.modeOptions.chord', + 'arc-length': 'nodes.constructionDimension.modeOptions.arc-length', + angular: 'nodes.constructionDimension.modeOptions.angular', + coordinate: 'nodes.constructionDimension.modeOptions.coordinate', } -const DATUM_POLICY_OPTIONS: Array<{ label: string; value: ConstructionDimensionDatumPolicy }> = [ - { label: 'Centerline', value: 'centerline' }, - { label: 'Wall face', value: 'wall-face' }, - { label: 'Structural face', value: 'structural-face' }, - { label: 'Finish face', value: 'finish-face' }, +const DATUM_POLICY_OPTIONS: Array<{ + labelKey: string + value: ConstructionDimensionDatumPolicy +}> = [ + { labelKey: 'nodes.constructionDimension.datumPolicyOptions.centerline', value: 'centerline' }, + { labelKey: 'nodes.constructionDimension.datumPolicyOptions.wallFace', value: 'wall-face' }, + { + labelKey: 'nodes.constructionDimension.datumPolicyOptions.structuralFace', + value: 'structural-face', + }, + { labelKey: 'nodes.constructionDimension.datumPolicyOptions.finishFace', value: 'finish-face' }, ] -const TERMINATOR_OPTIONS: Array<{ label: string; value: ConstructionDimensionTerminator }> = [ - { label: 'Architectural tick', value: 'architectural-tick' }, - { label: 'Filled arrow', value: 'filled-arrow' }, - { label: 'Open arrow', value: 'open-arrow' }, - { label: 'Dot', value: 'dot' }, +const TERMINATOR_OPTIONS: Array<{ + labelKey: string + value: ConstructionDimensionTerminator +}> = [ + { + labelKey: 'nodes.constructionDimension.terminatorOptions.architectural-tick', + value: 'architectural-tick', + }, + { + labelKey: 'nodes.constructionDimension.terminatorOptions.filled-arrow', + value: 'filled-arrow', + }, + { + labelKey: 'nodes.constructionDimension.terminatorOptions.open-arrow', + value: 'open-arrow', + }, + { labelKey: 'nodes.constructionDimension.terminatorOptions.dot', value: 'dot' }, ] -const TEXT_POSITION_OPTIONS: Array<{ label: string; value: ConstructionDimensionTextPosition }> = [ - { label: 'Above line', value: 'above' }, - { label: 'Centered on line', value: 'centered' }, +const TEXT_POSITION_OPTIONS: Array<{ + labelKey: string + value: ConstructionDimensionTextPosition +}> = [ + { + labelKey: 'nodes.constructionDimension.textPositionOptions.above', + value: 'above', + }, + { + labelKey: 'nodes.constructionDimension.textPositionOptions.centered', + value: 'centered', + }, ] const IMPERIAL_PRECISION_OPTIONS: Array<{ - label: string + labelKey: string value: ConstructionDimensionImperialPrecision }> = [ - { label: 'Nearest inch', value: '1' }, - { label: 'Nearest 1/2 inch', value: '1/2' }, - { label: 'Nearest 1/4 inch', value: '1/4' }, - { label: 'Nearest 1/8 inch', value: '1/8' }, - { label: 'Nearest 1/16 inch', value: '1/16' }, + { labelKey: 'nodes.constructionDimension.imperialPrecisionOptions.1', value: '1' }, + { labelKey: 'nodes.constructionDimension.imperialPrecisionOptions.1/2', value: '1/2' }, + { labelKey: 'nodes.constructionDimension.imperialPrecisionOptions.1/4', value: '1/4' }, + { labelKey: 'nodes.constructionDimension.imperialPrecisionOptions.1/8', value: '1/8' }, + { labelKey: 'nodes.constructionDimension.imperialPrecisionOptions.1/16', value: '1/16' }, ] const METRIC_NOTATION_OPTIONS: Array<{ - label: string + labelKey: string value: ConstructionDimensionMetricNotation }> = [ - { label: 'Meters', value: 'meters' }, - { label: 'Millimeters', value: 'millimeters' }, + { + labelKey: 'nodes.constructionDimension.metricNotationOptions.meters', + value: 'meters', + }, + { + labelKey: 'nodes.constructionDimension.metricNotationOptions.millimeters', + value: 'millimeters', + }, ] export default function ConstructionDimensionPanel() { + const t = useTranslations() const selectedId = useViewer((state) => state.selection.selectedIds[0]) const setSelection = useViewer((state) => state.setSelection) const dimension = useScene((state) => { @@ -98,6 +133,9 @@ export default function ConstructionDimensionPanel() { ) const activeDrawingLabel = DRAWING_TYPE_OPTIONS.find((option) => option.id === activeDrawingType)?.label ?? 'Floor plan' + // Pass the resolved label through t() so it can also be localized if the + // drawing-type registry ever publishes a translation key alongside `label`. + const localizedDrawingLabel = t(activeDrawingLabel) const activePresentation = resolveConstructionDimensionDrawingPresentation( dimension, activeDrawingType, @@ -143,16 +181,16 @@ export default function ConstructionDimensionPanel() { setSelection({ selectedIds: [] })} - title="Construction Dimension" + title={t('nodes.constructionDimension.fallbackTitle')} width={320} > - +

- Mode - {MODE_LABELS[dimension.mode]} + {t('nodes.constructionDimension.mode')} + {t(MODE_LABEL_KEYS[dimension.mode])}
update({ featureCount })} @@ -162,7 +200,9 @@ export default function ConstructionDimensionPanel() { /> {supportsCenterMark ? (
- + update({ drawingType: drawingType as ConstructionDrawingType }) } @@ -185,7 +225,7 @@ export default function ConstructionDimensionPanel() { value={dimension.drawingType} /> updateDrawingPresentation( activeDrawingType, @@ -193,10 +233,15 @@ export default function ConstructionDimensionPanel() { ) } options={[ - { label: 'Shown', value: 'shown' }, - { label: 'Omitted', value: 'omit' }, + { label: t('nodes.constructionDimension.presentationOptions.shown'), value: 'shown' }, + { label: t('nodes.constructionDimension.presentationOptions.omit'), value: 'omit' }, ...(activeDrawingType === 'floor-plan' - ? [{ label: 'Controlled by foundation', value: 'controlled' }] + ? [ + { + label: t('nodes.constructionDimension.presentationOptions.controlled'), + value: 'controlled', + }, + ] : []), ]} value={activePresentation} @@ -213,83 +258,89 @@ export default function ConstructionDimensionPanel() { /> ) : null}

- Linked dimensions reuse the controller's associative anchors and update with it. + {t('nodes.constructionDimension.linkedDimensionsNote')}

- Segment numbers are one-based and apply only in this drawing view. + {t('nodes.constructionDimension.suppressedSegmentsNote')}

- + update({ prefix })} value={dimension.prefix} /> update({ suffix })} value={dimension.suffix} /> update({ textOverride: textOverride || null })} - placeholder="Use measured value" + placeholder={t('nodes.constructionDimension.textOverridePlaceholder')} value={dimension.textOverride ?? ''} /> - + update({ datumPolicy: datumPolicy as ConstructionDimensionDatumPolicy }) } - options={DATUM_POLICY_OPTIONS} + options={DATUM_POLICY_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={dimension.datumPolicy} /> update({ terminator: terminator as ConstructionDimensionTerminator }) } - options={TERMINATOR_OPTIONS} + options={TERMINATOR_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={dimension.terminator} /> update({ textPosition: textPosition as ConstructionDimensionTextPosition }) } - options={TEXT_POSITION_OPTIONS} + options={TEXT_POSITION_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={dimension.textPosition} /> update({ imperialPrecision: imperialPrecision as ConstructionDimensionImperialPrecision, }) } - options={IMPERIAL_PRECISION_OPTIONS} + options={IMPERIAL_PRECISION_OPTIONS.map((o) => ({ + label: t(o.labelKey), + value: o.value, + }))} value={dimension.imperialPrecision} /> update({ metricNotation: metricNotation as ConstructionDimensionMetricNotation }) } - options={METRIC_NOTATION_OPTIONS} + options={METRIC_NOTATION_OPTIONS.map((o) => ({ + label: t(o.labelKey), + value: o.value, + }))} value={dimension.metricNotation} /> update({ extensionStartGap })} @@ -298,7 +349,7 @@ export default function ConstructionDimensionPanel() { value={dimension.extensionStartGap} /> update({ extensionOvershoot })} @@ -308,12 +359,12 @@ export default function ConstructionDimensionPanel() { /> - + } - label="Delete" + label={t('common.delete')} onClick={() => { triggerSFX('sfx:structure-delete') deleteNode(dimension.id) @@ -347,13 +398,14 @@ function FoundationControllerField({ value: string onChange: (value: NonNullable) => void }) { + const t = useTranslations() const foundationControllers = useScene( useShallow((state) => selectFoundationControllers(state.nodes, dimensionId)), ) return ( onChange( controllingDimensionId as NonNullable< @@ -362,10 +414,10 @@ function FoundationControllerField({ ) } options={foundationControllers.map((controller) => ({ - label: controller.name || 'Foundation dimension', + label: controller.name || t('nodes.constructionDimension.defaultFoundationDimensionName'), value: controller.id, }))} - placeholder="No foundation dimensions" + placeholder={t('nodes.constructionDimension.noFoundationDimensions')} value={value} /> ) diff --git a/packages/nodes/src/cupola/definition.ts b/packages/nodes/src/cupola/definition.ts index f456ead02c..e872897428 100644 --- a/packages/nodes/src/cupola/definition.ts +++ b/packages/nodes/src/cupola/definition.ts @@ -155,12 +155,13 @@ export const cupolaDefinition: NodeDefinition = { move: () => import('./move-tool'), }, toolHints: [ - { key: 'Left click', label: 'Place cupola on roof' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place cupola on roof', labelKey: 'nodes.cupola.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Cupola', + labelKey: 'panel.nodeType.cupola', description: 'Louvered roof lantern with a dome or pyramid cap and optional finial.', icon: { kind: 'url', src: '/icons/cupola.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/cupola/panel.tsx b/packages/nodes/src/cupola/panel.tsx index 2d5913a341..0c07c58850 100644 --- a/packages/nodes/src/cupola/panel.tsx +++ b/packages/nodes/src/cupola/panel.tsx @@ -18,6 +18,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' @@ -30,6 +31,7 @@ import type { CupolaNode } from './schema' * flow the placement tool uses. Mirrors the box-vent panel. */ export default function CupolaPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -140,31 +142,31 @@ export default function CupolaPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Cupola'} + title={node.name || t('nodes.cupola.cupola')} width={300} > - + handleUpdate({ roofStyle: v as CupolaNode['roofStyle'] })} options={[ - { label: 'Dome', value: 'dome' }, - { label: 'Pyramid', value: 'pyramid' }, + { label: t('nodes.cupola.dome'), value: 'dome' }, + { label: t('nodes.cupola.pyramid'), value: 'pyramid' }, ]} value={node.roofStyle ?? 'dome'} /> handleUpdate({ finial: v === 'on' })} options={[ - { label: 'Finial', value: 'on' }, - { label: 'No Finial', value: 'off' }, + { label: t('nodes.cupola.finial'), value: 'on' }, + { label: t('nodes.cupola.noFinial'), value: 'off' }, ]} value={(node.finial ?? true) ? 'on' : 'off'} /> - + previewProp({ width: v })} @@ -176,7 +178,7 @@ export default function CupolaPanel() { value={Math.round(node.width * 100) / 100} /> previewProp({ depth: v })} @@ -188,7 +190,7 @@ export default function CupolaPanel() { value={Math.round(node.depth * 100) / 100} /> previewProp({ height: v })} @@ -201,9 +203,9 @@ export default function CupolaPanel() { /> - + @@ -219,7 +221,7 @@ export default function CupolaPanel() { value={Math.round((node.position[0] ?? 0) * 100) / 100} /> @@ -254,7 +256,7 @@ export default function CupolaPanel() { value={Math.round((node.position[2] ?? 0) * 100) / 100} /> previewProp({ rotation: (deg * Math.PI) / 180 })} @@ -267,18 +269,18 @@ export default function CupolaPanel() { /> - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/cupola/parametrics.ts b/packages/nodes/src/cupola/parametrics.ts index 238ad3a275..b75c564ae8 100644 --- a/packages/nodes/src/cupola/parametrics.ts +++ b/packages/nodes/src/cupola/parametrics.ts @@ -11,11 +11,16 @@ export const cupolaParametrics: ParametricDescriptor = { groups: [ { label: 'Style', + labelKey: 'common.style', fields: [ { key: 'roofStyle', kind: 'enum', options: ['dome', 'pyramid'], + optionLabelKeys: { + "dome": "nodes.cupola.dome", + "pyramid": "nodes.cupola.pyramid" + }, display: 'segmented', }, { key: 'finial', kind: 'boolean' }, @@ -23,6 +28,7 @@ export const cupolaParametrics: ParametricDescriptor = { }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 9c7025d05e..2a6a744f34 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -266,14 +266,15 @@ export const doorDefinition: NodeDefinition = { }, toolHints: [ - { key: 'Left click', label: 'Place door on wall' }, - { key: 'R', label: 'Flip side' }, - { key: 'Alt', label: 'Force place' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place door on wall', labelKey: 'nodes.door.toolHints.place' }, + { key: 'R', label: 'Flip side', labelKey: 'nodes.door.toolHints.flipSide' }, + { key: 'Alt', label: 'Force place', labelKey: 'editor.forcePlace' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Door', + labelKey: 'panel.nodeType.door', description: 'A door cut into a wall. Animated open/close state.', icon: { kind: 'url', src: '/icons/door.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 479ba7eed6..d2e12b98d0 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, DoorNode, diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index 12608e108b..17e9a2204e 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -12,6 +12,7 @@ import { ToggleControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' @@ -20,25 +21,25 @@ import { OpeningDocumentationFields } from '../shared/opening-documentation-fiel import { scaleHandleHeight } from './door-math' const doorTypeOptions = [ - { label: 'Hinged', value: 'hinged', available: true }, - { label: 'Double', value: 'double', available: true }, - { label: 'French', value: 'french', available: true }, - { label: 'Folding', value: 'folding', available: true }, - { label: 'Pocket', value: 'pocket', available: true }, - { label: 'Barn', value: 'barn', available: true }, - { label: 'Sliding', value: 'sliding', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.hinged', value: 'hinged', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.double', value: 'double', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.french', value: 'french', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.folding', value: 'folding', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.pocket', value: 'pocket', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.barn', value: 'barn', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.sliding', value: 'sliding', available: true }, ] satisfies { - label: string + labelKey: string value: DoorNode['doorType'] available: boolean }[] const garageDoorTypeOptions = [ - { label: 'Sectional', value: 'garage-sectional', available: true }, - { label: 'Roll-up', value: 'garage-rollup', available: true }, - { label: 'Tilt-up', value: 'garage-tiltup', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.sectional', value: 'garage-sectional', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.rollup', value: 'garage-rollup', available: true }, + { labelKey: 'nodes.door.doorTypeOptions.tiltup', value: 'garage-tiltup', available: true }, ] satisfies { - label: string + labelKey: string value: DoorNode['doorType'] available: boolean }[] @@ -134,6 +135,7 @@ function isSameDoorValue(current: unknown, next: unknown): boolean { } export default function DoorPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) @@ -520,10 +522,10 @@ export default function DoorPanel() { - +
@@ -550,9 +552,9 @@ export default function DoorPanel() { ) } options={[ - { label: 'Door', value: 'door' }, - { label: 'Opening', value: 'opening' }, - { label: 'Garage', value: 'garage' }, + { label: t('nodes.door.typeOptions.door'), value: 'door' }, + { label: t('nodes.door.typeOptions.opening'), value: 'opening' }, + { label: t('nodes.door.typeOptions.garage'), value: 'garage' }, ]} value={typeMode} /> @@ -577,7 +579,7 @@ export default function DoorPanel() { type="button" > - {option.label} + {t(option.labelKey)} ) })} @@ -600,7 +602,7 @@ export default function DoorPanel() { /> - + @@ -620,7 +622,7 @@ export default function DoorPanel() { } - label="Flip Side" + label={t('nodes.door.flipSide')} onClick={handleFlip} />
@@ -628,11 +630,11 @@ export default function DoorPanel() {
{showFoldSection && ( - +
- Panels + {t('nodes.door.panels')} handleUpdate({ leafCount: v === '2' ? 2 : 4 })} @@ -645,7 +647,7 @@ export default function DoorPanel() {
handleUpdate({ operationState: v / 100 })} @@ -659,24 +661,28 @@ export default function DoorPanel() { )} {showSlideSection && ( - +
- {doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'} + {doorType === 'pocket' + ? t('nodes.door.slideDirectionOptions.pocket') + : doorType === 'barn' + ? t('nodes.door.slideDirectionOptions.rail') + : t('nodes.door.slideDirectionOptions.panel')} handleUpdate({ slideDirection: v })} options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, + { label: t('common.directions.left'), value: 'left' }, + { label: t('common.directions.right'), value: 'right' }, ]} value={node.slideDirection ?? 'left'} />
handleUpdate({ operationState: v / 100 })} @@ -690,9 +696,9 @@ export default function DoorPanel() { )} {showGarageSection && ( - + handleUpdate({ operationState: v / 100 })} @@ -704,7 +710,7 @@ export default function DoorPanel() { /> {isSectionalGarageDoor && ( handleUpdate({ garagePanelCount: Math.round(v) })} @@ -717,9 +723,9 @@ export default function DoorPanel() { )} - + handleUpdate({ width: v })} @@ -730,7 +736,7 @@ export default function DoorPanel() { value={Math.round(node.width * 100) / 100} /> @@ -751,7 +757,7 @@ export default function DoorPanel() { {showDoorShapeSection && ( - +
@@ -769,9 +775,9 @@ export default function DoorPanel() { }) } options={[ - { label: 'Rect', value: 'rectangle' }, - { label: 'Rounded', value: 'rounded' }, - { label: 'Arch', value: 'arch' }, + { label: t('nodes.door.topShapeOptions.rect'), value: 'rectangle' }, + { label: t('nodes.door.topShapeOptions.rounded'), value: 'rounded' }, + { label: t('nodes.door.topShapeOptions.arch'), value: 'arch' }, ]} value={doorShape} /> @@ -784,15 +790,15 @@ export default function DoorPanel() { handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) } options={[ - { label: 'All', value: 'all' }, - { label: 'Individual', value: 'individual' }, + { label: t('nodes.door.radiusModeOptions.all'), value: 'all' }, + { label: t('nodes.door.radiusModeOptions.individual'), value: 'individual' }, ]} value={openingRadiusMode} />
{openingRadiusMode === 'all' ? ( previewDoorUpdate('cornerRadius', v)} @@ -805,8 +811,8 @@ export default function DoorPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], + [t('nodes.door.corners.topLeft'), 0], + [t('nodes.door.corners.topRight'), 1], ].map(([label, index]) => ( )} previewDoorUpdate('openingRevealRadius', v)} @@ -838,7 +844,7 @@ export default function DoorPanel() { )} {doorShape === 'arch' && ( handleUpdate({ archHeight: v })} @@ -853,7 +859,7 @@ export default function DoorPanel() { )} {showOpeningShapeSection && ( - +
@@ -866,9 +872,9 @@ export default function DoorPanel() { }) } options={[ - { label: 'Rect', value: 'rectangle' }, - { label: 'Rounded', value: 'rounded' }, - { label: 'Arch', value: 'arch' }, + { label: t('nodes.door.openingShapeOptions.rect'), value: 'rectangle' }, + { label: t('nodes.door.openingShapeOptions.rounded'), value: 'rounded' }, + { label: t('nodes.door.openingShapeOptions.arch'), value: 'arch' }, ]} value={openingShape} /> @@ -881,15 +887,15 @@ export default function DoorPanel() { handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) } options={[ - { label: 'All', value: 'all' }, - { label: 'Individual', value: 'individual' }, + { label: t('nodes.door.radiusModeOptions.all'), value: 'all' }, + { label: t('nodes.door.radiusModeOptions.individual'), value: 'individual' }, ]} value={openingRadiusMode} />
{openingRadiusMode === 'all' ? ( previewDoorUpdate('cornerRadius', v)} @@ -902,8 +908,8 @@ export default function DoorPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], + [t('nodes.door.corners.topLeft'), 0], + [t('nodes.door.corners.topRight'), 1], ].map(([label, index]) => ( )} previewDoorUpdate('openingRevealRadius', v)} @@ -935,7 +941,7 @@ export default function DoorPanel() { )} {openingShape === 'arch' && ( handleUpdate({ archHeight: v })} @@ -952,9 +958,9 @@ export default function DoorPanel() { {!isCutoutOnly && ( <> {showFrameSection && ( - + handleUpdate({ frameThickness: v })} @@ -964,7 +970,7 @@ export default function DoorPanel() { value={Math.round(node.frameThickness * 1000) / 1000} /> handleUpdate({ frameDepth: v })} @@ -977,9 +983,9 @@ export default function DoorPanel() { )} {showContentPaddingSection && ( - + handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} @@ -989,7 +995,7 @@ export default function DoorPanel() { value={Math.round(node.contentPadding[0] * 1000) / 1000} /> handleUpdate({ contentPadding: [node.contentPadding[0], v] })} @@ -1002,18 +1008,18 @@ export default function DoorPanel() { )} {showSwingSection && ( - +
{supportsHingeSide && (
- Hinges Side + {t('nodes.door.hingesSide')} handleUpdate({ hingesSide: v })} options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, + { label: t('common.directions.left'), value: 'left' }, + { label: t('common.directions.right'), value: 'right' }, ]} value={node.hingesSide} /> @@ -1021,13 +1027,13 @@ export default function DoorPanel() { )}
- Direction + {t('nodes.door.direction')} handleUpdate({ swingDirection: v })} options={[ - { label: 'Inward', value: 'inward' }, - { label: 'Outward', value: 'outward' }, + { label: t('nodes.door.swingDirectionOptions.inward'), value: 'inward' }, + { label: t('nodes.door.swingDirectionOptions.outward'), value: 'outward' }, ]} value={node.swingDirection} /> @@ -1037,16 +1043,16 @@ export default function DoorPanel() { )} {showThresholdSection && ( - + handleUpdate({ threshold: checked })} /> {node.threshold && (
handleUpdate({ thresholdHeight: v })} @@ -1061,18 +1067,18 @@ export default function DoorPanel() { )} {showHandleSection && ( - + {isSwingDoor && ( handleUpdate({ handle: checked })} /> )} {(node.handle || !isSwingDoor) && (
handleUpdate({ handleHeight: v })} @@ -1084,13 +1090,13 @@ export default function DoorPanel() { {supportsHandleSide && (
- Handle Side + {t('nodes.door.handleSide')} handleUpdate({ handleSide: v })} options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, + { label: t('common.directions.left'), value: 'left' }, + { label: t('common.directions.right'), value: 'right' }, ]} value={node.handleSide} /> @@ -1102,21 +1108,21 @@ export default function DoorPanel() { )} {showHardwareSection && ( - + handleUpdate({ doorCloser: checked })} /> handleUpdate({ panicBar: checked })} /> {node.panicBar && (
handleUpdate({ panicBarHeight: v })} @@ -1131,7 +1137,7 @@ export default function DoorPanel() { )} {showSegmentsSection && ( - + {node.segments.map((seg, i) => { const numCols = seg.columnRatios.length const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) @@ -1139,7 +1145,9 @@ export default function DoorPanel() { return (
- Segment {i + 1} + + {t('nodes.door.segmentLabel', { i: i + 1 })} +
setSegmentHeightRatio(i, v / 100)} @@ -1169,7 +1177,7 @@ export default function DoorPanel() { /> { @@ -1189,7 +1197,7 @@ export default function DoorPanel() { {normCols.map((ratio, ci) => ( setSegmentColumnRatio(i, ci, v / 100)} @@ -1200,7 +1208,7 @@ export default function DoorPanel() { /> ))} { @@ -1220,7 +1228,7 @@ export default function DoorPanel() { {seg.type === 'panel' && (
{ @@ -1235,7 +1243,7 @@ export default function DoorPanel() { value={Math.round(seg.panelInset * 1000) / 1000} /> { @@ -1257,7 +1265,7 @@ export default function DoorPanel() {
{ const updated = [ ...node.segments, @@ -1276,7 +1284,7 @@ export default function DoorPanel() { {node.segments.length > 1 && ( handleUpdate({ segments: node.segments.slice(0, -1) })} /> )} @@ -1286,18 +1294,18 @@ export default function DoorPanel() { )} - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/door/parametrics.ts b/packages/nodes/src/door/parametrics.ts index bdf0bee6ab..5afe200864 100644 --- a/packages/nodes/src/door/parametrics.ts +++ b/packages/nodes/src/door/parametrics.ts @@ -13,6 +13,7 @@ export const doorParametrics: ParametricDescriptor = { groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.5, max: 1000, step: 0.05 }, { key: 'height', kind: 'number', unit: 'm', min: 1.0, max: 1000, step: 0.05 }, @@ -20,6 +21,7 @@ export const doorParametrics: ParametricDescriptor = { }, { label: 'Frame', + labelKey: 'nodes.door.frame', fields: [ { key: 'frameThickness', kind: 'number', unit: 'm', min: 0.01, max: 0.2, step: 0.005 }, { key: 'frameDepth', kind: 'number', unit: 'm', min: 0.01, max: 0.3, step: 0.005 }, diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index a4b89b4181..45cba01402 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -1,3 +1,5 @@ +'use client' + import { type DormerNode, dormerWallFacePointToDormer, diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index aaef64f82c..c71833cf7c 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -324,13 +324,14 @@ export const dormerDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Place dormer on roof' }, - { key: 'R / Shift+R', label: 'Rotate ghost ±15°' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place dormer on roof', labelKey: 'nodes.dormer.toolHints.place' }, + { key: 'R / Shift+R', label: 'Rotate ghost', labelKey: 'nodes.dormer.toolHints.rotateGhost' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Dormer', + labelKey: 'panel.nodeType.dormer', description: 'House-shaped protrusion on a roof segment.', icon: { kind: 'url', src: '/icons/dormer.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/dormer/panel-actions-section.tsx b/packages/nodes/src/dormer/panel-actions-section.tsx index d8c8cdf013..3a8f4f8a94 100644 --- a/packages/nodes/src/dormer/panel-actions-section.tsx +++ b/packages/nodes/src/dormer/panel-actions-section.tsx @@ -1,6 +1,6 @@ 'use client' -import { ActionButton, ActionGroup, PanelSection } from '@pascal-app/editor' +import { ActionButton, ActionGroup, PanelSection, useTranslations } from '@pascal-app/editor' import { Copy, Move, Trash2 } from 'lucide-react' /** @@ -16,19 +16,20 @@ export function DormerActionsSection({ onDuplicate: () => void onDelete: () => void }) { + const t = useTranslations() return ( - + - } label="Move" onClick={onMove} /> + } label={t('common.move')} onClick={onMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={onDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={onDelete} /> diff --git a/packages/nodes/src/dormer/panel-position-section.tsx b/packages/nodes/src/dormer/panel-position-section.tsx index 545d55f15f..1d2437a720 100644 --- a/packages/nodes/src/dormer/panel-position-section.tsx +++ b/packages/nodes/src/dormer/panel-position-section.tsx @@ -8,7 +8,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { PanelSection, SliderControl } from '@pascal-app/editor' +import { PanelSection, SliderControl, useTranslations } from '@pascal-app/editor' import { useMemo } from 'react' import { Vector3 } from 'three' @@ -34,6 +34,7 @@ export function DormerPositionSection({ previewProp: (updates: Partial) => void commitProp: (updates: Partial) => void }) { + const t = useTranslations() const px = node.position[0] const py = node.position[1] const pz = node.position[2] @@ -165,9 +166,9 @@ export function DormerPositionSection({ } return ( - + { @@ -183,7 +184,7 @@ export function DormerPositionSection({ value={Math.round(worldX_now * 100) / 100} /> { @@ -199,7 +200,7 @@ export function DormerPositionSection({ value={Math.round(worldZ_now * 100) / 100} /> { diff --git a/packages/nodes/src/dormer/panel-windows-section.tsx b/packages/nodes/src/dormer/panel-windows-section.tsx index e6300d329a..d61f12e3b0 100644 --- a/packages/nodes/src/dormer/panel-windows-section.tsx +++ b/packages/nodes/src/dormer/panel-windows-section.tsx @@ -1,7 +1,7 @@ 'use client' import type { WindowNode } from '@pascal-app/core' -import { ActionButton, PanelSection } from '@pascal-app/editor' +import { ActionButton, PanelSection, useTranslations } from '@pascal-app/editor' import { Move, Pencil, Plus } from 'lucide-react' export function DormerWindowsSection({ @@ -17,8 +17,9 @@ export function DormerWindowsSection({ onEdit: (window: WindowNode) => void onMove: (window: WindowNode) => void }) { + const t = useTranslations() return ( - + {windows.length > 0 ? (
{windows.map((window, index) => ( @@ -43,7 +44,7 @@ export function DormerWindowsSection({ aria-label={`Edit ${window.name || `Window ${index + 1}`}`} className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground" onClick={() => onEdit(window)} - title="Edit window" + title={t('nodes.dormer.editWindow')} type="button" > @@ -52,17 +53,19 @@ export function DormerWindowsSection({ aria-label={`Move ${window.name || `Window ${index + 1}`}`} className="flex h-8 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground" onClick={() => onMove(window)} - title="Move window" + title={t('nodes.dormer.moveWindow')} type="button" > - Move + {t('editor.move')}
))}
) : ( -
No windows
+
+ {t('nodes.dormer.noWindows')} +
)}
@@ -70,12 +73,12 @@ export function DormerWindowsSection({ className="w-full" disabled={!canAdd} icon={} - label="Add Window" + label={t('nodes.dormer.addWindow')} onClick={onAdd} /> {!canAdd && (

- Increase the dormer width to add another window. + {t('nodes.dormer.increaseWidth')}

)}
diff --git a/packages/nodes/src/dormer/panel.tsx b/packages/nodes/src/dormer/panel.tsx index bd5cc438b2..824dd33029 100644 --- a/packages/nodes/src/dormer/panel.tsx +++ b/packages/nodes/src/dormer/panel.tsx @@ -20,6 +20,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useState } from 'react' @@ -33,27 +34,28 @@ type RoofType = DormerNode['roofType'] type ShedHighSide = DormerNode['shedHighSide'] type DormerSection = 'dormer' | 'window' -const ROOF_TYPE_OPTIONS: Array<{ label: string; value: RoofType }> = [ - { label: 'Gable', value: 'gable' }, - { label: 'Hip', value: 'hip' }, - { label: 'Shed', value: 'shed' }, - { label: 'Gambrel', value: 'gambrel' }, - { label: 'Dutch', value: 'dutch' }, - { label: 'Mansard', value: 'mansard' }, - { label: 'Flat', value: 'flat' }, +const ROOF_TYPE_OPTIONS: Array<{ labelKey: string; value: RoofType }> = [ + { labelKey: 'nodes.dormer.gable', value: 'gable' }, + { labelKey: 'nodes.dormer.hip', value: 'hip' }, + { labelKey: 'nodes.dormer.shed', value: 'shed' }, + { labelKey: 'nodes.dormer.gambrel', value: 'gambrel' }, + { labelKey: 'nodes.dormer.dutch', value: 'dutch' }, + { labelKey: 'nodes.dormer.mansard', value: 'mansard' }, + { labelKey: 'nodes.dormer.flat', value: 'flat' }, ] -const SHED_HIGH_SIDE_OPTIONS: Array<{ label: string; value: ShedHighSide }> = [ - { label: 'Rise Back', value: 'back' }, - { label: 'Rise Front', value: 'front' }, +const SHED_HIGH_SIDE_OPTIONS: Array<{ labelKey: string; value: ShedHighSide }> = [ + { labelKey: 'nodes.dormer.riseBack', value: 'back' }, + { labelKey: 'nodes.dormer.riseFront', value: 'front' }, ] -const SECTION_OPTIONS: Array<{ label: string; value: DormerSection }> = [ - { label: 'Dormer', value: 'dormer' }, - { label: 'Windows', value: 'window' }, +const SECTION_OPTIONS: Array<{ labelKey: string; value: DormerSection }> = [ + { labelKey: 'nodes.dormer.dormer', value: 'dormer' }, + { labelKey: 'nodes.dormer.window', value: 'window' }, ] export default function DormerPanel() { + const t = useTranslations() const [section, setSection] = useState('dormer') const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -175,7 +177,7 @@ export default function DormerPanel() { const newWindow = WindowNode.parse({ ...(template ? structuredClone(template) : defaultWindow), id, - name: `Window ${hostedWindows.length + 1}`, + name: t('nodes.dormer.defaultName', { count: hostedWindows.length + 1 }), parentId: node.id, dormerId: node.id, dormerFace: 'front', @@ -254,7 +256,7 @@ export default function DormerPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Dormer'} + title={node.name || t('nodes.dormer.dormer')} width={300} > - +
{SECTION_OPTIONS.map((option) => { const isSelected = section === option.value @@ -282,7 +284,7 @@ export default function DormerPanel() { onClick={() => setSection(option.value)} type="button" > - {option.label} + {t(option.labelKey)} ) })} @@ -291,9 +293,9 @@ export default function DormerPanel() { {section === 'dormer' && ( <> - + previewProp({ width: v })} @@ -305,7 +307,7 @@ export default function DormerPanel() { value={Math.round(node.width * 100) / 100} /> previewProp({ depth: v })} @@ -317,7 +319,7 @@ export default function DormerPanel() { value={Math.round(node.depth * 100) / 100} /> previewProp({ height: v })} @@ -329,7 +331,7 @@ export default function DormerPanel() { value={Math.round(node.height * 100) / 100} /> previewProp({ roofHeight: v })} @@ -342,7 +344,7 @@ export default function DormerPanel() { /> - +
{ROOF_TYPE_OPTIONS.map((option) => { const isSelected = node.roofType === option.value @@ -358,7 +360,7 @@ export default function DormerPanel() { onClick={() => handleUpdate({ roofType: option.value })} type="button" > - {option.label} + {t(option.labelKey)} ) })} @@ -366,7 +368,7 @@ export default function DormerPanel() { {node.roofType === 'shed' && ( - +
{SHED_HIGH_SIDE_OPTIONS.map((option) => { const isSelected = node.shedHighSide === option.value @@ -382,7 +384,7 @@ export default function DormerPanel() { onClick={() => handleUpdate({ shedHighSide: option.value })} type="button" > - {option.label} + {t(option.labelKey)} ) })} diff --git a/packages/nodes/src/dormer/parametrics.ts b/packages/nodes/src/dormer/parametrics.ts index fa9a214865..cb1a1c520f 100644 --- a/packages/nodes/src/dormer/parametrics.ts +++ b/packages/nodes/src/dormer/parametrics.ts @@ -9,6 +9,7 @@ export const dormerParametrics: ParametricDescriptor = { groups: [ { label: 'Dormer', + labelKey: 'nodes.dormer.dormer', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.5, max: 1000, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.5, max: 1000, step: 0.05 }, @@ -17,11 +18,21 @@ export const dormerParametrics: ParametricDescriptor = { }, { label: 'Dormer roof', + labelKey: 'nodes.dormer.dormerRoof', fields: [ { key: 'roofType', kind: 'enum', options: ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'], + optionLabelKeys: { + "hip": "nodes.dormer.hip", + "gable": "nodes.dormer.gable", + "shed": "nodes.dormer.shed", + "gambrel": "nodes.dormer.gambrel", + "dutch": "nodes.dormer.dutch", + "mansard": "nodes.dormer.mansard", + "flat": "nodes.dormer.flat" + }, display: 'select', }, { key: 'roofHeight', kind: 'number', unit: 'm', min: 0, max: 2, step: 0.05 }, @@ -29,6 +40,9 @@ export const dormerParametrics: ParametricDescriptor = { key: 'shedHighSide', kind: 'enum', options: ['back', 'front'], + optionLabelKeys: { + "back": "common.back" + }, display: 'segmented', visibleIf: (n) => n.roofType === 'shed', }, @@ -36,6 +50,7 @@ export const dormerParametrics: ParametricDescriptor = { }, { label: 'Hung wall', + labelKey: 'nodes.dormer.hungWall', fields: [ { key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 1000, step: 0.05 }, ], diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index 52d0e03e4a..8ae52b999e 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, emitter, diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index ff487c54dc..6d4cc56f9a 100644 --- a/packages/nodes/src/downspout/definition.ts +++ b/packages/nodes/src/downspout/definition.ts @@ -202,13 +202,14 @@ export const downspoutDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ - { key: 'Hover gutter', label: 'Highlight outlet' }, - { key: 'Left click', label: 'Drop downspout from outlet' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Hover gutter', label: 'Highlight outlet', labelKey: 'nodes.downspout.toolHints.highlightOutlet' }, + { key: 'Left click', label: 'Drop downspout from outlet', labelKey: 'nodes.downspout.toolHints.drop' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Downspout', + labelKey: 'panel.nodeType.downspout', description: 'Vertical drop pipe from a gutter outlet to the ground.', icon: { kind: 'url', src: '/icons/downspout.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/downspout/parametrics.ts b/packages/nodes/src/downspout/parametrics.ts index 2ab301768b..f4a91878ae 100644 --- a/packages/nodes/src/downspout/parametrics.ts +++ b/packages/nodes/src/downspout/parametrics.ts @@ -7,6 +7,7 @@ export const downspoutParametrics: ParametricDescriptor = { groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'length', kind: 'number', unit: 'm', min: 0.1, max: 1000, step: 0.05 }, { key: 'diameter', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 }, @@ -21,6 +22,7 @@ export const downspoutParametrics: ParametricDescriptor = { }, { label: 'Hardware', + labelKey: 'nodes.downspout.hardware', fields: [ // Wall straps clamping the run, like the gutter's hangers. { @@ -49,6 +51,7 @@ export const downspoutParametrics: ParametricDescriptor = { }, { label: 'Placement', + labelKey: 'common.placement', fields: [ // Slide the outlet (and so this downspout) along the eave. Edits // the linked outlet's offset on the host gutter — the only way to diff --git a/packages/nodes/src/duct-fitting/definition.ts b/packages/nodes/src/duct-fitting/definition.ts index 3416030d97..13cb5a9ae3 100644 --- a/packages/nodes/src/duct-fitting/definition.ts +++ b/packages/nodes/src/duct-fitting/definition.ts @@ -117,15 +117,16 @@ export const ductFittingDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ - { key: 'Click', label: 'Place fitting' }, - { key: 'Hover a duct end', label: 'Snap onto the run' }, - { key: 'R / T', label: 'Rotate ±45°' }, - { key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' }, - { key: 'Esc', label: 'Exit' }, + { key: 'Click', label: 'Place fitting', labelKey: 'nodes.ductFitting.toolHints.place' }, + { key: 'Hover a duct end', label: 'Snap onto the run', labelKey: 'nodes.ductFitting.toolHints.snap' }, + { key: 'R / T', label: 'Rotate ±45°', labelKey: 'contextualHelp.mep.fitting.rotate45' }, + { key: 'Alt', label: 'Switch rotation axis (Y → X → Z)', labelKey: 'contextualHelp.mep.fitting.switchAxis' }, + { key: 'Esc', label: 'Exit', labelKey: 'common.cancel' }, ], presentation: { label: 'Duct Fitting', + labelKey: 'panel.nodeType.ductFitting', description: 'Elbow, tee, reducer, or square-to-round transition connecting duct runs.', icon: { kind: 'url', src: '/icons/duct-fitting.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/duct-fitting/inspector-editors.tsx b/packages/nodes/src/duct-fitting/inspector-editors.tsx index 5839f9df5c..2720cbbe1c 100644 --- a/packages/nodes/src/duct-fitting/inspector-editors.tsx +++ b/packages/nodes/src/duct-fitting/inspector-editors.tsx @@ -1,6 +1,6 @@ 'use client' -import { ActionButton } from '@pascal-app/editor' +import { ActionButton, useTranslations } from '@pascal-app/editor' import { ArrowLeftRight } from 'lucide-react' import type { DuctFittingNode } from './schema' @@ -20,6 +20,7 @@ export function DuctFittingSizeSwapEditor({ node: DuctFittingNode onUpdate: (patch: Partial) => void }) { + const t = useTranslations() const nextWidth = clamp(node.height, WIDTH_MIN, WIDTH_MAX) const nextHeight = clamp(node.width, HEIGHT_MIN, HEIGHT_MAX) @@ -28,9 +29,9 @@ export function DuctFittingSizeSwapEditor({ } - label="Swap W/H" + label={t('nodes.ductFitting.swapWH')} onClick={() => onUpdate({ width: nextWidth, height: nextHeight })} - title="Swap width and height" + title={t('nodes.ductFitting.swapWidthHeight')} type="button" />
diff --git a/packages/nodes/src/duct-fitting/parametrics.ts b/packages/nodes/src/duct-fitting/parametrics.ts index b16c3c7abc..62ddbfae52 100644 --- a/packages/nodes/src/duct-fitting/parametrics.ts +++ b/packages/nodes/src/duct-fitting/parametrics.ts @@ -217,6 +217,7 @@ export const ductFittingParametrics: ParametricDescriptor = { groups: [ { label: 'Fitting', + labelKey: 'common.fitting', fields: [ { key: 'fittingType', @@ -252,6 +253,7 @@ export const ductFittingParametrics: ParametricDescriptor = { }, { label: 'Connections', + labelKey: 'common.connections', fields: [ { key: 'shape', @@ -349,6 +351,7 @@ export const ductFittingParametrics: ParametricDescriptor = { }, { label: 'Placement', + labelKey: 'common.placement', fields: [ { key: 'position', kind: 'vec3' }, { key: 'rotation', kind: 'vec3' }, diff --git a/packages/nodes/src/duct-segment/definition.ts b/packages/nodes/src/duct-segment/definition.ts index 7d23144f95..f42af759aa 100644 --- a/packages/nodes/src/duct-segment/definition.ts +++ b/packages/nodes/src/duct-segment/definition.ts @@ -175,17 +175,18 @@ export const ductSegmentDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ - { key: 'Click', label: 'Start segment' }, - { key: 'Click again', label: 'Place and continue' }, - { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, - { key: '[ / ]', label: 'Duct diameter down / up' }, - { key: 'Q', label: 'Round / rect trunk' }, - { key: 'C', label: 'Ceiling / floor height' }, - { key: 'Esc', label: 'Cancel start point' }, + { key: 'Click', label: 'Start segment', labelKey: 'nodes.ductSegment.toolHints.start' }, + { key: 'Click again', label: 'Place and continue', labelKey: 'nodes.ductSegment.toolHints.placeContinue' }, + { key: 'Alt + drag', label: 'Go vertical ↕, click to place', labelKey: 'nodes.ductSegment.toolHints.vertical' }, + { key: '[ / ]', label: 'Duct diameter down / up', labelKey: 'nodes.ductSegment.toolHints.diameter' }, + { key: 'Q', label: 'Round / rect trunk', labelKey: 'nodes.ductSegment.toolHints.trunk' }, + { key: 'C', label: 'Ceiling / floor height', labelKey: 'nodes.ductSegment.toolHints.height' }, + { key: 'Esc', label: 'Cancel start point', labelKey: 'common.cancel' }, ], presentation: { label: 'Duct', + labelKey: 'panel.nodeType.ductSegment', description: 'HVAC duct run — polyline of round, rect, or flat-oval sections.', icon: { kind: 'url', src: '/icons/duct.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/duct-segment/parametrics.ts b/packages/nodes/src/duct-segment/parametrics.ts index 76776b5eb3..5864527022 100644 --- a/packages/nodes/src/duct-segment/parametrics.ts +++ b/packages/nodes/src/duct-segment/parametrics.ts @@ -97,6 +97,7 @@ export const ductSegmentParametrics: ParametricDescriptor = { groups: [ { label: 'Air', + labelKey: 'nodes.ductSegment.air', fields: [ { key: 'system', @@ -141,6 +142,7 @@ export const ductSegmentParametrics: ParametricDescriptor = { }, { label: 'Construction', + labelKey: 'common.construction', fields: [ { key: 'ductMaterial', diff --git a/packages/nodes/src/duct-terminal/definition.ts b/packages/nodes/src/duct-terminal/definition.ts index ff32b20648..edab106a0c 100644 --- a/packages/nodes/src/duct-terminal/definition.ts +++ b/packages/nodes/src/duct-terminal/definition.ts @@ -79,14 +79,15 @@ export const ductTerminalDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ - { key: 'Click', label: 'Place register' }, - { key: 'M', label: 'Mount: floor / ceiling / wall' }, - { key: 'R / T', label: 'Rotate ±45° (floor / ceiling)' }, - { key: 'Esc', label: 'Exit' }, + { key: 'Click', label: 'Place register', labelKey: 'nodes.ductTerminal.toolHints.place' }, + { key: 'M', label: 'Mount: floor / ceiling / wall', labelKey: 'nodes.ductTerminal.toolHints.mount' }, + { key: 'R / T', label: 'Rotate ±45° (floor / ceiling)', labelKey: 'nodes.ductTerminal.toolHints.rotate' }, + { key: 'Esc', label: 'Exit', labelKey: 'common.cancel' }, ], presentation: { label: 'Register', + labelKey: 'panel.nodeType.ductTerminal', description: 'Duct terminal — supply register, ceiling diffuser, or return grille. Duct runs end at its collar.', icon: { kind: 'url', src: '/icons/registers.webp' }, diff --git a/packages/nodes/src/duct-terminal/parametrics.ts b/packages/nodes/src/duct-terminal/parametrics.ts index 4df7760ef7..b447b590f1 100644 --- a/packages/nodes/src/duct-terminal/parametrics.ts +++ b/packages/nodes/src/duct-terminal/parametrics.ts @@ -5,6 +5,7 @@ export const ductTerminalParametrics: ParametricDescriptor = { groups: [ { label: 'Terminal', + labelKey: 'nodes.ductTerminal.terminal', fields: [ { key: 'terminalType', @@ -21,6 +22,7 @@ export const ductTerminalParametrics: ParametricDescriptor = { }, { label: 'Face', + labelKey: 'nodes.ductTerminal.face', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 1000, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.05, max: 1000, step: 0.05 }, @@ -28,6 +30,7 @@ export const ductTerminalParametrics: ParametricDescriptor = { }, { label: 'Collar', + labelKey: 'nodes.ductTerminal.collar', fields: [ { key: 'collarShape', @@ -66,6 +69,7 @@ export const ductTerminalParametrics: ParametricDescriptor = { }, { label: 'Placement', + labelKey: 'common.placement', fields: [{ key: 'position', kind: 'vec3' }], }, ], diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts index 5d610b099c..a061c3e9ef 100644 --- a/packages/nodes/src/elevator/definition.ts +++ b/packages/nodes/src/elevator/definition.ts @@ -182,9 +182,9 @@ export const elevatorDefinition: NodeDefinition = { // snapping chip shows during placement. snapDraftDirectional: false, toolHints: [ - { key: 'Left click', label: 'Place elevator' }, - { key: 'R / T', label: 'Rotate' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place elevator', labelKey: 'nodes.elevator.toolHints.place' }, + { key: 'R / T', label: 'Rotate', labelKey: 'editor.rotate' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], surfaceRole: 'joinery', @@ -272,6 +272,7 @@ export const elevatorDefinition: NodeDefinition = { presentation: { label: 'Elevator', + labelKey: 'panel.nodeType.elevator', description: 'A multi-level elevator shaft with configurable openings per level.', icon: { kind: 'url', src: '/icons/wallcut.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/elevator/panel.tsx b/packages/nodes/src/elevator/panel.tsx index f716e1b3c9..c65373162d 100644 --- a/packages/nodes/src/elevator/panel.tsx +++ b/packages/nodes/src/elevator/panel.tsx @@ -23,6 +23,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Send, Trash2 } from 'lucide-react' @@ -108,29 +109,29 @@ type ElevatorMetricKey = type ElevatorAccessField = 'disabledLevelIds' | 'serviceOnlyLevelIds' const DOOR_STYLE_OPTIONS: Array<{ - label: string + labelKey: string value: ElevatorNode['doorStyle'] }> = [ - { label: 'Center opening', value: 'center-opening' }, - { label: 'Single left', value: 'single-left' }, - { label: 'Single right', value: 'single-right' }, + { labelKey: 'nodes.elevator.centerOpening', value: 'center-opening' }, + { labelKey: 'nodes.elevator.singleLeft', value: 'single-left' }, + { labelKey: 'nodes.elevator.singleRight', value: 'single-right' }, ] const DOOR_PANEL_STYLE_OPTIONS: Array<{ - label: string + labelKey: string value: ElevatorNode['doorPanelStyle'] }> = [ - { label: 'Glass frame', value: 'glass-frame' }, - { label: 'Solid panel', value: 'solid-panel' }, - { label: 'Segmented panel', value: 'segmented-panel' }, + { labelKey: 'nodes.elevator.glassFrame', value: 'glass-frame' }, + { labelKey: 'nodes.elevator.solidPanel', value: 'solid-panel' }, + { labelKey: 'nodes.elevator.segmentedPanel', value: 'segmented-panel' }, ] const SHAFT_STYLE_OPTIONS: Array<{ - label: string + labelKey: string value: ElevatorNode['shaftStyle'] }> = [ - { label: 'Solid', value: 'solid' }, - { label: 'Glass', value: 'glass' }, + { labelKey: 'nodes.elevator.solid', value: 'solid' }, + { labelKey: 'nodes.elevator.glass', value: 'glass' }, ] function roundMeters(value: number) { @@ -158,6 +159,7 @@ function degreesToRadians(degrees: number) { } export default function ElevatorPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) @@ -321,7 +323,7 @@ export default function ElevatorPanel() { const duplicate = ElevatorNodeSchema.parse({ ...structuredClone(node), id: undefined, - name: node.name ? `${node.name} Copy` : 'Elevator Copy', + name: node.name ? t('nodes.elevator.copy', { name: node.name }) : t('nodes.elevator.defaultCopy'), position: [node.position[0] + 1, node.position[1], node.position[2] + 1], metadata: { ...(stripDuplicateFlags(node.metadata) as Record), isNew: true }, }) @@ -473,29 +475,29 @@ export default function ElevatorPanel() { - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> - + { const position = getSupportedPosition(value, displayPosition[2]) previewTransform(position, displayRotation) @@ -511,7 +513,7 @@ export default function ElevatorPanel() { value={roundMeters(displayPosition[0])} /> { const position: ElevatorNode['position'] = [ displayPosition[0], @@ -535,7 +537,7 @@ export default function ElevatorPanel() { value={roundMeters(displayPosition[1])} /> { const position = getSupportedPosition(displayPosition[0], value) previewTransform(position, displayRotation) @@ -552,9 +554,9 @@ export default function ElevatorPanel() { /> - + previewTransform(displayPosition, degreesToRadians(degrees))} @@ -567,14 +569,14 @@ export default function ElevatorPanel() { />
{ triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation - Math.PI / 4) }} /> { triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation + Math.PI / 4) @@ -583,11 +585,11 @@ export default function ElevatorPanel() {
- +
- From + {t('nodes.elevator.from')}
@@ -604,7 +606,7 @@ export default function ElevatorPanel() {
- To + {t('nodes.elevator.to')}
@@ -622,7 +624,7 @@ export default function ElevatorPanel() {
- Default Floor + {t('nodes.elevator.defaultFloor')}
- + previewMetric('width', value)} @@ -652,7 +654,7 @@ export default function ElevatorPanel() { value={displayNode.width} /> previewMetric('depth', value)} @@ -664,7 +666,7 @@ export default function ElevatorPanel() { value={displayNode.depth} /> previewMetric('cabHeight', value)} @@ -677,10 +679,10 @@ export default function ElevatorPanel() { /> - +
- Shaft Style + {t('nodes.elevator.shaftStyle')}
previewMetric('shaftWidth', Math.max(value, displayNode.width))} @@ -709,7 +711,7 @@ export default function ElevatorPanel() { value={displayShaftWidth} /> previewMetric('shaftDepth', Math.max(value, displayNode.depth))} @@ -721,7 +723,7 @@ export default function ElevatorPanel() { value={displayShaftDepth} /> previewMetric('shaftWallThickness', value)} @@ -734,10 +736,10 @@ export default function ElevatorPanel() { />
- +
- Opening Style + {t('nodes.elevator.openingStyle')}
- Door Type + {t('nodes.elevator.doorType')}
previewMetric('doorWidth', value)} @@ -786,7 +788,7 @@ export default function ElevatorPanel() { value={displayNode.doorWidth} /> previewMetric('doorHeight', value)} @@ -799,7 +801,7 @@ export default function ElevatorPanel() { />
- +
{servedLevels.map((level) => { const isDisabled = disabledLevelIds.has(level.id) @@ -811,7 +813,7 @@ export default function ElevatorPanel() { key={level.id} > - {level.name || `Level ${level.level}`} + {level.name || t('nodes.elevator.levelFallback', { n: level.level })}
@@ -844,7 +846,7 @@ export default function ElevatorPanel() {
- +
{servedLevels.map((level) => { const isActive = activeLevelId === level.id @@ -866,19 +868,19 @@ export default function ElevatorPanel() { type="button" > - {level.name || `Level ${level.level}`} + {level.name || t('nodes.elevator.levelFallback', { n: level.level })} {isDisabled ? ( - Disabled + {t('nodes.elevator.disabled')} ) : isServiceOnly ? ( - Service + {t('nodes.elevator.service')} ) : ( stopOrder && ( - Stop {stopOrder} + {t('nodes.elevator.stopLabel', { n: stopOrder })} ) )} @@ -896,9 +898,9 @@ export default function ElevatorPanel() {
- + handleUpdate({ speed: value })} @@ -908,7 +910,7 @@ export default function ElevatorPanel() { value={node.speed} /> handleUpdate({ doorDurationMs: value })} @@ -917,7 +919,7 @@ export default function ElevatorPanel() { value={node.doorDurationMs} /> handleUpdate({ dwellMs: value })} diff --git a/packages/nodes/src/eyebrow-vent/definition.ts b/packages/nodes/src/eyebrow-vent/definition.ts index 41f52d259d..aa8ba2fd13 100644 --- a/packages/nodes/src/eyebrow-vent/definition.ts +++ b/packages/nodes/src/eyebrow-vent/definition.ts @@ -156,12 +156,13 @@ export const eyebrowVentDefinition: NodeDefinition = { move: () => import('./move-tool'), }, toolHints: [ - { key: 'Left click', label: 'Place eyebrow vent on roof' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place eyebrow vent on roof', labelKey: 'nodes.eyebrowVent.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Eyebrow Vent', + labelKey: 'panel.nodeType.eyebrowVent', description: 'Low curved lens-shaped roof vent with a louvered front.', icon: { kind: 'url', src: '/icons/eyebrow-vent.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/eyebrow-vent/panel.tsx b/packages/nodes/src/eyebrow-vent/panel.tsx index 5ced7043e8..78e61be0da 100644 --- a/packages/nodes/src/eyebrow-vent/panel.tsx +++ b/packages/nodes/src/eyebrow-vent/panel.tsx @@ -18,6 +18,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' @@ -30,6 +31,7 @@ import type { EyebrowVentNode } from './schema' * placement tool uses. Mirrors the box-vent / cupola panel. */ export default function EyebrowVentPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -142,21 +144,21 @@ export default function EyebrowVentPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Eyebrow Vent'} + title={node.name || t('nodes.eyebrowVent.fallbackTitle')} width={300} > - + handleUpdate({ style: v as EyebrowVentNode['style'] })} options={[ - { label: 'Scoop', value: 'scoop' }, - { label: 'Half-round', value: 'half-round' }, - { label: 'Slant-box', value: 'slant-box' }, + { label: t('nodes.eyebrowVent.scoop'), value: 'scoop' }, + { label: t('nodes.eyebrowVent.halfRound'), value: 'half-round' }, + { label: t('nodes.eyebrowVent.slantBox'), value: 'slant-box' }, ]} value={node.style ?? 'scoop'} /> previewProp({ louverCount: Math.round(v) })} @@ -168,7 +170,7 @@ export default function EyebrowVentPanel() { /> {node.style === 'slant-box' ? ( previewProp({ backRatio: v })} @@ -181,9 +183,9 @@ export default function EyebrowVentPanel() { ) : null} - + previewProp({ width: v })} @@ -195,7 +197,7 @@ export default function EyebrowVentPanel() { value={Math.round(node.width * 100) / 100} /> previewProp({ depth: v })} @@ -207,7 +209,7 @@ export default function EyebrowVentPanel() { value={Math.round(node.depth * 100) / 100} /> previewProp({ height: v })} @@ -220,9 +222,9 @@ export default function EyebrowVentPanel() { /> - + @@ -238,7 +240,7 @@ export default function EyebrowVentPanel() { value={Math.round((node.position[0] ?? 0) * 100) / 100} /> @@ -273,7 +275,7 @@ export default function EyebrowVentPanel() { value={Math.round((node.position[2] ?? 0) * 100) / 100} /> previewProp({ rotation: (deg * Math.PI) / 180 })} @@ -286,18 +288,18 @@ export default function EyebrowVentPanel() { /> - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/eyebrow-vent/parametrics.ts b/packages/nodes/src/eyebrow-vent/parametrics.ts index 041e421fc1..2af601198e 100644 --- a/packages/nodes/src/eyebrow-vent/parametrics.ts +++ b/packages/nodes/src/eyebrow-vent/parametrics.ts @@ -11,11 +11,15 @@ export const eyebrowVentParametrics: ParametricDescriptor = { groups: [ { label: 'Style', + labelKey: 'common.style', fields: [ { key: 'style', kind: 'enum', options: ['scoop', 'half-round', 'slant-box'], + optionLabelKeys: { + "scoop": "nodes.eyebrowVent.scoop" + }, display: 'segmented', }, { key: 'louverCount', kind: 'number', min: 0, max: 8, step: 1 }, @@ -24,6 +28,7 @@ export const eyebrowVentParametrics: ParametricDescriptor = { }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.2, max: 1000, step: 0.05 }, diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 9178f5df75..f9c13965c8 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -384,12 +384,13 @@ export const fenceDefinition: NodeDefinition = { }, toolHints: [ - { key: 'Left click', label: 'Set fence start / end' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Set fence start / end', labelKey: 'nodes.fence.toolHints.setStartEnd' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Fence', + labelKey: 'panel.nodeType.fence', description: 'A straight or curved fence segment with configurable posts and infill.', icon: { kind: 'url', src: '/icons/fence.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/fence/inspector-editors.tsx b/packages/nodes/src/fence/inspector-editors.tsx index d8ef95938e..d0b7794365 100644 --- a/packages/nodes/src/fence/inspector-editors.tsx +++ b/packages/nodes/src/fence/inspector-editors.tsx @@ -7,7 +7,7 @@ import { getWallCurveLength, normalizeWallCurveOffset, } from '@pascal-app/core' -import { SliderControl } from '@pascal-app/editor' +import { SliderControl, useTranslations } from '@pascal-app/editor' /** * Custom inspector editors for fence fields that don't map to a single @@ -30,6 +30,7 @@ export function FenceLengthEditor({ node: FenceNode onUpdate: (patch: Partial) => void }) { + const t = useTranslations() const length = getWallCurveLength(node) const handleChange = (newLength: number) => { @@ -49,7 +50,7 @@ export function FenceLengthEditor({ return ( ) => void }) { + const t = useTranslations() const curveOffset = getClampedWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node) return ( onUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })} diff --git a/packages/nodes/src/fence/parametrics.ts b/packages/nodes/src/fence/parametrics.ts index c26f529d83..933842f762 100644 --- a/packages/nodes/src/fence/parametrics.ts +++ b/packages/nodes/src/fence/parametrics.ts @@ -18,6 +18,7 @@ export const fenceParametrics: ParametricDescriptor = { groups: [ { label: 'Style', + labelKey: 'common.style', fields: [ { key: 'style', @@ -36,6 +37,7 @@ export const fenceParametrics: ParametricDescriptor = { }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ // Length / Curve drive start/end + the single sagitta — meaningless // for a multi-point spline fence, so hide them when `path` is set. @@ -57,6 +59,7 @@ export const fenceParametrics: ParametricDescriptor = { }, { label: 'Structure', + labelKey: 'nodes.fence.structure', fields: [ { key: 'baseHeight', kind: 'number', unit: 'm', min: 0.04, max: 1, step: 0.01 }, { key: 'topRailHeight', kind: 'number', unit: 'm', min: 0.01, max: 0.25, step: 0.005 }, diff --git a/packages/nodes/src/guide/definition.ts b/packages/nodes/src/guide/definition.ts index a60c5126ab..18e3d5f7d3 100644 --- a/packages/nodes/src/guide/definition.ts +++ b/packages/nodes/src/guide/definition.ts @@ -47,6 +47,7 @@ export const guideDefinition: NodeDefinition = { presentation: { label: 'Guide', + labelKey: 'panel.nodeType.guide', description: 'A measurement / reference annotation (linear, area, or arc).', icon: { kind: 'url', src: '/icons/blueprint.webp' }, paletteSection: 'site', diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 129dfc8fa6..d198b63b16 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -185,12 +185,13 @@ export const gutterDefinition: NodeDefinition = { move: () => import('./move-tool'), }, toolHints: [ - { key: 'Left click', label: 'Place gutter on roof eave' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place gutter on roof eave', labelKey: 'nodes.gutter.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Gutter', + labelKey: 'panel.nodeType.gutter', description: 'Rain-water channel running along the eave of a roof segment.', icon: { kind: 'url', src: '/icons/gutter.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx index 640312f6f3..a89866bd3a 100644 --- a/packages/nodes/src/gutter/downspouts-panel.tsx +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -9,7 +9,7 @@ import { type RoofSegmentNode, useScene, } from '@pascal-app/core' -import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor' +import { ActionButton, ActionGroup, PanelSection, triggerSFX, useTranslations } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useShallow } from 'zustand/react/shallow' import { computeEaveY } from './eave-snap' @@ -56,6 +56,7 @@ function nextOutletOffset(gutter: GutterNode): number { * on it, and each row's ✕ removes both the downspout and its outlet. */ export default function DownspoutsPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined const setSelection = useViewer((s) => s.setSelection) @@ -128,7 +129,7 @@ export default function DownspoutsPanel() { } return ( - +
{downspouts.map((d, i) => (
handleSelectDownspout(d.id as AnyNodeId)} type="button" > - {d.name || `Downspout ${i + 1}`} + {d.name || t('nodes.gutter.downspoutDefaultName', { index: i + 1 })}
))} - +
diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index 622d390ccb..cd3e8fe79c 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -5,6 +5,7 @@ export const gutterParametrics: ParametricDescriptor = { groups: [ { label: 'Profile', + labelKey: 'nodes.gutter.profile', fields: [ { key: 'profile', @@ -16,6 +17,7 @@ export const gutterParametrics: ParametricDescriptor = { }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'length', kind: 'number', unit: 'm', min: 0.2, max: 1000, step: 0.05 }, { key: 'size', kind: 'number', unit: 'm', min: 0.05, max: 0.3, step: 0.005 }, @@ -31,6 +33,7 @@ export const gutterParametrics: ParametricDescriptor = { }, { label: 'End caps', + labelKey: 'nodes.gutter.endCaps', fields: [ { key: 'endCapLeft', kind: 'boolean' }, { key: 'endCapRight', kind: 'boolean' }, @@ -38,6 +41,7 @@ export const gutterParametrics: ParametricDescriptor = { }, { label: 'Hangers', + labelKey: 'nodes.gutter.hangers', fields: [ { key: 'hangerStyle', diff --git a/packages/nodes/src/hvac-equipment/definition.ts b/packages/nodes/src/hvac-equipment/definition.ts index db61cdff29..29513a0312 100644 --- a/packages/nodes/src/hvac-equipment/definition.ts +++ b/packages/nodes/src/hvac-equipment/definition.ts @@ -85,13 +85,14 @@ export const hvacEquipmentDefinition: NodeDefinition = tool: () => import('./tool'), toolHints: [ - { key: 'Click', label: 'Place unit' }, - { key: 'R / T', label: 'Rotate ±45°' }, - { key: 'Esc', label: 'Exit' }, + { key: 'Click', label: 'Place unit', labelKey: 'nodes.hvacEquipment.toolHints.place' }, + { key: 'R / T', label: 'Rotate ±45°', labelKey: 'contextualHelp.mep.fitting.rotate45' }, + { key: 'Esc', label: 'Exit', labelKey: 'common.cancel' }, ], presentation: { label: 'HVAC Unit', + labelKey: 'panel.nodeType.hvacEquipment', description: 'Furnace, air handler, or condenser — duct runs connect to its supply/return collars.', icon: { kind: 'url', src: '/icons/HVAC.webp' }, diff --git a/packages/nodes/src/hvac-equipment/parametrics.ts b/packages/nodes/src/hvac-equipment/parametrics.ts index 9130f9ed0a..6b61ee8bb3 100644 --- a/packages/nodes/src/hvac-equipment/parametrics.ts +++ b/packages/nodes/src/hvac-equipment/parametrics.ts @@ -5,6 +5,7 @@ export const hvacEquipmentParametrics: ParametricDescriptor = groups: [ { label: 'Equipment', + labelKey: 'nodes.hvacEquipment.equipment', fields: [ { key: 'equipmentType', @@ -16,6 +17,7 @@ export const hvacEquipmentParametrics: ParametricDescriptor = }, { label: 'Cabinet', + labelKey: 'nodes.hvacEquipment.cabinet', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, @@ -24,6 +26,7 @@ export const hvacEquipmentParametrics: ParametricDescriptor = }, { label: 'Supply', + labelKey: 'nodes.hvacEquipment.supply', fields: [ { key: 'supplyShape', @@ -63,6 +66,7 @@ export const hvacEquipmentParametrics: ParametricDescriptor = }, { label: 'Return', + labelKey: 'nodes.hvacEquipment.return', fields: [ { key: 'returnShape', diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 4f6204c965..784bb5a97e 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -330,15 +330,16 @@ export const itemDefinition: NodeDefinition = { floorplanMoveTarget: itemFloorplanMoveTarget, toolHints: [ - { key: 'Left click', label: 'Place item' }, - { key: 'R / T', label: 'Rotate' }, - { key: 'Shift', label: 'Cycle snapping mode' }, - { key: 'Alt', label: 'Force place' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place item', labelKey: 'nodes.item.toolHints.place' }, + { key: 'R / T', label: 'Rotate', labelKey: 'editor.rotate' }, + { key: 'Shift', label: 'Cycle snapping mode', labelKey: 'nodes.item.toolHints.cycleSnap' }, + { key: 'Alt', label: 'Force place', labelKey: 'editor.forcePlace' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Item', + labelKey: 'panel.nodeType.item', description: 'A catalog-backed item (furniture, fixtures, decorations).', icon: { kind: 'url', src: '/icons/item.webp' }, paletteSection: 'furnish', diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx index 60f8d93f7b..aae18b5870 100644 --- a/packages/nodes/src/item/panel.tsx +++ b/packages/nodes/src/item/panel.tsx @@ -10,6 +10,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react' @@ -30,6 +31,7 @@ import { useCallback, useRef, useState } from 'react' * (see the wiki / plan recipe). */ export default function ItemPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) @@ -105,7 +107,7 @@ export default function ItemPanel() { title={node.name || node.asset.name} width={300} > - + @@ -156,7 +158,7 @@ export default function ItemPanel() { /> - + @@ -176,7 +178,7 @@ export default function ItemPanel() { />
{ triggerSFX('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI @@ -185,7 +187,7 @@ export default function ItemPanel() { }} /> { triggerSFX('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI @@ -196,10 +198,10 @@ export default function ItemPanel() {
- +
- Uniform Scale + {t('nodes.item.uniformScale')} ))} @@ -264,15 +268,15 @@ export default function RoofPanel() { } - label="Draw Segment" + label={t('nodes.roof.addSegment')} onClick={handleAddSegment} /> - + { const pos = [...node.position] as [number, number, number] pos[0] = v @@ -284,7 +288,7 @@ export default function RoofPanel() { value={Math.round(node.position[0] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[1] = v @@ -296,7 +300,7 @@ export default function RoofPanel() { value={Math.round(node.position[1] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[2] = v @@ -308,7 +312,7 @@ export default function RoofPanel() { value={Math.round(node.position[2] * 100) / 100} /> { @@ -321,14 +325,14 @@ export default function RoofPanel() { />
{ triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation - Math.PI / 4 }) }} /> { triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation + Math.PI / 4 }) @@ -337,7 +341,7 @@ export default function RoofPanel() {
- +
{chimneys.map((chimney, i) => ( @@ -347,14 +351,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(chimney.id)} type="button" > - {chimney.name || `Chimney ${i + 1}`} - chimney + + {chimney.name || t('nodes.roof.defaultName.chimney', { count: i + 1 })} + + + {t('nodes.roof.kindLabel.chimney')} + ))} } - label="Add Chimney" + label={t('nodes.roof.addChimney')} onClick={() => activateTool('chimney')} /> @@ -368,14 +376,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(dormer.id)} type="button" > - {dormer.name || `Dormer ${i + 1}`} - dormer + + {dormer.name || t('nodes.roof.defaultName.dormer', { count: i + 1 })} + + + {t('nodes.roof.kindLabel.dormer')} + ))} } - label="Add Dormer" + label={t('nodes.roof.addDormer')} onClick={() => activateTool('dormer')} /> @@ -389,14 +401,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(skylight.id)} type="button" > - {skylight.name || `Skylight ${i + 1}`} - skylight + + {skylight.name || t('nodes.roof.defaultName.skylight', { count: i + 1 })} + + + {t('nodes.roof.kindLabel.skylight')} + ))} } - label="Add Skylight" + label={t('nodes.roof.addSkylight')} onClick={() => activateTool('skylight')} /> @@ -410,14 +426,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(panel.id)} type="button" > - {panel.name || `Solar Panel ${i + 1}`} - solar panel + + {panel.name || t('nodes.roof.defaultName.solarPanel', { count: i + 1 })} + + + {t('nodes.roof.kindLabel.solarPanel')} + ))} } - label="Add Solar Panel" + label={t('nodes.roof.addSolarPanel')} onClick={() => activateTool('solar-panel')} /> @@ -433,34 +453,31 @@ export default function RoofPanel() { > {vent.name || - (vent.type === 'box-vent' - ? `Box Vent ${i + 1}` - : vent.type === 'ridge-vent' - ? `Ridge Vent ${i + 1}` - : `Turbine Vent ${i + 1}`)} + t( + `nodes.roof.defaultName.${vent.type === 'box-vent' ? 'boxVent' : vent.type === 'ridge-vent' ? 'ridgeVent' : 'turbineVent'}`, + { count: i + 1 }, + )} - {vent.type === 'box-vent' - ? 'box vent' - : vent.type === 'ridge-vent' - ? 'ridge vent' - : 'turbine vent'} + {t( + `nodes.roof.kindLabel.${vent.type === 'box-vent' ? 'boxVent' : vent.type === 'ridge-vent' ? 'ridgeVent' : 'turbineVent'}`, + )} ))} onChange={setVentType} options={[ - { label: 'Box', value: 'box-vent' }, - { label: 'Ridge', value: 'ridge-vent' }, - { label: 'Turbine', value: 'turbine-vent' }, + { label: t('nodes.roof.box'), value: 'box-vent' }, + { label: t('nodes.roof.ridge'), value: 'ridge-vent' }, + { label: t('nodes.roof.turbine'), value: 'turbine-vent' }, ]} value={ventType} /> } - label="Add Vent" + label={t('nodes.roof.addVent')} onClick={() => activateTool(ventType)} /> @@ -470,7 +487,7 @@ export default function RoofPanel() { } - label="Add Cupola" + label={t('nodes.roof.addCupola')} onClick={() => activateTool('cupola')} /> @@ -480,7 +497,7 @@ export default function RoofPanel() { } - label="Add Eyebrow Vent" + label={t('nodes.roof.addEyebrowVent')} onClick={() => activateTool('eyebrow-vent')} /> @@ -494,14 +511,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(gutter.id)} type="button" > - {gutter.name || `Gutter ${i + 1}`} - gutter + + {gutter.name || t('nodes.roof.defaultName.gutter', { count: i + 1 })} + + + {t('nodes.roof.kindLabel.gutter')} + ))} } - label="Add Gutter" + label={t('nodes.roof.addGutter')} onClick={() => activateTool('gutter')} /> @@ -509,18 +530,18 @@ export default function RoofPanel() {
- + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/roof/tool.tsx b/packages/nodes/src/roof/tool.tsx index 323ff1ca38..ed4fa42fcf 100644 --- a/packages/nodes/src/roof/tool.tsx +++ b/packages/nodes/src/roof/tool.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AlignmentAnchor, type AnyNode, @@ -35,6 +37,7 @@ import { useFloorplanDraftPreview, useInteractionScope, useRegistryToolContext, + useTranslations, } from '@pascal-app/editor' import { generateRoofSegmentGeometry, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' @@ -49,6 +52,7 @@ import { } from 'three' import { createConicalRoofSectorAboveWall } from './conical-roof' import { resolveConicalRoofPlacement } from './conical-roof-placement' +import { getDefaultRoofName, getRoofPreviewName } from './naming' import { isStandardRoofWallEligible, parseRoofFootprintSource, @@ -191,6 +195,7 @@ const commitRoofPlacement = ( selectedIds: string[], quarterTurn: boolean, placementMode: RoofPlacementMode, + t: ReturnType, ): AnyNode['id'] | null => { const nodes = sceneApi.nodes() @@ -236,7 +241,7 @@ const commitRoofPlacement = ( }) const roof = RoofNode.parse({ ...defaults, - name: `Roof ${roofCount + 1}`, + name: getDefaultRoofName(roofCount + 1, t), position: resolved.position, support: resolved.support, children: [segment.id], @@ -309,7 +314,7 @@ const commitRoofPlacement = ( // Count existing roofs for naming const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length - const name = `Roof ${roofCount + 1}` + const name = getDefaultRoofName(roofCount + 1, t) const roofRotation = typeof defaults.rotation === 'number' ? defaults.rotation : 0 const placement = resolveRoofDraftPlacement( footprintWidth, @@ -355,6 +360,7 @@ const commitRoofFootprint = ( levelId: LevelNode['id'], target: RoofFootprintTarget, quarterTurn: boolean, + t: ReturnType, ): AnyNode['id'] | null => { if (!target.rectangular) return null const nodes = sceneApi.nodes() @@ -375,7 +381,7 @@ const commitRoofFootprint = ( }) const roof = RoofNode.parse({ ...defaults, - name: `Roof ${roofCount + 1}`, + name: getDefaultRoofName(roofCount + 1, t), position: [ target.center[0], resolveRoofFootprintElevation(levelId, target, nodes), @@ -578,6 +584,7 @@ function buildRoofGhostEdges( } export const RoofTool: React.FC = () => { + const t = useTranslations() const { activeLevelId: currentLevelId, sceneApi, selectNode } = useRegistryToolContext() const cursorRef = useRef(null) const outlineRef = useRef(null!) @@ -613,7 +620,7 @@ export const RoofTool: React.FC = () => { if (!currentLevelId) return const draft = RoofNode.parse({ ...useEditor.getState().toolDefaults.roof, - name: 'Roof preview', + name: getRoofPreviewName(t), parentId: currentLevelId, }) useInteractionScope.getState().begin({ @@ -844,7 +851,7 @@ export const RoofTool: React.FC = () => { { rectangularOnly: true }, ) if (!target) return - const roofId = commitRoofFootprint(sceneApi, currentLevelId, target, quarterTurnRef.current) + const roofId = commitRoofFootprint(sceneApi, currentLevelId, target, quarterTurnRef.current, t) if (roofId) selectNode(roofId) return } @@ -861,6 +868,7 @@ export const RoofTool: React.FC = () => { selectedIdsRef.current, quarterTurnRef.current, useRoofPlacementMode.getState().mode, + t, ) if (!roofId) return diff --git a/packages/nodes/src/scan/definition.ts b/packages/nodes/src/scan/definition.ts index ba13caf293..f404b55eee 100644 --- a/packages/nodes/src/scan/definition.ts +++ b/packages/nodes/src/scan/definition.ts @@ -46,6 +46,7 @@ export const scanDefinition: NodeDefinition = { presentation: { label: 'Capture', + labelKey: 'panel.nodeType.scan', description: 'A captured session with optional mesh, motion, media, and sensor data.', icon: { kind: 'url', src: '/icons/mesh.webp' }, paletteSection: 'site', diff --git a/packages/nodes/src/scan/parametrics.ts b/packages/nodes/src/scan/parametrics.ts index 1511ed3e41..a515578fab 100644 --- a/packages/nodes/src/scan/parametrics.ts +++ b/packages/nodes/src/scan/parametrics.ts @@ -4,6 +4,7 @@ export const scanParametrics: ParametricDescriptor = { groups: [ { label: 'Transform', + labelKey: 'common.transform', fields: [ { key: 'position', kind: 'vec3' }, { key: 'scale', kind: 'number', min: 0.01, max: 1000, step: 0.1 }, @@ -11,6 +12,7 @@ export const scanParametrics: ParametricDescriptor = { }, { label: 'Appearance', + labelKey: 'common.appearance', fields: [{ key: 'opacity', kind: 'number', unit: '%', min: 0, max: 100, step: 1 }], }, ], diff --git a/packages/nodes/src/shared/opening-documentation-fields.tsx b/packages/nodes/src/shared/opening-documentation-fields.tsx index 83a724befc..7762a42fb7 100644 --- a/packages/nodes/src/shared/opening-documentation-fields.tsx +++ b/packages/nodes/src/shared/opening-documentation-fields.tsx @@ -1,6 +1,11 @@ 'use client' -import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from '@pascal-app/editor' +import { + getLinearUnitLabel, + linearUnitToMeters, + metersToLinearUnit, + useTranslations, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' type OpeningDocumentationPatch = { @@ -29,11 +34,12 @@ export function OpeningDocumentationFields({ }: OpeningDocumentationPatch & { onChange: (patch: OpeningDocumentationPatch) => void }) { + const t = useTranslations() return (
onChange({ roughOpeningWidth: value })} value={roughOpeningWidth} /> onChange({ roughOpeningHeight: value })} value={roughOpeningHeight} />
onChange({ masonryOpeningWidth: value })} value={masonryOpeningWidth} /> onChange({ masonryOpeningHeight: value })} value={masonryOpeningHeight} />
onChange({ finishOpeningWidth: value })} value={finishOpeningWidth} /> onChange({ finishOpeningHeight: value })} value={finishOpeningHeight} />

- Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade. + {t('nodes.shared.leaveBlankHint')}

) diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index d583a6cd59..1426825cbc 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -265,12 +265,13 @@ export const shelfDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Place shelf' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place shelf', labelKey: 'nodes.shelf.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Shelf', + labelKey: 'panel.nodeType.shelf', description: 'A configurable shelving unit. Items host on each row.', icon: { kind: 'url', src: '/icons/shelf.webp' }, paletteSection: 'furnish', diff --git a/packages/nodes/src/shelf/parametrics.ts b/packages/nodes/src/shelf/parametrics.ts index 5da33dc80a..8ce5d44689 100644 --- a/packages/nodes/src/shelf/parametrics.ts +++ b/packages/nodes/src/shelf/parametrics.ts @@ -15,6 +15,7 @@ export const shelfParametrics: ParametricDescriptor = { groups: [ { label: 'Style', + labelKey: 'common.style', fields: [ { key: 'style', @@ -25,6 +26,7 @@ export const shelfParametrics: ParametricDescriptor = { }, { label: 'Topology', + labelKey: 'nodes.shelf.topology', fields: [ { key: 'rows', kind: 'number', min: 1, max: 8, step: 1 }, // Columns only meaningful for kinds with vertical dividers. @@ -69,6 +71,7 @@ export const shelfParametrics: ParametricDescriptor = { }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 1000, step: 0.05 }, @@ -78,6 +81,7 @@ export const shelfParametrics: ParametricDescriptor = { }, { label: 'Position', + labelKey: 'common.position', fields: [{ key: 'position', kind: 'vec3' }], }, ], diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts index 2ca24257e9..0d80d94fa7 100644 --- a/packages/nodes/src/site/definition.ts +++ b/packages/nodes/src/site/definition.ts @@ -50,6 +50,7 @@ export const siteDefinition: NodeDefinition = { presentation: { label: 'Site', + labelKey: 'panel.nodeType.site', description: 'The top-level container holding buildings, zones, and the property boundary.', icon: { kind: 'url', src: '/icons/site-flag.webp' }, paletteSection: 'site', diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index 1a3a12d42a..4b2192ce62 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -265,12 +265,13 @@ export const skylightDefinition: NodeDefinition = { move: () => import('./move-tool'), }, toolHints: [ - { key: 'Left click', label: 'Place skylight on roof' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place skylight on roof', labelKey: 'nodes.skylight.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Skylight', + labelKey: 'panel.nodeType.skylight', description: 'Framed glass opening on a roof segment.', icon: { kind: 'url', src: '/icons/skylight.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/skylight/panel.tsx b/packages/nodes/src/skylight/panel.tsx index 8630dfba9b..d69bd8bcfe 100644 --- a/packages/nodes/src/skylight/panel.tsx +++ b/packages/nodes/src/skylight/panel.tsx @@ -21,6 +21,7 @@ import { SegmentedControl, SliderControl, triggerSFX, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Trash2 } from 'lucide-react' @@ -31,6 +32,7 @@ const cn = (...classes: Array): string => classes.filter(Boolean).join(' ') export default function SkylightPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -261,10 +263,10 @@ export default function SkylightPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Skylight'} + title={node.name || t('nodes.skylight.skylight')} width={300} > - +
{SKYLIGHT_TYPE_ORDER.map((skylightType) => { const isSelected = activeSkylightType === skylightType @@ -288,7 +290,7 @@ export default function SkylightPanel() { })}
previewProp({ glassThickness: v })} @@ -302,7 +304,7 @@ export default function SkylightPanel() { {activeSkylightType === 'lantern' && ( <> previewProp({ lanternHeight: v })} @@ -314,7 +316,7 @@ export default function SkylightPanel() { value={Math.round((node.lanternHeight ?? 0.25) * 1000) / 1000} /> previewProp({ lanternTopScale: v })} @@ -330,7 +332,7 @@ export default function SkylightPanel() { {activeSkylightType === 'opening' && ( <> previewProp({ operationState: v })} @@ -342,7 +344,7 @@ export default function SkylightPanel() { value={Math.round((node.operationState ?? 0) * 100) / 100} /> previewProp({ openingAngle: (deg * Math.PI) / 180 })} @@ -356,24 +358,24 @@ export default function SkylightPanel() { commitProp({ openingSide: v as SkylightNode['openingSide'] })} options={[ - { label: 'Top', value: 'top' }, - { label: 'Bottom', value: 'bottom' }, - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, + { label: t('nodes.skylight.top'), value: 'top' }, + { label: t('nodes.skylight.bottom'), value: 'bottom' }, + { label: t('nodes.skylight.left'), value: 'left' }, + { label: t('nodes.skylight.right'), value: 'right' }, ]} value={(node.openingSide ?? 'top') as any} /> commitProp({ motorHousing: v === 'yes' })} options={[ - { label: 'Motor', value: 'yes' }, - { label: 'No Motor', value: 'no' }, + { label: t('nodes.skylight.motor'), value: 'yes' }, + { label: t('nodes.skylight.noMotor'), value: 'no' }, ]} value={(node.motorHousing ?? false) ? 'yes' : 'no'} /> {(node.motorHousing ?? false) && ( previewProp({ motorHousingSize: v })} @@ -390,7 +392,7 @@ export default function SkylightPanel() { {activeSkylightType === 'sliding' && ( <> previewProp({ operationState: v })} @@ -404,13 +406,13 @@ export default function SkylightPanel() { commitProp({ slideDirection: v as SkylightNode['slideDirection'] })} options={[ - { label: 'Along Z', value: 'z' }, - { label: 'Along X', value: 'x' }, + { label: t('nodes.skylight.alongZ'), value: 'z' }, + { label: t('nodes.skylight.alongX'), value: 'x' }, ]} value={(node.slideDirection ?? 'z') as any} /> previewProp({ trackWidth: v })} @@ -425,9 +427,9 @@ export default function SkylightPanel() { )}
- + previewProp({ width: v })} @@ -439,7 +441,7 @@ export default function SkylightPanel() { value={Math.round(node.width * 100) / 100} /> previewProp({ height: v })} @@ -452,9 +454,9 @@ export default function SkylightPanel() { /> - + previewProp({ frameThickness: v })} @@ -466,7 +468,7 @@ export default function SkylightPanel() { value={Math.round((node.frameThickness ?? 0.05) * 1000) / 1000} /> previewProp({ frameDepth: v })} @@ -478,7 +480,7 @@ export default function SkylightPanel() { value={Math.round((node.frameDepth ?? 0.08) * 1000) / 1000} /> previewProp({ cutoutOffset: v })} @@ -491,18 +493,18 @@ export default function SkylightPanel() { /> - + handleUpdate({ curb: v === 'yes' })} options={[ - { label: 'Yes', value: 'yes' }, - { label: 'No', value: 'no' }, + { label: t('nodes.skylight.yes'), value: 'yes' }, + { label: t('nodes.skylight.no'), value: 'no' }, ]} value={(node.curb ?? false) ? 'yes' : 'no'} /> {(node.curb ?? false) && ( previewProp({ curbHeight: v })} @@ -516,9 +518,9 @@ export default function SkylightPanel() { )} - + { @@ -534,7 +536,7 @@ export default function SkylightPanel() { value={Math.round(worldX_now * 100) / 100} /> { @@ -550,7 +552,7 @@ export default function SkylightPanel() { value={Math.round(worldZ_now * 100) / 100} /> { @@ -569,12 +571,12 @@ export default function SkylightPanel() { /> - + } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/skylight/parametrics.ts b/packages/nodes/src/skylight/parametrics.ts index 90607694e0..76a5929368 100644 --- a/packages/nodes/src/skylight/parametrics.ts +++ b/packages/nodes/src/skylight/parametrics.ts @@ -6,6 +6,7 @@ export const skylightParametrics: ParametricDescriptor = { groups: [ { label: 'Type', + labelKey: 'nodes.skylight.type', fields: [ { key: 'skylightType', @@ -17,6 +18,7 @@ export const skylightParametrics: ParametricDescriptor = { }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, { key: 'height', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, @@ -27,6 +29,7 @@ export const skylightParametrics: ParametricDescriptor = { }, { label: 'Curb', + labelKey: 'nodes.skylight.curb', fields: [ { key: 'curb', kind: 'boolean' }, { @@ -42,6 +45,7 @@ export const skylightParametrics: ParametricDescriptor = { }, { label: 'Opening', + labelKey: 'nodes.skylight.opening', fields: [ { key: 'operationState', @@ -64,6 +68,12 @@ export const skylightParametrics: ParametricDescriptor = { key: 'openingSide', kind: 'enum', options: ['top', 'bottom', 'left', 'right'], + optionLabelKeys: { + "top": "nodes.skylight.top", + "bottom": "nodes.skylight.bottom", + "left": "nodes.skylight.left", + "right": "nodes.skylight.right" + }, display: 'segmented', visibleIf: (n) => n.skylightType === 'opening', }, @@ -71,6 +81,10 @@ export const skylightParametrics: ParametricDescriptor = { key: 'slideDirection', kind: 'enum', options: ['x', 'z'], + optionLabelKeys: { + "x": "common.x", + "z": "common.z" + }, display: 'segmented', visibleIf: (n) => n.skylightType === 'sliding', }, @@ -78,6 +92,7 @@ export const skylightParametrics: ParametricDescriptor = { }, { label: 'Lantern', + labelKey: 'nodes.skylight.lantern', fields: [ { key: 'lanternHeight', diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 9d58ca7661..7638fa9595 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -362,13 +362,14 @@ export const slabDefinition: NodeDefinition = { }, toolHints: [ - { key: 'Left click', label: 'Trace slab outline' }, - { key: 'Enter', label: 'Finish slab', minDraftVertices: 3 }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Trace slab outline', labelKey: 'nodes.slab.toolHints.trace' }, + { key: 'Enter', label: 'Finish slab', labelKey: 'nodes.slab.toolHints.finish', minDraftVertices: 3 }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Slab', + labelKey: 'panel.nodeType.slab', description: 'A polygon-bounded floor surface that hosts items on top.', icon: { kind: 'url', src: '/icons/floor.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index 22a2eefc13..b697387497 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -13,6 +13,7 @@ import { useEditingHole, useEditor, useInteractionScope, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' @@ -41,6 +42,7 @@ import { * into `parametrics.groups`. */ export function SlabPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) @@ -259,30 +261,30 @@ export function SlabPanel() { const elevationPresets = unit === 'imperial' ? [ - { label: 'Sunken (6")', elevation: -0.1524 }, - { label: 'Thin (1")', elevation: 0.0254 }, - { label: 'Standard (2")', elevation: 0.0508 }, - { label: 'Thick (6")', elevation: 0.1524 }, + { labelKey: 'nodes.slab.elevationPresets.sunken', elevation: -0.1524 }, + { labelKey: 'nodes.slab.elevationPresets.thin', elevation: 0.0254 }, + { labelKey: 'nodes.slab.elevationPresets.standard', elevation: 0.0508 }, + { labelKey: 'nodes.slab.elevationPresets.thick', elevation: 0.1524 }, ] : [ - { label: 'Sunken (15cm)', elevation: -0.15 }, - { label: 'Thin (2cm)', elevation: 0.02 }, - { label: 'Standard (5cm)', elevation: 0.05 }, - { label: 'Thick (15cm)', elevation: 0.15 }, + { labelKey: 'nodes.slab.elevationPresets.sunken', elevation: -0.15 }, + { labelKey: 'nodes.slab.elevationPresets.thin', elevation: 0.02 }, + { labelKey: 'nodes.slab.elevationPresets.standard', elevation: 0.05 }, + { labelKey: 'nodes.slab.elevationPresets.thick', elevation: 0.15 }, ] return ( - + {/* Range mirrors the 20 m storey cap; `clampSlabElevation` in the write path stays the real bound against the level. */} ) : (
- Foundation + {t('nodes.slab.foundation')}
{node.fillToTerrain && (
- Extends the perimeter down to terrain. The flat surface, base, and thickness stay - unchanged. + {t('nodes.slab.terrainDescription')}
)} @@ -352,22 +353,22 @@ export function SlabPanel() {
{elevationPresets.map((preset) => ( handleElevationPreset(preset.elevation)} /> ))}
- +
- Area + {t('nodes.slab.area')} {area.toFixed(2)} m²
- + {node.holes && node.holes.length > 0 ? (
{node.holes.map((hole, index) => { @@ -376,7 +377,10 @@ export function SlabPanel() { editingHole?.nodeId === selectedId && editingHole?.holeIndex === index const source = node.holeMetadata?.[index]?.source ?? 'manual' const isAutoHole = source !== 'manual' - const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout' + const autoLabel = + source === 'elevator' + ? t('nodes.slab.autoHoleLabel.elevator') + : t('nodes.slab.autoHoleLabel.stair') return (
- Hole {index + 1} {isEditing && '(Editing)'} + {t('nodes.slab.holeLabel', { index: index + 1 })}{' '} + {isEditing && t('nodes.slab.editing')}

- {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} - {isAutoHole ? autoLabel : 'Manual'} + {holeArea.toFixed(2)} m² · {hole.length} {t('nodes.slab.pts')} ·{' '} + {isAutoHole ? autoLabel : t('nodes.slab.manual')}

{isEditing ? ( useInteractionScope .getState() @@ -412,7 +417,7 @@ export function SlabPanel() { /> ) : isAutoHole ? (
- Auto + {t('nodes.slab.auto')}
) : ( <> @@ -438,7 +443,9 @@ export function SlabPanel() { })}
) : ( -
No holes
+
+ {t('nodes.slab.noHoles')} +
)}
@@ -446,13 +453,13 @@ export function SlabPanel() { className="w-full" disabled={editingHole?.nodeId === selectedId} icon={} - label="Add Hole" + label={t('nodes.slab.addHole')} onClick={handleAddHole} />
- } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> ) diff --git a/packages/nodes/src/slab/parametrics.ts b/packages/nodes/src/slab/parametrics.ts index a34e481933..2a7a22c252 100644 --- a/packages/nodes/src/slab/parametrics.ts +++ b/packages/nodes/src/slab/parametrics.ts @@ -15,6 +15,7 @@ export const slabParametrics: ParametricDescriptor = { groups: [ { label: 'Elevation', + labelKey: 'nodes.slab.elevation', fields: [ { key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }, { diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index 8aece59b78..5a558b6fda 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -266,12 +266,13 @@ export const solarPanelDefinition: NodeDefinition = { move: () => import('./move-tool'), }, toolHints: [ - { key: 'Left click', label: 'Place solar panel array on roof' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place solar panel array on roof', labelKey: 'nodes.solarPanel.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Solar Panel', + labelKey: 'panel.nodeType.solarPanel', description: 'Grid of photovoltaic panels mounted on a roof segment.', icon: { kind: 'url', src: '/icons/solar-panel.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/solar-panel/panel.tsx b/packages/nodes/src/solar-panel/panel.tsx index aa7d7c913d..d9cda64ac3 100644 --- a/packages/nodes/src/solar-panel/panel.tsx +++ b/packages/nodes/src/solar-panel/panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, type RoofSegmentNode, - SOLAR_PANEL_PRESET_LABELS, SOLAR_PANEL_PRESETS, type SolarPanelNode, type SolarPanelPresetKey, @@ -19,6 +18,7 @@ import { SegmentedControl, SliderControl, triggerSFX, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { LayoutGrid, Trash2 } from 'lucide-react' @@ -37,11 +37,11 @@ const PRESET_OWNED_FIELDS: ReadonlyArray = [ 'frameDepth', ] -const PRESET_CARDS: { key: SolarPanelPresetKey; label: string }[] = [ - { key: 'residential', label: SOLAR_PANEL_PRESET_LABELS.residential }, - { key: 'residential-large', label: SOLAR_PANEL_PRESET_LABELS['residential-large'] }, - { key: 'compact', label: SOLAR_PANEL_PRESET_LABELS.compact }, - { key: 'frameless', label: SOLAR_PANEL_PRESET_LABELS.frameless }, +const PRESET_CARDS: { key: SolarPanelPresetKey; labelKey: string }[] = [ + { key: 'residential', labelKey: 'nodes.solarPanel.presetResidential' }, + { key: 'residential-large', labelKey: 'nodes.solarPanel.presetResidentialLarge' }, + { key: 'compact', labelKey: 'nodes.solarPanel.presetCompact' }, + { key: 'frameless', labelKey: 'nodes.solarPanel.presetFrameless' }, ] function dimsTouchedByUpdate(updates: Partial): boolean { @@ -56,6 +56,7 @@ function num(value: unknown, fallback: number): number { } export default function SolarPanelPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -145,12 +146,12 @@ export default function SolarPanelPanel() { if (!(selectedId && node && segment)) return const fit = computeAutoFit(segment, node) if (!fit) { - setAutoFitMessage('Setbacks too large to fit a panel.') + setAutoFitMessage(t('nodes.solarPanel.setbacksTooLarge')) return } updateNode(selectedId as AnyNode['id'], { rows: fit.rows, columns: fit.columns }) setAutoFitMessage(null) - }, [selectedId, node, segment, updateNode]) + }, [selectedId, node, segment, updateNode, t]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return @@ -184,10 +185,10 @@ export default function SolarPanelPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Solar Panel'} + title={node.name || t('nodes.solarPanel.fallbackTitle')} width={300} > - +
{PRESET_CARDS.map((card) => { const dims = SOLAR_PANEL_PRESETS[card.key] @@ -206,7 +207,7 @@ export default function SolarPanelPanel() { > - {card.label} + {t(card.labelKey)} {formatDims(dims.panelWidth, dims.panelHeight)} @@ -217,14 +218,14 @@ export default function SolarPanelPanel() {
{!activePreset && (

- Custom — dimensions don't match any preset + {t('nodes.solarPanel.customNotice')}

)}
- + previewProp({ rows: Math.round(v) })} @@ -235,7 +236,7 @@ export default function SolarPanelPanel() { value={num(node.rows, 4)} /> previewProp({ columns: Math.round(v) })} @@ -246,7 +247,7 @@ export default function SolarPanelPanel() { value={num(node.columns, 5)} /> previewProp({ gapX: v })} @@ -258,7 +259,7 @@ export default function SolarPanelPanel() { value={Math.round(num(node.gapX, 0.02) * 1000) / 1000} /> previewProp({ gapY: v })} @@ -270,14 +271,14 @@ export default function SolarPanelPanel() { value={Math.round(num(node.gapY, 0.02) * 1000) / 1000} /> - + {autoFitMessage ?

{autoFitMessage}

: null}
- + previewProp({ panelWidth: v })} @@ -289,7 +290,7 @@ export default function SolarPanelPanel() { value={Math.round(num(node.panelWidth, 1) * 100) / 100} /> previewProp({ panelHeight: v })} @@ -301,10 +302,10 @@ export default function SolarPanelPanel() { value={Math.round(num(node.panelHeight, 1.65) * 100) / 100} /> - + previewProp({ frameThickness: v })} @@ -316,7 +317,7 @@ export default function SolarPanelPanel() { value={Math.round(num(node.frameThickness, 0.04) * 1000) / 1000} /> previewProp({ frameDepth: v })} @@ -329,18 +330,18 @@ export default function SolarPanelPanel() { /> - + handleUpdate({ mountingType: v })} options={[ - { label: 'Flush', value: 'flush' }, - { label: 'Tilted', value: 'tilted' }, + { label: t('nodes.solarPanel.flush'), value: 'flush' }, + { label: t('nodes.solarPanel.tilted'), value: 'tilted' }, ]} value={node.mountingType ?? 'flush'} /> {node.mountingType === 'tilted' && ( previewProp({ tiltAngle: v })} @@ -353,7 +354,7 @@ export default function SolarPanelPanel() { /> )} previewProp({ standoffHeight: v })} @@ -366,12 +367,12 @@ export default function SolarPanelPanel() { /> - + } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/solar-panel/parametrics.ts b/packages/nodes/src/solar-panel/parametrics.ts index 28a6aa0ea0..66cc2a770f 100644 --- a/packages/nodes/src/solar-panel/parametrics.ts +++ b/packages/nodes/src/solar-panel/parametrics.ts @@ -9,6 +9,7 @@ export const solarPanelParametrics: ParametricDescriptor = { groups: [ { label: 'Grid', + labelKey: 'nodes.solarPanel.grid', fields: [ { key: 'rows', kind: 'number', min: 1, max: 20, step: 1 }, { key: 'columns', kind: 'number', min: 1, max: 20, step: 1 }, @@ -16,6 +17,7 @@ export const solarPanelParametrics: ParametricDescriptor = { }, { label: 'Panel dimensions', + labelKey: 'nodes.solarPanel.panelDimensions', fields: [ { key: 'panelWidth', kind: 'number', unit: 'm', min: 0.4, max: 1000, step: 0.01 }, { key: 'panelHeight', kind: 'number', unit: 'm', min: 0.4, max: 1000, step: 0.01 }, @@ -25,11 +27,16 @@ export const solarPanelParametrics: ParametricDescriptor = { }, { label: 'Mounting', + labelKey: 'common.mounting', fields: [ { key: 'mountingType', kind: 'enum', options: ['flush', 'tilted'], + optionLabelKeys: { + "flush": "nodes.solarPanel.flush", + "tilted": "nodes.solarPanel.tilted" + }, display: 'segmented', }, { @@ -46,6 +53,7 @@ export const solarPanelParametrics: ParametricDescriptor = { }, { label: 'Frame', + labelKey: 'nodes.solarPanel.frame', fields: [ { key: 'frameThickness', kind: 'number', unit: 'm', min: 0, max: 0.1, step: 0.005 }, { key: 'frameDepth', kind: 'number', unit: 'm', min: 0.005, max: 0.1, step: 0.005 }, diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 94facff033..ad5fa68e27 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -100,13 +100,14 @@ export const spawnDefinition: NodeDefinition = { }, tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Place spawn point' }, - { key: 'R / T', label: 'Rotate spawn point' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place spawn point', labelKey: 'nodes.spawn.toolHints.place' }, + { key: 'R / T', label: 'Rotate spawn point', labelKey: 'nodes.spawn.toolHints.rotate' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Spawn Point', + labelKey: 'panel.nodeType.spawn', description: 'Player or camera origin within a level. One per level.', icon: { kind: 'url', src: '/icons/spawn-point.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/spawn/panel.tsx b/packages/nodes/src/spawn/panel.tsx index de8e49d093..4e6e96ff7b 100644 --- a/packages/nodes/src/spawn/panel.tsx +++ b/packages/nodes/src/spawn/panel.tsx @@ -9,6 +9,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Move, Trash2 } from 'lucide-react' @@ -20,6 +21,7 @@ export default function SpawnPanel() { const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const t = useTranslations() const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined, @@ -97,12 +99,12 @@ export default function SpawnPanel() { - + @@ -114,7 +116,7 @@ export default function SpawnPanel() { value={Math.round(node.position[0] * 100) / 100} /> @@ -126,7 +128,7 @@ export default function SpawnPanel() { value={Math.round(node.position[1] * 100) / 100} /> @@ -139,9 +141,9 @@ export default function SpawnPanel() { /> - + - + - } label="Move" onClick={handleMove} /> + } + label={t('editor.move')} + onClick={handleMove} + /> } - label="Delete" + label={t('editor.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/spawn/parametrics.ts b/packages/nodes/src/spawn/parametrics.ts index 57001084bc..302954471a 100644 --- a/packages/nodes/src/spawn/parametrics.ts +++ b/packages/nodes/src/spawn/parametrics.ts @@ -9,6 +9,7 @@ export const spawnParametrics: ParametricDescriptor = { groups: [ { label: 'Transform', + labelKey: 'common.transform', fields: [ { key: 'position', kind: 'vec3' }, // rotation on spawn is a scalar yaw (not vec3). Phase 4 will support a diff --git a/packages/nodes/src/stair-segment/definition.ts b/packages/nodes/src/stair-segment/definition.ts index 81730be0db..6ca7ccbefe 100644 --- a/packages/nodes/src/stair-segment/definition.ts +++ b/packages/nodes/src/stair-segment/definition.ts @@ -139,6 +139,7 @@ export const stairSegmentDefinition: NodeDefinition = { presentation: { label: 'Stair Segment', + labelKey: 'panel.nodeType.stairSegment', description: 'A single flight of a parent stair.', icon: { kind: 'url', src: '/icons/stairs.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/stair-segment/panel.tsx b/packages/nodes/src/stair-segment/panel.tsx index daad0fa4b2..b33d40c7f8 100644 --- a/packages/nodes/src/stair-segment/panel.tsx +++ b/packages/nodes/src/stair-segment/panel.tsx @@ -19,23 +19,25 @@ import { ToggleControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' -const SEGMENT_TYPE_OPTIONS: { label: string; value: StairSegmentType }[] = [ - { label: 'Flight', value: 'stair' }, - { label: 'Landing', value: 'landing' }, +const SEGMENT_TYPE_OPTIONS: { labelKey: string; value: StairSegmentType }[] = [ + { labelKey: 'nodes.stairSegment.flight', value: 'stair' }, + { labelKey: 'nodes.stairSegment.landing', value: 'landing' }, ] -const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [ - { label: 'Front', value: 'front' }, - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, +const ATTACHMENT_SIDE_OPTIONS: { labelKey: string; value: AttachmentSide }[] = [ + { labelKey: 'nodes.stairSegment.front', value: 'front' }, + { labelKey: 'nodes.stairSegment.left', value: 'left' }, + { labelKey: 'nodes.stairSegment.right', value: 'right' }, ] export default function StairSegmentPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -124,10 +126,10 @@ export default function StairSegmentPanel() { icon="/icons/stairs.webp" onBack={handleBack} onClose={handleClose} - title={node.name || 'Stair Segment'} + title={node.name || t('panel.nodeType.stairSegment')} width={300} > - + { const updates: Partial = { segmentType: v } @@ -142,24 +144,24 @@ export default function StairSegmentPanel() { } handleUpdate(updates) }} - options={SEGMENT_TYPE_OPTIONS} + options={SEGMENT_TYPE_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={node.segmentType} /> {!isFirstSegment && ( - + handleUpdate({ attachmentSide: v })} - options={ATTACHMENT_SIDE_OPTIONS} + options={ATTACHMENT_SIDE_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={node.attachmentSide} /> )} - + handleUpdate({ width: v })} @@ -169,7 +171,7 @@ export default function StairSegmentPanel() { value={Math.round(node.width * 100) / 100} /> handleUpdate({ length: v })} @@ -181,7 +183,7 @@ export default function StairSegmentPanel() { {node.segmentType === 'stair' && ( <> handleUpdate({ height: v })} @@ -191,7 +193,7 @@ export default function StairSegmentPanel() { value={Math.round(node.height * 100) / 100} /> handleUpdate({ stepCount: Math.round(v) })} @@ -204,16 +206,16 @@ export default function StairSegmentPanel() { )} - +
handleUpdate({ fillToFloor: checked })} /> {!node.fillToFloor && ( handleUpdate({ thickness: v })} @@ -226,9 +228,9 @@ export default function StairSegmentPanel() {
- + { const pos = [...node.position] as [number, number, number] pos[0] = v @@ -240,7 +242,7 @@ export default function StairSegmentPanel() { value={Math.round(node.position[0] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[1] = v @@ -252,7 +254,7 @@ export default function StairSegmentPanel() { value={Math.round(node.position[1] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[2] = v @@ -264,7 +266,7 @@ export default function StairSegmentPanel() { value={Math.round(node.position[2] * 100) / 100} /> { @@ -277,14 +279,14 @@ export default function StairSegmentPanel() { />
{ triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation - Math.PI / 4 }) }} /> { triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation + Math.PI / 4 }) @@ -293,18 +295,18 @@ export default function StairSegmentPanel() {
- + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index b73bee834a..6d8a9e02df 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -445,9 +445,9 @@ export const stairDefinition: NodeDefinition = { // snapping chip shows during placement. snapDraftDirectional: false, toolHints: [ - { key: 'Left click', label: 'Place stairs' }, - { key: 'R / T', label: 'Rotate' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place stairs', labelKey: 'nodes.stair.toolHints.place' }, + { key: 'R / T', label: 'Rotate', labelKey: 'editor.rotate' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], surfaceRole: 'joinery', @@ -526,6 +526,7 @@ export const stairDefinition: NodeDefinition = { presentation: { label: 'Stair', + labelKey: 'panel.nodeType.stair', description: 'A stair composed of one or more flights with configurable treads, risers, railings.', icon: { kind: 'url', src: '/icons/stairs.webp' }, diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 9500bbce80..e066701110 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -32,6 +32,7 @@ import { ToggleControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' @@ -39,27 +40,27 @@ import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { getStairDestinationUpdates } from './destination' -const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ - { label: 'None', value: 'none' }, - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - { label: 'Both', value: 'both' }, +const RAILING_MODE_OPTIONS: { labelKey: string; value: StairRailingMode }[] = [ + { labelKey: 'nodes.stair.none', value: 'none' }, + { labelKey: 'nodes.stair.left', value: 'left' }, + { labelKey: 'nodes.stair.right', value: 'right' }, + { labelKey: 'nodes.stair.both', value: 'both' }, ] -const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [ - { label: 'Straight', value: 'straight' }, - { label: 'Curved', value: 'curved' }, - { label: 'Spiral', value: 'spiral' }, +const STAIR_TYPE_OPTIONS: { labelKey: string; value: StairType }[] = [ + { labelKey: 'nodes.stair.straight', value: 'straight' }, + { labelKey: 'nodes.stair.curved', value: 'curved' }, + { labelKey: 'nodes.stair.spiral', value: 'spiral' }, ] -const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [ - { label: 'None', value: 'none' }, - { label: 'Integrated', value: 'integrated' }, +const TOP_LANDING_MODE_OPTIONS: { labelKey: string; value: StairTopLandingMode }[] = [ + { labelKey: 'nodes.stair.none', value: 'none' }, + { labelKey: 'nodes.stair.integrated', value: 'integrated' }, ] -const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [ - { label: 'None', value: 'none' }, - { label: 'Destination', value: 'destination' }, +const STAIR_SLAB_OPENING_OPTIONS: { labelKey: string; value: StairSlabOpeningMode }[] = [ + { labelKey: 'nodes.stair.none', value: 'none' }, + { labelKey: 'nodes.stair.destination', value: 'destination' }, ] // Slabs at least this high off the storey floor read as decks (mezzanines) — @@ -67,6 +68,7 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode } const DECK_DESTINATION_MIN_ELEVATION = 0.5 export default function StairPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) @@ -261,10 +263,10 @@ export default function StairPanel() { - + handleUpdate( @@ -277,24 +279,24 @@ export default function StairPanel() { : { stairType: value }, ) } - options={STAIR_TYPE_OPTIONS} + options={STAIR_TYPE_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={node.stairType ?? 'straight'} /> - +
{attachedDeck ? null : ( )}
- From Level + {t('nodes.stair.fromLevel')}
@@ -311,7 +313,7 @@ export default function StairPanel() {
- To + {t('nodes.stair.toLevel')}
@@ -334,7 +336,7 @@ export default function StairPanel() { {attachedDeck ? (
- Rise + {t('nodes.stair.rise')}
@@ -343,18 +345,18 @@ export default function StairPanel() { ) } options={[ - { label: 'Follows deck', value: 'follows' }, - { label: 'Custom rise', value: 'custom' }, + { label: t('nodes.stair.followsDeck'), value: 'follows' }, + { label: t('nodes.stair.customRise'), value: 'custom' }, ]} value={node.totalRise == null ? 'follows' : 'custom'} /> {node.totalRise == null ? (
- Currently {resolvedRise} m + {t('nodes.stair.currently', { height: resolvedRise })}
) : ( handleUpdate({ totalRise: value })} @@ -371,13 +373,13 @@ export default function StairPanel() { <> handleAutoCutoutChange(value === 'destination')} - options={STAIR_SLAB_OPENING_OPTIONS} + options={STAIR_SLAB_OPENING_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={node.slabOpeningMode ?? 'none'} /> {(node.slabOpeningMode ?? 'none') === 'destination' ? ( handleUpdate({ openingOffset: value })} @@ -394,17 +396,17 @@ export default function StairPanel() { <>
- Landing + {t('nodes.stair.landing')}
handleUpdate({ topLandingMode: value })} - options={TOP_LANDING_MODE_OPTIONS} + options={TOP_LANDING_MODE_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={node.topLandingMode ?? 'none'} />
{(node.topLandingMode ?? 'none') === 'integrated' && ( handleUpdate({ topLandingDepth: value })} @@ -420,7 +422,7 @@ export default function StairPanel() { {node.stairType === 'straight' && ( - +
{segments.map((seg, i) => ( ))}
} - label="Add flight" + label={t('nodes.stair.addFlight')} onClick={handleAddFlight} /> } - label="Add landing" + label={t('nodes.stair.addLanding')} onClick={handleAddLanding} />
@@ -450,9 +452,9 @@ export default function StairPanel() { )} {(node.stairType === 'curved' || node.stairType === 'spiral') && ( - + handleUpdate({ width: value })} @@ -462,7 +464,7 @@ export default function StairPanel() { value={Math.round((node.width ?? 1) * 100) / 100} /> handleUpdate({ totalRise: value })} @@ -472,7 +474,7 @@ export default function StairPanel() { value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100} /> handleUpdate({ stepCount: Math.max(2, Math.round(value)) })} @@ -484,13 +486,13 @@ export default function StairPanel() { {node.stairType !== 'spiral' && ( handleUpdate({ fillToFloor: checked })} /> )} {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && ( handleUpdate({ thickness: value })} @@ -501,7 +503,7 @@ export default function StairPanel() { /> )} handleUpdate({ innerRadius: value })} @@ -511,7 +513,7 @@ export default function StairPanel() { value={Math.round((node.innerRadius ?? 0.9) * 100) / 100} /> handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })} @@ -524,12 +526,12 @@ export default function StairPanel() { <> handleUpdate({ showCenterColumn: checked })} /> handleUpdate({ showStepSupports: checked })} /> @@ -537,9 +539,9 @@ export default function StairPanel() { )} - + { const pos = [...node.position] as [number, number, number] pos[0] = v @@ -551,7 +553,7 @@ export default function StairPanel() { value={Math.round(node.position[0] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[1] = v @@ -563,7 +565,7 @@ export default function StairPanel() { value={Math.round(node.position[1] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[2] = v @@ -575,7 +577,7 @@ export default function StairPanel() { value={Math.round(node.position[2] * 100) / 100} /> { @@ -588,14 +590,14 @@ export default function StairPanel() { />
{ triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation - Math.PI / 4 }) }} /> { triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation + Math.PI / 4 }) @@ -604,15 +606,15 @@ export default function StairPanel() {
- + handleUpdate({ railingMode: value })} - options={RAILING_MODE_OPTIONS} + options={RAILING_MODE_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={node.railingMode ?? 'none'} /> {(node.railingMode ?? 'none') !== 'none' && ( handleUpdate({ railingHeight: value })} @@ -624,18 +626,18 @@ export default function StairPanel() { )} - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/structural-grid/definition.ts b/packages/nodes/src/structural-grid/definition.ts index dc89573d82..c91ef6cb75 100644 --- a/packages/nodes/src/structural-grid/definition.ts +++ b/packages/nodes/src/structural-grid/definition.ts @@ -39,14 +39,15 @@ export const structuralGridDefinition: NodeDefinition dirtyTracking: false, floorplan: buildStructuralGridFloorplan, toolHints: [ - { key: 'Left click', label: 'Start grid axis' }, - { key: 'Left click', label: 'Finish grid axis' }, - { key: 'Alt', label: 'Bypass snapping' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Start grid axis', labelKey: 'nodes.structuralGrid.toolHints.start' }, + { key: 'Left click', label: 'Finish grid axis', labelKey: 'nodes.structuralGrid.toolHints.finish' }, + { key: 'Alt', label: 'Bypass snapping', labelKey: 'nodes.structuralGrid.toolHints.bypassSnap' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Structural Grid', + labelKey: 'panel.nodeType.structuralGrid', description: 'Persistent construction grid axis with identification bubbles.', icon: { kind: 'url', src: '/icons/structural-grid.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/turbine-vent/definition.ts b/packages/nodes/src/turbine-vent/definition.ts index c48ecde0e6..068413ce70 100644 --- a/packages/nodes/src/turbine-vent/definition.ts +++ b/packages/nodes/src/turbine-vent/definition.ts @@ -122,12 +122,13 @@ export const turbineVentDefinition: NodeDefinition = { move: () => import('./move-tool'), }, toolHints: [ - { key: 'Left click', label: 'Place turbine vent on roof' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place turbine vent on roof', labelKey: 'nodes.turbineVent.toolHints.place' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Turbine Vent', + labelKey: 'panel.nodeType.turbineVent', description: 'Wind-driven spinning whirlybird exhaust vent for a roof slope.', icon: { kind: 'url', src: '/icons/turbine-vent.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/turbine-vent/panel.tsx b/packages/nodes/src/turbine-vent/panel.tsx index fc23df3142..f3f686b03a 100644 --- a/packages/nodes/src/turbine-vent/panel.tsx +++ b/packages/nodes/src/turbine-vent/panel.tsx @@ -18,6 +18,7 @@ import { SliderControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Pause, Play, Trash2 } from 'lucide-react' @@ -35,6 +36,7 @@ const DEFAULT_SPIN_SPEED = 0.8 * Mirrors the box-vent panel. */ export default function TurbineVentPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -164,23 +166,23 @@ export default function TurbineVentPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Turbine Vent'} + title={node.name || t('nodes.turbineVent.fallbackTitle')} width={300} > - + handleUpdate({ style: v as TurbineVentNode['style'] })} options={[ - { label: 'Globe', value: 'globe' }, - { label: 'Cylinder', value: 'cylinder' }, + { label: t('nodes.turbineVent.globe'), value: 'globe' }, + { label: t('nodes.turbineVent.cylinder'), value: 'cylinder' }, ]} value={node.style ?? 'globe'} /> - + previewProp({ diameter: v })} @@ -192,7 +194,7 @@ export default function TurbineVentPanel() { value={Math.round(node.diameter * 100) / 100} /> previewProp({ height: v })} @@ -204,7 +206,7 @@ export default function TurbineVentPanel() { value={Math.round(node.height * 100) / 100} /> previewProp({ neckHeight: v })} @@ -216,7 +218,7 @@ export default function TurbineVentPanel() { value={Math.round((node.neckHeight ?? 0.09) * 100) / 100} /> previewProp({ vaneCount: Math.round(v) })} @@ -229,16 +231,16 @@ export default function TurbineVentPanel() { /> - + : } - label={isSpinning ? 'Pause' : 'Play'} + label={isSpinning ? t('nodes.turbineVent.pause') : t('nodes.turbineVent.play')} onClick={handleToggleSpin} /> previewProp({ spinSpeed: v })} @@ -251,9 +253,9 @@ export default function TurbineVentPanel() { /> - + @@ -269,7 +271,7 @@ export default function TurbineVentPanel() { value={Math.round((node.position[0] ?? 0) * 100) / 100} /> @@ -304,7 +306,7 @@ export default function TurbineVentPanel() { value={Math.round((node.position[2] ?? 0) * 100) / 100} /> previewProp({ rotation: (deg * Math.PI) / 180 })} @@ -317,18 +319,18 @@ export default function TurbineVentPanel() { /> - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/turbine-vent/parametrics.ts b/packages/nodes/src/turbine-vent/parametrics.ts index 6804207c16..c121362249 100644 --- a/packages/nodes/src/turbine-vent/parametrics.ts +++ b/packages/nodes/src/turbine-vent/parametrics.ts @@ -11,17 +11,23 @@ export const turbineVentParametrics: ParametricDescriptor = { groups: [ { label: 'Style', + labelKey: 'common.style', fields: [ { key: 'style', kind: 'enum', options: ['globe', 'cylinder'], + optionLabelKeys: { + "globe": "nodes.turbineVent.globe", + "cylinder": "nodes.turbineVent.cylinder" + }, display: 'segmented', }, ], }, { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'diameter', kind: 'number', unit: 'm', min: 0.15, max: 1000, step: 0.01 }, { key: 'height', kind: 'number', unit: 'm', min: 0.2, max: 1000, step: 0.01 }, @@ -31,6 +37,7 @@ export const turbineVentParametrics: ParametricDescriptor = { }, { label: 'Motion', + labelKey: 'nodes.turbineVent.motion', fields: [{ key: 'spinSpeed', kind: 'number', unit: 'rad/s', min: 0, max: 4, step: 0.1 }], }, ], diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index e5b7af1d4f..9fcff88dfc 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -166,12 +166,13 @@ export const wallDefinition: NodeDefinition = { floorplanMoveTarget: wallFloorplanMoveTarget, floorplanSiblingOverrides: wallFloorplanSiblingOverrides, toolHints: [ - { key: 'Left click', label: 'Set wall start / end' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Set wall start / end', labelKey: 'nodes.wall.toolHints.setStartEnd' }, + { key: 'Esc', label: 'Cancel', labelKey: 'nodes.wall.toolHints.cancel' }, ], presentation: { label: 'Wall', + labelKey: 'panel.nodeType.wall', description: 'A straight or curved wall segment. Hosts doors, windows, lean-to extensions, and wall-mounted items.', icon: { kind: 'url', src: '/icons/wall.webp' }, diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index a85e9abae3..20b39f5d02 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -34,6 +34,7 @@ import { SliderControl, triggerSFX, useInteractionScope, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' @@ -63,32 +64,33 @@ type WallTrimKey = 'skirting' | 'crown' | 'chairRail' const WALL_TRIM_PROFILE_OPTIONS: Record< WallTrimKey, - Array<{ label: string; value: WallTrimProfile }> + Array<{ labelKey: string; value: WallTrimProfile }> > = { skirting: [ - { label: 'Flat', value: 'flat' }, - { label: 'Modern', value: 'base-modern' }, - { label: 'Colonial', value: 'base-colonial' }, - { label: 'Shoe', value: 'base-shoe' }, - { label: 'Ogee', value: 'base-ogee' }, + { labelKey: 'nodes.wall.trimProfile.flat', value: 'flat' }, + { labelKey: 'nodes.wall.trimProfile.modern', value: 'base-modern' }, + { labelKey: 'nodes.wall.trimProfile.colonial', value: 'base-colonial' }, + { labelKey: 'nodes.wall.trimProfile.shoe', value: 'base-shoe' }, + { labelKey: 'nodes.wall.trimProfile.ogee', value: 'base-ogee' }, ], crown: [ - { label: 'Flat', value: 'flat' }, - { label: 'Cove', value: 'crown-cove' }, - { label: 'Ogee', value: 'crown-ogee' }, - { label: 'Craft', value: 'crown-craftsman' }, - { label: 'Layered', value: 'crown-layered' }, + { labelKey: 'nodes.wall.trimProfile.flat', value: 'flat' }, + { labelKey: 'nodes.wall.trimProfile.cove', value: 'crown-cove' }, + { labelKey: 'nodes.wall.trimProfile.ogee', value: 'crown-ogee' }, + { labelKey: 'nodes.wall.trimProfile.craft', value: 'crown-craftsman' }, + { labelKey: 'nodes.wall.trimProfile.layered', value: 'crown-layered' }, ], chairRail: [ - { label: 'Flat', value: 'flat' }, - { label: 'Round', value: 'rail-rounded' }, - { label: 'Ogee', value: 'rail-ogee' }, - { label: 'Picture', value: 'rail-picture' }, - { label: 'Step', value: 'rail-stepped' }, + { labelKey: 'nodes.wall.trimProfile.flat', value: 'flat' }, + { labelKey: 'nodes.wall.trimProfile.round', value: 'rail-rounded' }, + { labelKey: 'nodes.wall.trimProfile.ogee', value: 'rail-ogee' }, + { labelKey: 'nodes.wall.trimProfile.picture', value: 'rail-picture' }, + { labelKey: 'nodes.wall.trimProfile.step', value: 'rail-stepped' }, ], } export default function WallPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) @@ -248,12 +250,12 @@ export default function WallPanel() { - + @@ -267,23 +269,23 @@ export default function WallPanel() { value={displayLength} />
- Top + {t('nodes.wall.top')}
{isPlaneBound ? (
- Currently {formatLinearMeasurement(height, unit)} + {t('nodes.wall.currently', { measurement: formatLinearMeasurement(height, unit) })}
) : ( @@ -298,23 +300,23 @@ export default function WallPanel() { /> )}
- Bottom + {t('nodes.wall.bottom')}
{followsTerrain && (
- Extends downward to meet the terrain. Height and top stay unchanged. + {t('nodes.wall.fillToTerrainDescription')}
)} @@ -332,7 +334,7 @@ export default function WallPanel() { /> {!hasWallChildrenBlockingCurve && ( @@ -365,7 +367,7 @@ export default function WallPanel() { {!hasWallChildrenBlockingCurve && ( - + } - label="Curve" + label={t('nodes.wall.curve')} onClick={handleCurve} /> @@ -421,6 +423,7 @@ function WallFaceBandSection({ unitLabel: string wallHeightMeters: number }) { + const t = useTranslations() const bandConfig = getWallFaceBandConfig(node, wallHeightMeters) const bandCount = bandConfig.count const lowerHeight = bandConfig.lowerHeight @@ -438,9 +441,9 @@ function WallFaceBandSection({ }) return ( - + onUpdate(buildWallFaceBandCountPatch(node, Math.round(value)))} @@ -450,7 +453,7 @@ function WallFaceBandSection({ /> {bandCount >= 2 && ( @@ -469,7 +472,7 @@ function WallFaceBandSection({ )} {bandCount >= 3 && ( @@ -488,7 +491,7 @@ function WallFaceBandSection({ )} {bandCount >= 4 && ( @@ -528,6 +531,7 @@ function WallTrimSection({ unitLabel: string wallHeightMeters: number }) { + const t = useTranslations() const updateTrim = (patch: Partial>) => onUpdate({ [trimKey]: { @@ -544,7 +548,11 @@ function WallTrimSection({ updateTrim({ enabled: !trimValue.enabled })} /> @@ -553,19 +561,19 @@ function WallTrimSection({ updateTrim({ sides: next as any })} options={[ - { label: 'Interior', value: 'interior' }, - { label: 'Exterior', value: 'exterior' }, - { label: 'Both', value: 'both' }, + { label: t('nodes.wall.interior'), value: 'interior' }, + { label: t('nodes.wall.exterior'), value: 'exterior' }, + { label: t('nodes.wall.both'), value: 'both' }, ]} value={trimValue.sides} /> updateTrim({ profile: next })} - options={profileOptions} + options={profileOptions.map((o) => ({ label: t(o.labelKey), value: o.value }))} value={selectedProfile} /> @@ -582,7 +590,7 @@ function WallTrimSection({ value={metersToLinearUnit(trimValue.height, unit)} /> @@ -600,7 +608,7 @@ function WallTrimSection({ /> {trimKey === 'chairRail' && ( diff --git a/packages/nodes/src/wall/parametrics.ts b/packages/nodes/src/wall/parametrics.ts index 411013bc00..72ffbdb928 100644 --- a/packages/nodes/src/wall/parametrics.ts +++ b/packages/nodes/src/wall/parametrics.ts @@ -17,6 +17,7 @@ export const wallParametrics: ParametricDescriptor = { groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'thickness', kind: 'number', unit: 'm', min: 0.05, max: 1000, step: 0.01 }, // `height` may be absent (plane-bound top); the custom panel owns the diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 8f12a8eefc..efdfd9a43b 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -299,14 +299,15 @@ export const windowDefinition: NodeDefinition = { }, toolHints: [ - { key: 'Left click', label: 'Place window on wall' }, - { key: 'R', label: 'Flip side' }, - { key: 'Alt', label: 'Force place' }, - { key: 'Esc', label: 'Cancel' }, + { key: 'Left click', label: 'Place window on wall', labelKey: 'nodes.window.toolHints.place' }, + { key: 'R', label: 'Flip side', labelKey: 'nodes.window.toolHints.flipSide' }, + { key: 'Alt', label: 'Force place', labelKey: 'editor.forcePlace' }, + { key: 'Esc', label: 'Cancel', labelKey: 'common.cancel' }, ], presentation: { label: 'Window', + labelKey: 'panel.nodeType.window', description: 'A window cut into a wall. Animated open/close for opening windows.', icon: { kind: 'url', src: '/icons/window.webp' }, paletteSection: 'structure', diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index ee9c383397..4166ca57ab 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type DormerEvent, diff --git a/packages/nodes/src/window/naming.ts b/packages/nodes/src/window/naming.ts new file mode 100644 index 0000000000..a56990f429 --- /dev/null +++ b/packages/nodes/src/window/naming.ts @@ -0,0 +1,22 @@ +import type { Translator } from '@pascal-app/core' + +/** + * Mirrors `@pascal-app/core::level-name.ts` — a Translator-shaped helper + * usable from non-React call sites. Callers pass their `t()` from the React + * tree; non-React callers (export tooling) get English. + * + * Keep the fallback values in sync with `packages/editor/src/lib/i18n/en.json`. + */ +const fallbackTranslator: Translator = (key, vars) => { + switch (key) { + case 'nodes.window.defaultName': + return `Window ${vars?.count ?? ''}`.trim() + default: + return key + } +} + +/** Default name for the Nth window created (`Window 1`, `Window 2`, ...). */ +export function getDefaultWindowName(count: number, t: Translator = fallbackTranslator): string { + return t('nodes.window.defaultName', { count }) +} \ No newline at end of file diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index e496329205..a43ffa9d4c 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -18,6 +18,7 @@ import { ToggleControl, triggerSFX, useEditor, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' @@ -70,15 +71,15 @@ function isSameRadiusTuple( } const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [ - { label: 'Fixed', value: 'fixed' }, - { label: 'Sliding', value: 'sliding' }, - { label: 'Casement', value: 'casement' }, - { label: 'Awning', value: 'awning' }, - { label: 'Single Hung', value: 'single-hung' }, - { label: 'Double Hung', value: 'double-hung' }, - { label: 'Bay', value: 'bay' }, - { label: 'Bow', value: 'bow' }, - { label: 'Louvered', value: 'louvered' }, + { label: 'nodes.window.typeOptions.fixed', value: 'fixed' }, + { label: 'nodes.window.typeOptions.sliding', value: 'sliding' }, + { label: 'nodes.window.typeOptions.casement', value: 'casement' }, + { label: 'nodes.window.typeOptions.awning', value: 'awning' }, + { label: 'nodes.window.typeOptions.singleHung', value: 'single-hung' }, + { label: 'nodes.window.typeOptions.doubleHung', value: 'double-hung' }, + { label: 'nodes.window.typeOptions.bay', value: 'bay' }, + { label: 'nodes.window.typeOptions.bow', value: 'bow' }, + { label: 'nodes.window.typeOptions.louvered', value: 'louvered' }, ] const shapedWindowTypes = new Set([ @@ -92,6 +93,7 @@ const shapedWindowTypes = new Set([ const silllessWindowTypes = new Set(['bay', 'bow']) export default function WindowPanel() { + const t = useTranslations() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) @@ -299,13 +301,13 @@ export default function WindowPanel() { const showFlipSide = !isOpening const operationLabel = isTrackSashWindow ? windowType === 'sliding' - ? 'Slide' - : 'Raise' + ? t('nodes.window.operationLabels.slide') + : t('nodes.window.operationLabels.raise') : windowType === 'casement' - ? 'Swing' + ? t('nodes.window.operationLabels.swing') : windowType === 'louvered' - ? 'Slats' - : 'Tilt' + ? t('nodes.window.operationLabels.slats') + : t('nodes.window.operationLabels.tilt') const setOperationState = (value: number) => { useInteractive.getState().cancelWindowAnimation(node.id) @@ -390,10 +392,10 @@ export default function WindowPanel() { - + handleUpdate({ @@ -411,8 +413,8 @@ export default function WindowPanel() { }) } options={[ - { value: 'window', label: 'Window' }, - { value: 'opening', label: 'Opening' }, + { value: 'window', label: t('nodes.window.typeOptions.window') }, + { value: 'opening', label: t('nodes.window.typeOptions.opening') }, ]} value={node.openingKind ?? 'window'} /> @@ -434,7 +436,7 @@ export default function WindowPanel() { {showWindowTypeSection && ( - +
{windowTypeOptions.map((option) => { const isSelected = displayedWindowType === option.value @@ -459,7 +461,7 @@ export default function WindowPanel() { } type="button" > - {option.label} + {t(option.label)} ) })} @@ -474,8 +476,8 @@ export default function WindowPanel() { }) } options={[ - { value: 'up', label: 'Up' }, - { value: 'down', label: 'Down' }, + { value: 'up', label: t('common.directions.up') }, + { value: 'down', label: t('common.directions.down') }, ]} value={awningDirection} /> @@ -488,8 +490,8 @@ export default function WindowPanel() { handleUpdate({ casementStyle: value as WindowNode['casementStyle'] }) } options={[ - { value: 'single', label: 'Single' }, - { value: 'french', label: 'French' }, + { value: 'single', label: t('nodes.window.casementStyleOptions.single') }, + { value: 'french', label: t('nodes.window.casementStyleOptions.french') }, ]} value={node.casementStyle ?? 'single'} /> @@ -499,8 +501,8 @@ export default function WindowPanel() { handleUpdate({ hingesSide: value as WindowNode['hingesSide'] }) } options={[ - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' }, + { value: 'left', label: t('common.directions.left') }, + { value: 'right', label: t('common.directions.right') }, ]} value={node.hingesSide ?? 'left'} /> @@ -524,7 +526,7 @@ export default function WindowPanel() { )} - + @@ -554,16 +556,16 @@ export default function WindowPanel() { } - label="Flip Side" + label={t('nodes.window.flipSide')} onClick={handleFlip} />
)}
- + handleUpdate(getDimensionUpdates({ width: v }))} precision={2} @@ -573,7 +575,7 @@ export default function WindowPanel() { value={Math.round(node.width * 100) / 100} /> handleUpdate(getDimensionUpdates({ height: v }))} precision={2} @@ -585,7 +587,7 @@ export default function WindowPanel() { {showWindowShapeSection && ( - + handleUpdate({ @@ -603,9 +605,9 @@ export default function WindowPanel() { }) } options={[ - { value: 'rectangle', label: 'Rect' }, - { value: 'rounded', label: 'Rounded' }, - { value: 'arch', label: 'Arch' }, + { value: 'rectangle', label: t('nodes.window.topShapeOptions.rect') }, + { value: 'rounded', label: t('nodes.window.topShapeOptions.rounded') }, + { value: 'arch', label: t('nodes.window.topShapeOptions.arch') }, ]} value={windowShape} /> @@ -616,14 +618,14 @@ export default function WindowPanel() { handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) } options={[ - { value: 'all', label: 'All' }, - { value: 'individual', label: 'Individual' }, + { value: 'all', label: t('nodes.window.radiusModeOptions.all') }, + { value: 'individual', label: t('nodes.window.radiusModeOptions.individual') }, ]} value={openingRadiusMode} /> {openingRadiusMode === 'all' ? ( previewWindowUpdate('cornerRadius', value)} @@ -636,10 +638,10 @@ export default function WindowPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], - ['Bottom Right', 2], - ['Bottom Left', 3], + [t('common.corners.topLeft'), 0], + [t('common.corners.topRight'), 1], + [t('common.corners.bottomRight'), 2], + [t('common.corners.bottomLeft'), 3], ].map(([label, index]) => ( )} previewWindowUpdate('openingRevealRadius', value)} @@ -672,7 +674,7 @@ export default function WindowPanel() { {windowShape === 'arch' && (
handleUpdate({ archHeight: value })} @@ -688,15 +690,15 @@ export default function WindowPanel() { )} {showOpeningShapeSection && ( - + handleUpdate({ openingShape: value as WindowNode['openingShape'] }) } options={[ - { value: 'rectangle', label: 'Rect' }, - { value: 'rounded', label: 'Rounded' }, - { value: 'arch', label: 'Arch' }, + { value: 'rectangle', label: t('nodes.window.openingShapeOptions.rect') }, + { value: 'rounded', label: t('nodes.window.openingShapeOptions.rounded') }, + { value: 'arch', label: t('nodes.window.openingShapeOptions.arch') }, ]} value={openingShape} /> @@ -707,14 +709,14 @@ export default function WindowPanel() { handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) } options={[ - { value: 'all', label: 'All' }, - { value: 'individual', label: 'Individual' }, + { value: 'all', label: t('nodes.window.radiusModeOptions.all') }, + { value: 'individual', label: t('nodes.window.radiusModeOptions.individual') }, ]} value={openingRadiusMode} /> {openingRadiusMode === 'all' ? ( previewWindowUpdate('cornerRadius', value)} @@ -727,10 +729,10 @@ export default function WindowPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], - ['Bottom Right', 2], - ['Bottom Left', 3], + [t('common.corners.topLeft'), 0], + [t('common.corners.topRight'), 1], + [t('common.corners.bottomRight'), 2], + [t('common.corners.bottomLeft'), 3], ].map(([label, index]) => ( )} previewWindowUpdate('openingRevealRadius', value)} @@ -763,7 +765,7 @@ export default function WindowPanel() { {openingShape === 'arch' && (
handleUpdate({ archHeight: value })} @@ -781,9 +783,9 @@ export default function WindowPanel() { {!isOpening && ( <> {showFrameSection && ( - + handleUpdate({ frameThickness: v })} precision={3} @@ -792,7 +794,7 @@ export default function WindowPanel() { value={Math.round(node.frameThickness * 1000) / 1000} /> handleUpdate({ frameDepth: v })} precision={3} @@ -804,9 +806,9 @@ export default function WindowPanel() { )} {showGridSection && ( - + { @@ -818,7 +820,7 @@ export default function WindowPanel() { value={numCols} /> { @@ -833,7 +835,7 @@ export default function WindowPanel() { {numCols > 1 && (
- Col Widths + {t('nodes.window.colWidths')}
{normCols.map((ratio, i) => ( handleUpdate({ columnDividerThickness: v })} @@ -866,7 +868,7 @@ export default function WindowPanel() { {numRows > 1 && (
- Row Heights + {t('nodes.window.rowHeights')}
{normRows.map((ratio, i) => ( handleUpdate({ rowDividerThickness: v })} @@ -899,16 +901,16 @@ export default function WindowPanel() { )} {showSillSection && ( - + handleUpdate({ sill: checked })} /> {node.sill && (
handleUpdate({ sillDepth: v })} precision={3} @@ -917,7 +919,7 @@ export default function WindowPanel() { value={Math.round(node.sillDepth * 1000) / 1000} /> handleUpdate({ sillThickness: v })} precision={3} @@ -932,18 +934,18 @@ export default function WindowPanel() { )} - + - } label="Move" onClick={handleMove} /> + } label={t('common.move')} onClick={handleMove} /> } - label="Duplicate" + label={t('common.duplicate')} onClick={handleDuplicate} /> } - label="Delete" + label={t('common.delete')} onClick={handleDelete} /> diff --git a/packages/nodes/src/window/parametrics.ts b/packages/nodes/src/window/parametrics.ts index 785a94a2be..ddcbe02179 100644 --- a/packages/nodes/src/window/parametrics.ts +++ b/packages/nodes/src/window/parametrics.ts @@ -12,6 +12,7 @@ export const windowParametrics: ParametricDescriptor = { groups: [ { label: 'Dimensions', + labelKey: 'common.dimensions', fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, { key: 'height', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index feec01c2dd..f52313b852 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -36,6 +36,7 @@ import { useFacingPose, usePlacementPreview, useRegistryToolContext, + useTranslations, } from '@pascal-app/editor' import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' @@ -63,6 +64,7 @@ import { resolveWallSlideAlignment, } from '../shared/wall-opening-alignment' import { WindowFloorProjection } from './floor-projection' +import { getDefaultWindowName } from './naming' import WindowPreview from './preview' import { clampToWall, @@ -104,6 +106,7 @@ type HostKind = 'wall' | 'roof' | 'dormer' | null * engages only on an actual mesh hover — no proximity magnet. */ const WindowTool: React.FC = () => { + const t = useTranslations() const { activeLevelId, isCameraDragging, selectNode } = useRegistryToolContext() const draftRef = useRef(null) const cursorGroupRef = useRef(null!) @@ -553,7 +556,7 @@ const WindowTool: React.FC = () => { }).length const node = WindowNode.parse({ - name: `Window ${windowCount + 1}`, + name: getDefaultWindowName(windowCount + 1, t), position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, @@ -606,7 +609,7 @@ const WindowTool: React.FC = () => { const windowCount = Object.values(state.nodes).filter((node) => node.type === 'window').length const side = sideFlip ? 'back' : 'front' const node = WindowNode.parse({ - name: `Window ${windowCount + 1}`, + name: getDefaultWindowName(windowCount + 1, t), position: target.position, rotation: [0, sideFlip ? Math.PI : 0, 0], side, @@ -919,7 +922,7 @@ const WindowTool: React.FC = () => { ).length const node = WindowNode.parse({ - name: `Window ${windowCount + 1}`, + name: getDefaultWindowName(windowCount + 1, t), position, rotation: [0, 0, 0], side: 'front', diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index a081c37f14..25906e0875 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -94,6 +94,7 @@ export const zoneDefinition: NodeDefinition = { presentation: { label: 'Zone', + labelKey: 'panel.nodeType.zone', description: 'A polygonal site zone (lawn, water, paving) with a TSL gradient material.', icon: { kind: 'url', src: '/icons/zone.webp' }, paletteSection: 'site', diff --git a/packages/nodes/src/zone/quantities-panel.tsx b/packages/nodes/src/zone/quantities-panel.tsx index e81e31cb5f..5fd74ff914 100644 --- a/packages/nodes/src/zone/quantities-panel.tsx +++ b/packages/nodes/src/zone/quantities-panel.tsx @@ -15,6 +15,7 @@ import { MetricControl, PanelSection, ToggleControl, + useTranslations, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -33,10 +34,11 @@ function ZonePlanSketch({ polygon: readonly Point2D[] unit: 'metric' | 'imperial' }) { + const t = useTranslations() if (polygon.length < 3) { return (
- Zone boundary unavailable + {t('nodes.zone.boundaryUnavailable')}
) } @@ -65,7 +67,7 @@ function ZonePlanSketch({ return (
{abbreviation} {label} - {quantity.status === 'available' ? format(quantity.value) : 'Not proven'} + {quantity.status === 'available' ? format(quantity.value) : t('nodes.zone.notProven')}
@@ -234,60 +237,61 @@ function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) { const updateNode = useScene((state) => state.updateNode) const update = (patch: Partial) => updateNode(zone.id, patch) const isRoom = zone.spaceRole === 'room' + const t = useTranslations() return ( - + update({ spaceRole: checked ? 'room' : 'generic' })} /> {isRoom ? ( <> update({ name })} value={zone.name} /> update({ roomNumber })} value={zone.roomNumber} /> update({ enclosureStatus: enclosureStatus as ZoneNode['enclosureStatus'] }) } options={[ - { label: 'Auto-detect', value: 'auto' }, - { label: 'Enclosed', value: 'enclosed' }, - { label: 'Open', value: 'open' }, + { label: t('nodes.zone.autoDetect'), value: 'auto' }, + { label: t('nodes.zone.enclosed'), value: 'enclosed' }, + { label: t('nodes.zone.open'), value: 'open' }, ]} value={zone.enclosureStatus} /> update({ occupancy })} value={zone.occupancy} /> update({ floorFinish })} value={zone.floorFinish} /> update({ wallFinish })} value={zone.wallFinish} /> update({ ceilingFinish })} value={zone.ceilingFinish} /> update({ ceilingHeight })} @@ -297,16 +301,16 @@ function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) { value={zone.ceilingHeight} /> update({ clearDimensionPolicy: clearDimensionPolicy as ZoneNode['clearDimensionPolicy'], }) } options={[ - { label: 'None', value: 'none' }, - { label: 'Inside faces', value: 'inside-faces' }, - { label: 'Finish faces', value: 'finish-faces' }, + { label: t('common.none'), value: 'none' }, + { label: t('nodes.zone.insideFaces'), value: 'inside-faces' }, + { label: t('nodes.zone.finishFaces'), value: 'finish-faces' }, ]} value={zone.clearDimensionPolicy} /> @@ -317,6 +321,7 @@ function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) { } export default function ZoneQuantitiesPanel() { + const t = useTranslations() const selectedZoneId = useViewer((state) => state.selection.zoneId) const unit = useViewer((state) => state.unit) const metricNotation = useViewer((state) => state.metricNotation) @@ -362,13 +367,17 @@ export default function ZoneQuantitiesPanel() { <>
{effectiveZone.name} - {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} + {report.classification === 'enclosed-room' + ? t('nodes.zone.enclosedRoom') + : t('nodes.zone.footprintOnly')}
@@ -390,19 +399,19 @@ export default function ZoneQuantitiesPanel() { formatAreaLabel(value, unit, 2)} - label="Wall surface" + label={t('nodes.zone.wallSurface')} quantity={report.wallSurface} /> formatAreaLabel(value, unit, 2)} - label="Floor surface" + label={t('nodes.zone.floorSurface')} quantity={report.floorSurface} /> formatVolumeLabel(value, unit, 2)} - label="Volume" + label={t('nodes.zone.volume')} quantity={report.volume} />
diff --git a/packages/viewer/src/components/error-boundary.tsx b/packages/viewer/src/components/error-boundary.tsx index 5f8ba896b5..712af37f27 100644 --- a/packages/viewer/src/components/error-boundary.tsx +++ b/packages/viewer/src/components/error-boundary.tsx @@ -1,3 +1,5 @@ +'use client' + import type { ErrorInfo, ReactNode } from 'react' import { Component } from 'react' diff --git a/packages/viewer/src/components/viewer/frame-limiter.tsx b/packages/viewer/src/components/viewer/frame-limiter.tsx index 0a1bff5beb..948d67aa13 100644 --- a/packages/viewer/src/components/viewer/frame-limiter.tsx +++ b/packages/viewer/src/components/viewer/frame-limiter.tsx @@ -1,3 +1,5 @@ +'use client' + import { useThree } from '@react-three/fiber' import { useLayoutEffect, useRef } from 'react' import useViewer from '../../store/use-viewer' diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 53624d4fd4..9a7f41b395 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -1,3 +1,5 @@ +'use client' + import { sceneRegistry } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useMemo, useRef } from 'react' diff --git a/packages/viewer/src/components/viewer/perf-monitor.tsx b/packages/viewer/src/components/viewer/perf-monitor.tsx index 2972a6b5f1..0fa76d9888 100644 --- a/packages/viewer/src/components/viewer/perf-monitor.tsx +++ b/packages/viewer/src/components/viewer/perf-monitor.tsx @@ -1,3 +1,5 @@ +'use client' + import { useScene } from '@pascal-app/core' import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b4e83eaf76..3d39b215fc 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,3 +1,5 @@ +'use client' + import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Color, Layers, Matrix4, type Object3D, Scene, UnsignedByteType } from 'three' diff --git a/packages/viewer/src/components/viewer/scene-bvh.tsx b/packages/viewer/src/components/viewer/scene-bvh.tsx index 9dd6a5844d..f937bf5a85 100644 --- a/packages/viewer/src/components/viewer/scene-bvh.tsx +++ b/packages/viewer/src/components/viewer/scene-bvh.tsx @@ -1,3 +1,5 @@ +'use client' + import { useThree } from '@react-three/fiber' import { forwardRef, type ReactNode, useEffect, useImperativeHandle, useRef } from 'react' import { type BufferGeometry, type Group, Mesh } from 'three' diff --git a/packages/viewer/src/components/viewer/viewer-camera.tsx b/packages/viewer/src/components/viewer/viewer-camera.tsx index adb24e9ff3..d81f724692 100644 --- a/packages/viewer/src/components/viewer/viewer-camera.tsx +++ b/packages/viewer/src/components/viewer/viewer-camera.tsx @@ -1,3 +1,5 @@ +'use client' + import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei' import useViewer from '../../store/use-viewer' diff --git a/packages/viewer/src/hooks/use-asset-url.ts b/packages/viewer/src/hooks/use-asset-url.ts index 57bf04d371..8d0a7c1f1e 100644 --- a/packages/viewer/src/hooks/use-asset-url.ts +++ b/packages/viewer/src/hooks/use-asset-url.ts @@ -1,3 +1,5 @@ +'use client' + import { loadAssetUrl } from '@pascal-app/core' import { useEffect, useState } from 'react' diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index ff8b3eed64..fd6aa83fbf 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -1,3 +1,5 @@ +'use client' + import { useGLTF } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index b583f2a83e..7613c28c7e 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -1,3 +1,5 @@ +'use client' + import { type AnyNode, type AnyNodeType, diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 6159ce6dbe..91f19ee686 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -1,3 +1,5 @@ +'use client' + import { getMaterialPresetByRef, type MaterialMapProperties, diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index 4c621ffb89..bc86297d65 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -1,5 +1,6 @@ // @ts-nocheck — Three.js TSL/WebGPU internal APIs have incomplete type definitions; // this file is a fork of OutlineNode and is intentionally exempt from strict TS checking. +'use client' /** * MergedOutlineNode — a fork of Three.js OutlineNode that processes two object diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 355274353b..a85fd4bd98 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -27,8 +27,8 @@ type ViewerState = { setExporting: (value: boolean) => void levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void - wallMode: 'up' | 'cutaway' | 'down' - setWallMode: (mode: 'up' | 'cutaway' | 'down') => void + wallMode: 'up' | 'cutaway' | 'down' | 'translucent' + setWallMode: (mode: 'up' | 'cutaway' | 'down' | 'translucent') => void showScans: boolean setShowScans: (show: boolean) => void showGuides: boolean diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index e490ecf697..16691baf28 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type CeilingNode, diff --git a/packages/viewer/src/systems/column/column-geometry.ts b/packages/viewer/src/systems/column/column-geometry.ts index 3319cfd653..bf22cecf7b 100644 --- a/packages/viewer/src/systems/column/column-geometry.ts +++ b/packages/viewer/src/systems/column/column-geometry.ts @@ -1,3 +1,5 @@ +'use client' + import { BoxGeometry, type BufferGeometry, diff --git a/packages/viewer/src/systems/door/door-animation-system.tsx b/packages/viewer/src/systems/door/door-animation-system.tsx index 2c2f380ac7..33b04dbd07 100644 --- a/packages/viewer/src/systems/door/door-animation-system.tsx +++ b/packages/viewer/src/systems/door/door-animation-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type DoorNode, emitter, useInteractive, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 3a2d96cb7e..206caf54db 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, clampDoorOperationState, diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx index 2b5699dba5..2187b007c8 100644 --- a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx +++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, openElevatorDoor, diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index 180076b6ee..ee56b3f895 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type FenceNode, diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 0216f76724..7f3ee0b251 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNode, type AnyNodeId, diff --git a/packages/viewer/src/systems/guide/guide-system.tsx b/packages/viewer/src/systems/guide/guide-system.tsx index bf0f951f11..b974a69bce 100644 --- a/packages/viewer/src/systems/guide/guide-system.tsx +++ b/packages/viewer/src/systems/guide/guide-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { sceneRegistry } from '@pascal-app/core' import { useEffect } from 'react' import useViewer from '../../store/use-viewer' diff --git a/packages/viewer/src/systems/item-light/item-light-system.tsx b/packages/viewer/src/systems/item-light/item-light-system.tsx index 2f0df245df..fd8c130644 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.tsx +++ b/packages/viewer/src/systems/item-light/item-light-system.tsx @@ -1,3 +1,5 @@ +'use client' + import type { AnyNodeId, LevelNode } from '@pascal-app/core' import { findLevelAncestorId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' diff --git a/packages/viewer/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx index 7aa7fb6790..fadc011960 100644 --- a/packages/viewer/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type ItemNode, diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 3c4b966c0a..8995197268 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNode, type AnyNodeId, diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 13c52c3a59..164e8706a1 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { getRenderableSlabPolygon, type PolygonPoint2D, diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index e63b75fe13..14e8cc9296 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNode, type AnyNodeId, diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 81a0915964..95873fe0c1 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -1,3 +1,5 @@ +'use client' + import { getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 49ea7a9c94..69758b685e 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNode, type AnyNodeId, diff --git a/packages/viewer/src/systems/window/window-animation-system.tsx b/packages/viewer/src/systems/window/window-animation-system.tsx index f5833c8c49..acb13f3af8 100644 --- a/packages/viewer/src/systems/window/window-animation-system.tsx +++ b/packages/viewer/src/systems/window/window-animation-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, emitter, diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 32f8c6760d..c7beae7274 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, DEFAULT_WALL_THICKNESS, diff --git a/packages/viewer/src/systems/zone/zone-system.tsx b/packages/viewer/src/systems/zone/zone-system.tsx index 917def3af0..7fa17e9395 100644 --- a/packages/viewer/src/systems/zone/zone-system.tsx +++ b/packages/viewer/src/systems/zone/zone-system.tsx @@ -1,3 +1,5 @@ +'use client' + import { sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react'