From 3580e4ded8548975d7c374dbfa70fffb7da4870f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 17:37:13 +0000 Subject: [PATCH 1/4] i18n: migrate the vent, fence, gate and MEP inspectors Box, ridge, turbine and eyebrow vents and the cupola; fence path, surface and pattern editors and the gate/opening inspector; run hangers, downspouts, and the MEP tool HUDs. Split sentences, the fence path summary, the hanger and downspout names and the mount label are whole ICU messages; the path length uses formatFixed and counts stay ungrouped. Segmented control labels may break inside a word, so a long translation wraps in its segment instead of spilling into the next. New audit surfaces for the five vents and the fence. --- i18n/pseudo-surfaces.ts | 74 +++++++++ .../ui/controls/segmented-control.tsx | 2 +- packages/nodes/src/box-vent/panel.tsx | 54 ++++--- packages/nodes/src/cupola/panel.tsx | 44 ++++-- .../nodes/src/downspout/inspector-editors.tsx | 4 +- .../src/duct-fitting/inspector-editors.tsx | 9 +- packages/nodes/src/duct-fitting/tool.tsx | 25 ++- packages/nodes/src/duct-terminal/tool.tsx | 27 +++- packages/nodes/src/eyebrow-vent/panel.tsx | 53 +++++-- packages/nodes/src/fence-feature/actions.tsx | 9 +- .../nodes/src/fence-feature/inspector.tsx | 148 ++++++++++++++---- .../nodes/src/fence/inspector-editors.tsx | 125 +++++++++++---- .../nodes/src/fence/move-endpoint-tool.tsx | 12 +- .../nodes/src/gutter/downspouts-panel.tsx | 26 ++- packages/nodes/src/hvac-equipment/tool.tsx | 6 +- packages/nodes/src/liquid-line/tool.tsx | 12 +- packages/nodes/src/pipe-fitting/tool.tsx | 14 +- packages/nodes/src/pipe-trap/tool.tsx | 15 +- packages/nodes/src/ridge-vent/panel.tsx | 61 +++++--- .../nodes/src/shared/run-hanger-inspector.tsx | 66 ++++++-- packages/nodes/src/turbine-vent/panel.tsx | 56 ++++--- 21 files changed, 649 insertions(+), 193 deletions(-) diff --git a/i18n/pseudo-surfaces.ts b/i18n/pseudo-surfaces.ts index dcb392a7f1..20e9ae5e70 100644 --- a/i18n/pseudo-surfaces.ts +++ b/i18n/pseudo-surfaces.ts @@ -1,6 +1,14 @@ import type { Page } from 'playwright-core' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { template as twoBedroom } from '../packages/mcp/src/templates/two-bedroom' +import { + BoxVentNode, + CupolaNode, + EyebrowVentNode, + FenceNode, + RidgeVentNode, + TurbineVentNode, +} from '../packages/core/src/schema' import { pseudoLocalize } from '../scripts/i18n' import { RoofNode, @@ -128,6 +136,29 @@ export const apartmentPath = async (base: string) => `/project/${await auditScene(base, 'two-bedroom', twoBedroom)}` const apartmentData = ['Project #1', 'Living / Kitchen', 'Bedroom 1', 'Bedroom 2', 'Bath'] +/** The furnished scene plus the five roof vents on its roof segment and a garden fence. */ +function ventsAndFenceScene() { + const graph = furnishedScene() as unknown as { + nodes: Record + } + const onRoof = { parentId: 'rseg_audit', roofSegmentId: 'rseg_audit' } + const vents = [ + BoxVentNode.parse({ id: 'bvent_audit', ...onRoof, position: [-1, 0, -1.5] }), + RidgeVentNode.parse({ id: 'rvent_audit', ...onRoof, position: [0, 0, 0] }), + TurbineVentNode.parse({ id: 'tvent_audit', ...onRoof, position: [1, 0, -1.5] }), + EyebrowVentNode.parse({ id: 'eyebrow-vent_audit', ...onRoof, position: [-1, 0, 1.5] }), + CupolaNode.parse({ id: 'cupola_audit', ...onRoof, position: [1, 0, 1.5] }), + ] + const fence = FenceNode.parse({ id: 'fence_audit', parentId: 'level_0', start: [-2, 12], end: [6, 12] }) + for (const node of [...vents, fence]) graph.nodes[node.id] = node + graph.nodes.rseg_audit?.children?.push(...vents.map((node) => node.id)) + graph.nodes.level_0?.children?.push(fence.id) + return graph as unknown as SceneGraph +} + +const ventsAndFencePath = async (base: string) => + `/project/${await auditScene(base, 'vents-fence', ventsAndFenceScene())}` + async function openLevelMenu(page: Page, row = 0): Promise { const level = page.locator('[data-surface="level-selector"] .group\\/level').nth(row) await level.hover() @@ -250,6 +281,49 @@ export const surfaces: PseudoSurface[] = [ // A kind registered by the external @pascal-app/plugin-streetscape, which owns its copy. data: ['Road'], }, + { + name: 'box-vent-panel', + path: ventsAndFencePath, + steps: (page) => openLayer(page, 'Box Vent'), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'ridge-vent-panel', + path: ventsAndFencePath, + steps: (page) => openLayer(page, 'Ridge Vent'), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'turbine-vent-panel', + path: ventsAndFencePath, + steps: (page) => openLayer(page, 'Turbine Vent'), + scope: '[data-editor-inspector]', + // The spin-speed unit symbol. + data: [...furnishedData, 'rad/s'], + }, + { + name: 'eyebrow-vent-panel', + path: ventsAndFencePath, + steps: (page) => openLayer(page, 'Eyebrow Vent'), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'cupola-panel', + path: ventsAndFencePath, + steps: (page) => openLayer(page, 'Cupola'), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'fence-panel', + path: ventsAndFencePath, + steps: (page) => openLayer(page, 'Fence'), + scope: '[data-editor-inspector]', + data: furnishedData, + }, { name: 'room-panel', path: furnishedPath, diff --git a/packages/editor/src/components/ui/controls/segmented-control.tsx b/packages/editor/src/components/ui/controls/segmented-control.tsx index 33b31722c4..aa3d93ae89 100644 --- a/packages/editor/src/components/ui/controls/segmented-control.tsx +++ b/packages/editor/src/components/ui/controls/segmented-control.tsx @@ -54,7 +54,7 @@ export function SegmentedControl({ }} type="button" > - + {option.label} diff --git a/packages/nodes/src/box-vent/panel.tsx b/packages/nodes/src/box-vent/panel.tsx index 2979041553..64fccef446 100644 --- a/packages/nodes/src/box-vent/panel.tsx +++ b/packages/nodes/src/box-vent/panel.tsx @@ -6,6 +6,7 @@ import { getActiveRoofHeight, type RoofSegmentNode, useLiveNodeOverrides, + usePascalI18n, useScene, } from '@pascal-app/core' import { @@ -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 i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -142,24 +144,24 @@ export default function BoxVentPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Box Vent'} + title={node.name || i18n._({ id: 'node.boxVent.panel.boxVent', message: 'Box Vent' })} width={300} > - + handleUpdate({ style: v as BoxVentNode['style'] })} options={[ - { label: 'Box', value: 'box' }, - { label: 'Cap', value: 'cap' }, - { label: 'Dome', value: 'dome' }, + { label: i18n._({ id: 'node.boxVent.panel.box', message: 'Box' }), value: 'box' }, + { label: i18n._({ id: 'node.boxVent.panel.cap', message: 'Cap' }), value: 'cap' }, + { label: i18n._({ id: 'node.boxVent.panel.dome', message: 'Dome' }), value: 'dome' }, ]} value={node.style ?? 'cap'} /> - + previewProp({ width: v })} @@ -171,7 +173,7 @@ export default function BoxVentPanel() { value={node.width} /> previewProp({ depth: v })} @@ -183,7 +185,7 @@ export default function BoxVentPanel() { value={node.depth} /> previewProp({ height: v })} @@ -200,7 +202,7 @@ export default function BoxVentPanel() { flare further past the body. */} {node.style === 'cap' && ( previewProp({ hoodOverhang: v })} @@ -215,7 +217,7 @@ export default function BoxVentPanel() { {node.style === 'box' && ( <> previewProp({ baseInset: v })} @@ -227,7 +229,7 @@ export default function BoxVentPanel() { value={node.baseInset ?? 0.06} /> previewProp({ baseHeight: v })} @@ -239,7 +241,7 @@ export default function BoxVentPanel() { value={node.baseHeight ?? 0.04} /> previewProp({ capHeight: v })} @@ -270,7 +272,7 @@ export default function BoxVentPanel() { value={node.capHeight ?? 0.07} /> previewProp({ capGap: v })} @@ -282,7 +284,7 @@ export default function BoxVentPanel() { value={node.capGap ?? 0} /> previewProp({ topTaper: v })} @@ -298,7 +300,7 @@ export default function BoxVentPanel() { {node.style === 'dome' && ( <> previewProp({ domeCurvature: v })} @@ -310,7 +312,7 @@ export default function BoxVentPanel() { value={Math.round((node.domeCurvature ?? 1.0) * 100) / 100} /> previewProp({ hoodOverhang: v })} @@ -325,7 +327,7 @@ export default function BoxVentPanel() { )} - + previewProp({ rotation: (deg * Math.PI) / 180 })} @@ -403,18 +405,22 @@ export default function BoxVentPanel() { /> - + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.boxVent.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.boxVent.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.boxVent.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/cupola/panel.tsx b/packages/nodes/src/cupola/panel.tsx index 894c0b0dbf..702589cfa2 100644 --- a/packages/nodes/src/cupola/panel.tsx +++ b/packages/nodes/src/cupola/panel.tsx @@ -6,6 +6,7 @@ import { getActiveRoofHeight, type RoofSegmentNode, useLiveNodeOverrides, + usePascalI18n, useScene, } from '@pascal-app/core' import { @@ -30,6 +31,7 @@ import type { CupolaNode } from './schema' * flow the placement tool uses. Mirrors the box-vent panel. */ export default function CupolaPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -120,31 +122,37 @@ export default function CupolaPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Cupola'} + title={node.name || i18n._({ id: 'node.cupola.panel.cupola', message: 'Cupola' })} width={300} > - + handleUpdate({ roofStyle: v as CupolaNode['roofStyle'] })} options={[ - { label: 'Dome', value: 'dome' }, - { label: 'Pyramid', value: 'pyramid' }, + { label: i18n._({ id: 'node.cupola.panel.dome', message: 'Dome' }), value: 'dome' }, + { + label: i18n._({ id: 'node.cupola.panel.pyramid', message: 'Pyramid' }), + value: 'pyramid', + }, ]} value={node.roofStyle ?? 'dome'} /> handleUpdate({ finial: v === 'on' })} options={[ - { label: 'Finial', value: 'on' }, - { label: 'No Finial', value: 'off' }, + { label: i18n._({ id: 'node.cupola.panel.finial', message: 'Finial' }), value: 'on' }, + { + label: i18n._({ id: 'node.cupola.panel.noFinial', message: 'No Finial' }), + value: 'off', + }, ]} value={(node.finial ?? true) ? 'on' : 'off'} /> - + previewProp({ width: v })} @@ -156,7 +164,7 @@ export default function CupolaPanel() { value={node.width} /> previewProp({ depth: v })} @@ -168,7 +176,7 @@ export default function CupolaPanel() { value={node.depth} /> previewProp({ height: v })} @@ -181,7 +189,7 @@ export default function CupolaPanel() { /> - + previewProp({ rotation: (deg * Math.PI) / 180 })} @@ -247,18 +255,22 @@ export default function CupolaPanel() { /> - + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.cupola.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.cupola.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.cupola.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/downspout/inspector-editors.tsx b/packages/nodes/src/downspout/inspector-editors.tsx index 3a8c1dcd9b..20c842143e 100644 --- a/packages/nodes/src/downspout/inspector-editors.tsx +++ b/packages/nodes/src/downspout/inspector-editors.tsx @@ -6,6 +6,7 @@ import { type GutterNode, type GutterOutlet, useLiveNodeOverrides, + usePascalI18n, useScene, } from '@pascal-app/core' import { SliderControl } from '@pascal-app/editor' @@ -31,6 +32,7 @@ import { SliderControl } from '@pascal-app/editor' * downspout isn't linked to an outlet. */ export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { + const i18n = usePascalI18n() const gutter = useScene((s) => node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined, ) @@ -84,7 +86,7 @@ export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { return ( ) => void }) { + const i18n = usePascalI18n() const nextWidth = clamp(node.height, WIDTH_MIN, WIDTH_MAX) const nextHeight = clamp(node.width, HEIGHT_MIN, HEIGHT_MAX) @@ -28,9 +30,12 @@ export function DuctFittingSizeSwapEditor({ } - label="Swap W/H" + label={i18n._({ id: 'node.ductFitting.inspectorEditors.swapWH', message: 'Swap W/H' })} onClick={() => onUpdate({ width: nextWidth, height: nextHeight })} - title="Swap width and height" + title={i18n._({ + id: 'node.ductFitting.inspectorEditors.swapWidthAndHeight', + message: 'Swap width and height', + })} type="button" /> diff --git a/packages/nodes/src/duct-fitting/tool.tsx b/packages/nodes/src/duct-fitting/tool.tsx index f89058219d..4354865159 100644 --- a/packages/nodes/src/duct-fitting/tool.tsx +++ b/packages/nodes/src/duct-fitting/tool.tsx @@ -1,6 +1,6 @@ 'use client' -import { DuctFittingNode, emitter, type GridEvent, useScene } from '@pascal-app/core' +import { DuctFittingNode, emitter, type GridEvent, usePascalI18n, useScene } from '@pascal-app/core' import { CursorSphere, EDITOR_LAYER, @@ -160,6 +160,7 @@ export function resolvePlacement( * node happens to be selected. */ const DuctFittingTool = () => { + const i18n = usePascalI18n() const activeLevelId = useViewer((s) => s.selection.levelId) const [placement, setPlacement] = useState(null) const toolDefaults = useEditor((s) => s.toolDefaults['duct-fitting']) @@ -362,18 +363,32 @@ const DuctFittingTool = () => {
{previewNode.fittingType === 'access-panel' && ( - {placement.valid ? 'Place access door' : 'Hover a duct face that fits the door'} + {placement.valid + ? i18n._({ + id: 'node.ductFitting.tool.placeAccessDoor', + message: 'Place access door', + }) + : i18n._({ + id: 'node.ductFitting.tool.hoverADuctFaceThatFits', + message: 'Hover a duct face that fits the door', + })} )} - Axis {axis.toUpperCase()} + + {i18n._({ id: 'node.ductFitting.tool.axis', message: 'Axis' })} {axis.toUpperCase()} + · - R/T rotate + + {i18n._({ id: 'node.ductFitting.tool.rTRotate', message: 'R/T rotate' })} + · - ⌥ axis + + {i18n._({ id: 'node.ductFitting.tool.axis2', message: '⌥ axis' })} +
{/* Port-snap halo so the user sees the click will mate, not free-place. */} diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index 70ac58bf8b..14c88eea1a 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -12,6 +12,7 @@ import { resolveLevelId, resolveSupportSlabPatch, sceneRegistry, + usePascalI18n, useScene, type WallEvent, } from '@pascal-app/core' @@ -162,6 +163,7 @@ function resolvePortSnap( * yaw ±45°; wall yaw is fixed by the wall it mates to. */ const DuctTerminalTool = () => { + const i18n = usePascalI18n() const { camera, gl } = useThree() const activeLevelId = useViewer((s) => s.selection.levelId) const toolDefaults = useEditor((s) => s.toolDefaults['duct-terminal']) @@ -530,23 +532,40 @@ const DuctTerminalTool = () => {
{placement.snapped && ( <> - Snapped to duct + + {i18n._({ id: 'node.ductTerminal.tool.snappedToDuct', message: 'Snapped to duct' })} + · )} - Mount {mountLabel} + + {i18n._({ + id: 'node.ductTerminal.tool.mount', + message: + '{mount, select, floor {Mount Floor} ceiling {Mount Ceiling} wall {Mount Wall} other {Mount {label}}}', + values: { mount: effectiveMount, label: mountLabel }, + })} + · - M surface + + {i18n._({ + id: 'node.ductTerminal.tool.mSurface', + message: 'M surface', + comment: 'Key hint: pressing M switches the mounting surface.', + })} + {effectiveMount !== 'wall' && ( <> · - R/T rotate + + {i18n._({ id: 'node.ductTerminal.tool.rTRotate', message: 'R/T rotate' })} + )}
diff --git a/packages/nodes/src/eyebrow-vent/panel.tsx b/packages/nodes/src/eyebrow-vent/panel.tsx index ab49bf9f98..eb73b979d3 100644 --- a/packages/nodes/src/eyebrow-vent/panel.tsx +++ b/packages/nodes/src/eyebrow-vent/panel.tsx @@ -6,6 +6,7 @@ import { getActiveRoofHeight, type RoofSegmentNode, useLiveNodeOverrides, + usePascalI18n, useScene, } from '@pascal-app/core' import { @@ -30,6 +31,7 @@ import type { EyebrowVentNode } from './schema' * placement tool uses. Mirrors the box-vent / cupola panel. */ export default function EyebrowVentPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -122,21 +124,32 @@ export default function EyebrowVentPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Eyebrow Vent'} + title={ + node.name || i18n._({ id: 'node.eyebrowVent.panel.eyebrowVent', message: 'Eyebrow Vent' }) + } 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: i18n._({ id: 'node.eyebrowVent.panel.scoop', message: 'Scoop' }), + value: 'scoop', + }, + { + label: i18n._({ id: 'node.eyebrowVent.panel.halfRound', message: 'Half-round' }), + value: 'half-round', + }, + { + label: i18n._({ id: 'node.eyebrowVent.panel.slantBox', message: 'Slant-box' }), + value: 'slant-box', + }, ]} value={node.style ?? 'scoop'} /> previewProp({ louverCount: Math.round(v) })} @@ -148,7 +161,7 @@ export default function EyebrowVentPanel() { /> {node.style === 'slant-box' ? ( previewProp({ backRatio: v })} @@ -161,9 +174,11 @@ export default function EyebrowVentPanel() { ) : null} - + previewProp({ width: v })} @@ -175,7 +190,7 @@ export default function EyebrowVentPanel() { value={node.width} /> previewProp({ depth: v })} @@ -187,7 +202,7 @@ export default function EyebrowVentPanel() { value={node.depth} /> previewProp({ height: v })} @@ -200,7 +215,7 @@ export default function EyebrowVentPanel() { /> - + previewProp({ rotation: (deg * Math.PI) / 180 })} @@ -266,18 +281,22 @@ export default function EyebrowVentPanel() { /> - + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.eyebrowVent.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.eyebrowVent.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.eyebrowVent.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/fence-feature/actions.tsx b/packages/nodes/src/fence-feature/actions.tsx index 2f4a1855dc..ddb012bdc3 100644 --- a/packages/nodes/src/fence-feature/actions.tsx +++ b/packages/nodes/src/fence-feature/actions.tsx @@ -1,5 +1,5 @@ 'use client' -import { type AnyNodeId, useScene } from '@pascal-app/core' +import { type AnyNodeId, usePascalI18n, useScene } from '@pascal-app/core' import { ActionMenuButton } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { DoorClosed, DoorOpen } from 'lucide-react' @@ -11,6 +11,7 @@ import { toggleFenceGate } from './interaction' * never write the node. */ export default function FenceGateActions() { + const i18n = usePascalI18n() const selected = useViewer((s) => s.selection.selectedIds) const gate = useScene((s) => { const node = selected.length === 1 ? s.nodes[selected[0] as AnyNodeId] : undefined @@ -21,7 +22,11 @@ export default function FenceGateActions() { return ( { event.stopPropagation() toggleFenceGate(gate.id as AnyNodeId) diff --git a/packages/nodes/src/fence-feature/inspector.tsx b/packages/nodes/src/fence-feature/inspector.tsx index a643a8997a..b06c11aad5 100644 --- a/packages/nodes/src/fence-feature/inspector.tsx +++ b/packages/nodes/src/fence-feature/inspector.tsx @@ -8,6 +8,7 @@ import { fenceFeatureData, fenceWithFeatures, getFenceCenterlineLength, + usePascalI18n, useScene, } from '@pascal-app/core' import { ActionButton, SliderControl, ToggleControl } from '@pascal-app/editor' @@ -22,6 +23,7 @@ export function FenceFeatureEditor({ node: FenceFeatureNode onUpdate: (patch: Partial) => void }) { + const i18n = usePascalI18n() const [error, setError] = useState('') const parentId = child.parentId as AnyNodeId | undefined const parent = useScene((s) => (parentId ? s.nodes[parentId] : undefined)) @@ -33,7 +35,15 @@ export function FenceFeatureEditor({ : [] }), ) - if (parent?.type !== 'fence') return

Fence unavailable.

+ if (parent?.type !== 'fence') + return ( +

+ {i18n._({ + id: 'node.fenceFeature.inspector.fenceUnavailable', + message: 'Fence unavailable.', + })} +

+ ) const node = fenceWithFeatures(parent, siblings) const features = [fenceFeatureData(child)] const length = getFenceCenterlineLength(node) @@ -45,7 +55,12 @@ export function FenceFeatureEditor({ (patch.width !== undefined || patch.center !== undefined) && !canPlaceFenceFeature(node, next) ) { - setError('Keep the opening inside the fence and clear of other gates or passages.') + setError( + i18n._({ + id: 'node.fenceFeature.inspector.keepTheOpeningInsideTheFence', + message: 'Keep the opening inside the fence and clear of other gates or passages.', + }), + ) return } setError('') @@ -66,7 +81,7 @@ export function FenceFeatureEditor({ ? [ { key: 'leafType', - label: 'Leaves', + label: i18n._({ id: 'node.fenceFeature.inspector.leaves', message: 'Leaves' }), value: feature.leafType ?? 'single', options: [ ['single', 'Single gate'], @@ -75,7 +90,10 @@ export function FenceFeatureEditor({ }, { key: 'hinge', - label: 'Hinge side', + label: i18n._({ + id: 'node.fenceFeature.inspector.hingeSide', + message: 'Hinge side', + }), value: feature.hinge ?? 'left', options: [ ['left', 'Left'], @@ -84,7 +102,10 @@ export function FenceFeatureEditor({ }, { key: 'swing', - label: 'Swing direction', + label: i18n._({ + id: 'node.fenceFeature.inspector.swingDirection', + message: 'Swing direction', + }), value: feature.swing ?? 'inward', options: [ ['inward', 'Inward'], @@ -93,7 +114,7 @@ export function FenceFeatureEditor({ }, { key: 'brace', - label: 'Bracing', + label: i18n._({ id: 'node.fenceFeature.inspector.bracing', message: 'Bracing' }), value: feature.brace ?? 'none', options: [ ['none', 'None'], @@ -107,7 +128,10 @@ export function FenceFeatureEditor({ const numbers = [ { key: 'center', - label: 'Distance from start', + label: i18n._({ + id: 'node.fenceFeature.inspector.distanceFromStart', + message: 'Distance from start', + }), value: feature.center, min: 0, max: length, @@ -116,7 +140,10 @@ export function FenceFeatureEditor({ }, { key: 'width', - label: 'Opening width', + label: i18n._({ + id: 'node.fenceFeature.inspector.openingWidth', + message: 'Opening width', + }), value: feature.width, min: 0.35, max: length, @@ -125,7 +152,9 @@ export function FenceFeatureEditor({ }, { key: 'height', - label: gate ? 'Gate height' : 'Post height', + label: gate + ? i18n._({ id: 'node.fenceFeature.inspector.gateHeight', message: 'Gate height' }) + : i18n._({ id: 'node.fenceFeature.inspector.postHeight', message: 'Post height' }), value: height, min: 0.3, max: 1000, @@ -136,7 +165,10 @@ export function FenceFeatureEditor({ ? [ { key: 'clearance', - label: 'Ground clearance', + label: i18n._({ + id: 'node.fenceFeature.inspector.groundClearance', + message: 'Ground clearance', + }), value: bottom, min: 0, max: 1000, @@ -145,7 +177,10 @@ export function FenceFeatureEditor({ }, { key: 'openAngle', - label: 'Opening angle', + label: i18n._({ + id: 'node.fenceFeature.inspector.openingAngle', + message: 'Opening angle', + }), value: feature.openAngle ?? 0, min: 0, max: 170, @@ -154,7 +189,10 @@ export function FenceFeatureEditor({ }, { key: 'thickness', - label: 'Leaf thickness', + label: i18n._({ + id: 'node.fenceFeature.inspector.leafThickness', + message: 'Leaf thickness', + }), value: feature.thickness ?? 0.06, min: 0.02, max: 0.3, @@ -163,7 +201,10 @@ export function FenceFeatureEditor({ }, { key: 'frameWidth', - label: 'Frame width', + label: i18n._({ + id: 'node.fenceFeature.inspector.frameWidth', + message: 'Frame width', + }), value: feature.frameWidth ?? 0.055, min: 0.025, max: 0.2, @@ -172,7 +213,10 @@ export function FenceFeatureEditor({ }, { key: 'spacing', - label: 'Infill spacing', + label: i18n._({ + id: 'node.fenceFeature.inspector.infillSpacing', + message: 'Infill spacing', + }), value: feature.spacing ?? 0.15, min: 0.04, max: 1000, @@ -181,7 +225,10 @@ export function FenceFeatureEditor({ }, { key: 'boardWidth', - label: 'Board width', + label: i18n._({ + id: 'node.fenceFeature.inspector.boardWidth', + message: 'Board width', + }), value: feature.boardWidth ?? 0.055, min: 0.02, max: 0.3, @@ -192,7 +239,10 @@ export function FenceFeatureEditor({ ? [ { key: 'leafSplit', - label: 'Left leaf proportion', + label: i18n._({ + id: 'node.fenceFeature.inspector.leftLeafProportion', + message: 'Left leaf proportion', + }), value: feature.leafSplit ?? 0.5, min: 0.2, max: 0.8, @@ -208,7 +258,10 @@ export function FenceFeatureEditor({
update(feature.id, { @@ -220,7 +273,10 @@ export function FenceFeatureEditor({ } /> update(feature.id, { @@ -232,7 +288,7 @@ export function FenceFeatureEditor({ /> {gate && feature.matchFenceStyle === false && ( )} @@ -304,20 +382,36 @@ export function FenceFeatureEditor({ /> ))} update(feature.id, { showPosts: checked })} /> {gate && ( update(feature.id, { showHardware: checked })} /> )} useScene.getState().deleteNode(child.id)} /> diff --git a/packages/nodes/src/fence/inspector-editors.tsx b/packages/nodes/src/fence/inspector-editors.tsx index ba3a239689..1e46281e51 100644 --- a/packages/nodes/src/fence/inspector-editors.tsx +++ b/packages/nodes/src/fence/inspector-editors.tsx @@ -5,6 +5,7 @@ import { clampFencePicketRailProjection, type FenceNode, findLevelAncestorId, + formatFixed, getClampedWallCurveOffset, getFenceCenterlineLength, getFenceControlHandle, @@ -18,21 +19,23 @@ import { nodeRegistry, normalizeWallCurveOffset, sampleFenceSpline, + usePascalI18n, useScene, } from '@pascal-app/core' import { SliderControl } from '@pascal-app/editor' export function FencePatternInfo({ node }: { node: FenceNode }) { - const subject = - node.style === 'picket' - ? 'pickets in each bay between posts' - : node.style === 'horizontal' - ? 'posts along the full fence' - : 'infill pieces between the two end posts' + const i18n = usePascalI18n() return (

- Distribution controls {subject}. Spacing is measured between centers. The available width - limits the count so pieces do not overlap. + {i18n._({ + id: 'node.fence.inspectorEditors.distributionInfo', + message: + '{style, select, picket {Distribution controls pickets in each bay between posts.} horizontal {Distribution controls posts along the full fence.} other {Distribution controls infill pieces between the two end posts.}} Spacing is measured between centers. The available width limits the count so pieces do not overlap.', + values: { + style: node.style === 'picket' || node.style === 'horizontal' ? node.style : 'other', + }, + })}

) } @@ -44,10 +47,14 @@ export function FencePicketRailProjectionEditor({ node: FenceNode onUpdate: (patch: Partial) => void }) { + const i18n = usePascalI18n() const max = maxFencePicketRailProjection(node.postSize) return ( @@ -68,6 +75,7 @@ export function FenceSurfaceEditor({ node: FenceNode onUpdate: (patch: Partial) => void }) { + const i18n = usePascalI18n() const nodes = useScene((state) => state.nodes) const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) const hosts = Object.values(nodes).filter( @@ -83,7 +91,7 @@ export function FenceSurfaceEditor({ return (
{node.surfaceMode === 'selected' && ( )} {(spanModes[index - 1] === 'curve' || spanModes[index] === 'curve') && ( )} @@ -366,7 +426,10 @@ export function FencePathEditor({ update(points, handles, modes) }} > - Add point after + {i18n._({ + id: 'node.fence.inspectorEditors.addPointAfter', + message: 'Add point after', + })} )} {index === path.length - 1 && ( @@ -385,7 +448,7 @@ export function FencePathEditor({ ) }} > - Extend path + {i18n._({ id: 'node.fence.inspectorEditors.extendPath', message: 'Extend path' })} )}
diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index a70ef1ab7f..9a61a29b38 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -5,6 +5,7 @@ import { type FenceNode, type GridEvent, getWallCurveLength, + usePascalI18n, useScene, type WallNode, } from '@pascal-app/core' @@ -95,6 +96,7 @@ function pickAngleLabel(args: { } export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => { + const i18n = usePascalI18n() const fenceId = target.fence.id const endpoint = target.endpoint const initialPoint: FencePlanPoint = @@ -278,7 +280,15 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = : 'border-border/70 bg-background/90 text-foreground/80' }`} > - {altPressed ? 'Detach endpoint' : 'Drag endpoint'} + {altPressed + ? i18n._({ + id: 'node.fence.moveEndpointTool.detachEndpoint', + message: 'Detach endpoint', + }) + : i18n._({ + id: 'node.fence.moveEndpointTool.dragEndpoint', + message: 'Drag endpoint', + })}
diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx index 1f02eaa549..a2237ba2eb 100644 --- a/packages/nodes/src/gutter/downspouts-panel.tsx +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -7,6 +7,7 @@ import { type GutterNode, generateId, type RoofSegmentNode, + usePascalI18n, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor' @@ -56,6 +57,7 @@ function nextOutletOffset(gutter: GutterNode): number { * on it, and each row's ✕ removes both the downspout and its outlet. */ export default function DownspoutsPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined const setSelection = useViewer((s) => s.setSelection) @@ -128,7 +130,9 @@ export default function DownspoutsPanel() { } return ( - +
{downspouts.map((d, i) => (
handleSelectDownspout(d.id as AnyNodeId)} type="button" > - {d.name || `Downspout ${i + 1}`} + {d.name || + i18n._({ + id: 'node.gutter.downspoutsPanel.downspoutNumber', + message: 'Downspout {number}', + values: { number: i + 1 }, + })}
))} - +
diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 6a87740320..55909cad9b 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -5,6 +5,7 @@ import { type GridEvent, HvacEquipmentNode, resolveSupportSlabPatch, + usePascalI18n, useScene, } from '@pascal-app/core' import { @@ -41,6 +42,7 @@ function snap(value: number, step: number): number { * inspector after placement. */ const HvacEquipmentTool = () => { + const i18n = usePascalI18n() const activeLevelId = useViewer((s) => s.selection.levelId) const toolDefaults = useEditor((s) => s.toolDefaults['hvac-equipment']) const [cursor, setCursor] = useState<[number, number, number] | null>(null) @@ -154,7 +156,9 @@ const HvacEquipmentTool = () => { zIndexRange={[100, 0]} >
- R/T rotate + + {i18n._({ id: 'node.hvacEquipment.tool.rTRotate', message: 'R/T rotate' })} +
diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index fd69e5704d..b0c0375e55 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -6,6 +6,7 @@ import { type GridEvent, type LinesetNode, LiquidLineNode, + usePascalI18n, useScene, } from '@pascal-app/core' import { @@ -264,6 +265,7 @@ function tracePath(target: FollowTarget): Vec3[] | null { } const LiquidLineTool = () => { + const i18n = usePascalI18n() const activeLevelId = useViewer((s) => s.selection.levelId) const unit = useViewer((s) => s.unit) const follow = useLiquidLineToolOptions((s) => s.follow) @@ -579,8 +581,14 @@ const LiquidLineTool = () => { }} > {followTargetRef.current - ? 'Click to trace this lineset run' - : 'Follow: hover a lineset'} + ? i18n._({ + id: 'node.liquidLine.tool.clickToTraceThisLinesetRun', + message: 'Click to trace this lineset run', + }) + : i18n._({ + id: 'node.liquidLine.tool.followHoverALineset', + message: 'Follow: hover a lineset', + })} diff --git a/packages/nodes/src/pipe-fitting/tool.tsx b/packages/nodes/src/pipe-fitting/tool.tsx index c09e184411..4816acb239 100644 --- a/packages/nodes/src/pipe-fitting/tool.tsx +++ b/packages/nodes/src/pipe-fitting/tool.tsx @@ -7,6 +7,7 @@ import { type GridEvent, PipeFittingNode, PipeSegmentNode, + usePascalI18n, } from '@pascal-app/core' import { CursorSphere, @@ -207,6 +208,7 @@ export function resolvePlacement( * node happens to be selected. */ const PipeFittingTool = () => { + const i18n = usePascalI18n() const { activeLevelId, sceneApi, selectNode } = useRegistryToolContext() const [placement, setPlacement] = useState(null) const toolDefaults = useEditor((s) => s.toolDefaults['pipe-fitting']) @@ -426,15 +428,21 @@ const PipeFittingTool = () => { {/* Same pill shell as DimensionPill so the placement HUD matches the drawing / dragging readouts. */}
- Axis {axis.toUpperCase()} + + {i18n._({ id: 'node.pipeFitting.tool.axis', message: 'Axis' })} {axis.toUpperCase()} + · - R/T rotate + + {i18n._({ id: 'node.pipeFitting.tool.rTRotate', message: 'R/T rotate' })} + · - ⌥ axis + + {i18n._({ id: 'node.pipeFitting.tool.axis2', message: '⌥ axis' })} +
{/* Port-snap halo so the user sees the click will mate, not free-place. */} diff --git a/packages/nodes/src/pipe-trap/tool.tsx b/packages/nodes/src/pipe-trap/tool.tsx index 8e2a3e2c84..dbd73f7750 100644 --- a/packages/nodes/src/pipe-trap/tool.tsx +++ b/packages/nodes/src/pipe-trap/tool.tsx @@ -1,6 +1,6 @@ 'use client' -import { emitter, type GridEvent, PipeTrapNode, useScene } from '@pascal-app/core' +import { emitter, type GridEvent, PipeTrapNode, usePascalI18n, useScene } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, @@ -32,6 +32,7 @@ function snap(value: number, step: number): number { * tool then draws the trap arm off the outlet toward the vent. */ const PipeTrapTool = () => { + const i18n = usePascalI18n() const activeLevelId = useViewer((s) => s.selection.levelId) const toolDefaults = useEditor((s) => s.toolDefaults['pipe-trap']) const [cursor, setCursor] = useState<[number, number, number] | null>(null) @@ -137,11 +138,19 @@ const PipeTrapTool = () => { zIndexRange={[100, 0]} >
- {previewNode.diameter}" Trap + + {i18n._({ + id: 'node.pipeTrap.tool.diameterTrap', + message: '{diameter}" Trap', + values: { diameter: previewNode.diameter }, + })} + · - R/T rotate + + {i18n._({ id: 'node.pipeTrap.tool.rTRotate', message: 'R/T rotate' })} +
diff --git a/packages/nodes/src/ridge-vent/panel.tsx b/packages/nodes/src/ridge-vent/panel.tsx index a644058b1a..0b5e720805 100644 --- a/packages/nodes/src/ridge-vent/panel.tsx +++ b/packages/nodes/src/ridge-vent/panel.tsx @@ -1,6 +1,12 @@ 'use client' -import { type AnyNode, type AnyNodeId, type RoofSegmentNode, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type RoofSegmentNode, + usePascalI18n, + useScene, +} from '@pascal-app/core' import { ActionButton, ActionGroup, @@ -23,6 +29,7 @@ import type { RidgeVentNode } from './schema' * Delete actions that route through the kind-owned ghost-drag flow. */ export default function RidgeVentPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -106,32 +113,46 @@ export default function RidgeVentPanel() { icon="/icons/roof.webp" onBack={node.roofSegmentId ? handleBack : undefined} onClose={handleClose} - title={node.name || 'Ridge Vent'} + title={node.name || i18n._({ id: 'node.ridgeVent.panel.ridgeVent', message: 'Ridge Vent' })} width={300} > - + handleUpdate({ style: v as RidgeVentNode['style'] })} options={[ - { label: 'Standard', value: 'standard' }, - { label: 'Shingled', value: 'shingled' }, - { label: 'Flanged', value: 'metal' }, + { + label: i18n._({ id: 'node.ridgeVent.panel.standard', message: 'Standard' }), + value: 'standard', + }, + { + label: i18n._({ id: 'node.ridgeVent.panel.shingled', message: 'Shingled' }), + value: 'shingled', + }, + { + label: i18n._({ id: 'node.ridgeVent.panel.flanged', message: 'Flanged' }), + value: 'metal', + }, ]} value={node.style ?? 'standard'} /> handleUpdate({ endCaps: v === 'yes' })} options={[ - { label: 'End Caps', value: 'yes' }, - { label: 'Open', value: 'no' }, + { + label: i18n._({ id: 'node.ridgeVent.panel.endCaps', message: 'End Caps' }), + value: 'yes', + }, + { label: i18n._({ id: 'node.ridgeVent.panel.open', message: 'Open' }), value: 'no' }, ]} value={(node.endCaps ?? true) ? 'yes' : 'no'} /> - + handleUpdate({ length: v })} @@ -143,7 +164,7 @@ export default function RidgeVentPanel() { value={node.length} /> handleUpdate({ width: v })} @@ -155,7 +176,7 @@ export default function RidgeVentPanel() { value={node.width} /> handleUpdate({ height: v })} @@ -168,7 +189,7 @@ export default function RidgeVentPanel() { /> - + handleUpdate({ rotation: (deg * Math.PI) / 180 })} @@ -241,18 +262,22 @@ export default function RidgeVentPanel() { /> - + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.ridgeVent.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.ridgeVent.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.ridgeVent.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/shared/run-hanger-inspector.tsx b/packages/nodes/src/shared/run-hanger-inspector.tsx index 657f7503f2..9d9078c698 100644 --- a/packages/nodes/src/shared/run-hanger-inspector.tsx +++ b/packages/nodes/src/shared/run-hanger-inspector.tsx @@ -1,11 +1,12 @@ 'use client' -import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' +import { type AnyNodeId, emitter, usePascalI18n, useScene } from '@pascal-app/core' import { useMemo } from 'react' import { planRunHangerSlots, type SupportedRun } from './run-hangers' import SystemCheckPanel from './system-check-panel' export default function RunHangerInspector({ node }: { node: SupportedRun }) { + const i18n = usePascalI18n() const nodes = useScene((state) => state.nodes) const slots = useMemo(() => planRunHangerSlots(node, nodes), [node, nodes]) if (!node.autoHangers) return @@ -28,10 +29,18 @@ export default function RunHangerInspector({ node }: { node: SupportedRun }) { <>
-

Individual hangers

+

+ {i18n._({ + id: 'node.shared.runHangerInspector.individualHangers', + message: 'Individual hangers', + })} +

- Positions are measured along each path segment. Changing spacing regenerates the numbered - slots. + {i18n._({ + id: 'node.shared.runHangerInspector.positionsAreMeasuredAlongEachPath', + message: + 'Positions are measured along each path segment. Changing spacing regenerates the numbered slots.', + })}

{slots.map((slot, index) => { @@ -43,7 +52,11 @@ export default function RunHangerInspector({ node }: { node: SupportedRun }) {
- Hanger {index + 1} · segment {slot.segmentIndex + 1} + {i18n._({ + id: 'node.shared.runHangerInspector.hangerOnSegment', + message: 'Hanger {number} · segment {segment}', + values: { number: index + 1, segment: slot.segmentIndex + 1 }, + })}
{!slot.skipped && !slot.hanger && (

- No support within reach. Move the hanger or choose another host. + {i18n._({ + id: 'node.shared.runHangerInspector.noSupportWithinReachMoveThe', + message: 'No support within reach. Move the hanger or choose another host.', + })}

)}