From eb7122f2e16828c37406df058aecb3873fa61f37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:17:20 +0000 Subject: [PATCH 1/5] editor: keep the editor's i18n scope inside drei Html roots Html renders its children in a separate React root, so components inside it (the node action menu, room pills, the selection summary) translated with the default English scope. Html from localized-html re-provides the caller's scope. --- .../editor/alignment-3d-guide-layer.tsx | 2 +- .../editor/elevation-3d-guide-layer.tsx | 2 +- .../editor/floating-action-menu.tsx | 2 +- .../editor/floating-building-action-menu.tsx | 2 +- .../editor/footprint-height-handle.tsx | 2 +- .../editor/group-floating-action-menu.tsx | 2 +- .../src/components/editor/localized-html.tsx | 22 +++++++++++++++++++ .../components/editor/node-arrow-handles.tsx | 3 +-- .../editor/open-wall-ends-3d-layer.tsx | 2 +- .../editor/opening-guides-3d-layer.tsx | 2 +- .../src/components/editor/room-controls.tsx | 2 +- .../src/components/editor/room-handles.tsx | 2 +- .../src/components/editor/room-labels.tsx | 2 +- .../components/editor/site-edge-labels.tsx | 2 +- .../editor/wall-measurement-label.tsx | 2 +- .../editor/wall-move-side-handles.tsx | 2 +- .../tools/item/placement-history.test.tsx | 2 +- .../tools/item/use-placement-coordinator.tsx | 2 +- .../components/tools/shared/cursor-sphere.tsx | 2 +- .../tools/shared/draft-measurement-label.tsx | 2 +- .../components/tools/shared/placement-box.tsx | 2 +- .../shared/placement-dimension-guides.tsx | 2 +- .../tools/site/terrain-brush-cursor.tsx | 2 +- 23 files changed, 44 insertions(+), 23 deletions(-) create mode 100644 packages/editor/src/components/editor/localized-html.tsx diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx index 65f77da07c..1349720c4f 100644 --- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -2,13 +2,13 @@ import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { memo, useMemo, useRef } from 'react' import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import useAlignmentGuides from '../../store/use-alignment-guides' +import { Html } from './localized-html' import { formatMeasurement } from './measurement-pill' /** diff --git a/packages/editor/src/components/editor/elevation-3d-guide-layer.tsx b/packages/editor/src/components/editor/elevation-3d-guide-layer.tsx index 49ac860586..eb579934d9 100644 --- a/packages/editor/src/components/editor/elevation-3d-guide-layer.tsx +++ b/packages/editor/src/components/editor/elevation-3d-guide-layer.tsx @@ -2,13 +2,13 @@ import { sceneRegistry } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { memo, useMemo, useRef } from 'react' import { BoxGeometry, CircleGeometry, type Group } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import useElevationGuides from '../../store/use-elevation-guides' +import { Html } from './localized-html' import { formatMeasurement } from './measurement-pill' const LINE_COLOR = 0x81_8c_f8 diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index d305412157..be2652b9c6 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -29,7 +29,6 @@ import { type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' @@ -54,6 +53,7 @@ import useInteractionScope, { } from '../../store/use-interaction-scope' import { IconRefGlyph } from '../ui/icon-ref' import { duplicateNodeAndPickUp } from './duplicate-node' +import { Html } from './localized-html' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' import { startZoneRoomTransform } from './room-controls' diff --git a/packages/editor/src/components/editor/floating-building-action-menu.tsx b/packages/editor/src/components/editor/floating-building-action-menu.tsx index f5e9cc8e9f..d7d2a866d7 100644 --- a/packages/editor/src/components/editor/floating-building-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-building-action-menu.tsx @@ -2,12 +2,12 @@ import { type BuildingNode, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useRef } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { Html } from './localized-html' import { NodeActionMenu } from './node-action-menu' export function FloatingBuildingActionMenu() { diff --git a/packages/editor/src/components/editor/footprint-height-handle.tsx b/packages/editor/src/components/editor/footprint-height-handle.tsx index 25019e894f..ecc52f95d4 100644 --- a/packages/editor/src/components/editor/footprint-height-handle.tsx +++ b/packages/editor/src/components/editor/footprint-height-handle.tsx @@ -10,7 +10,6 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useRef, useState } from 'react' import { type Group, Plane, Vector2, Vector3 } from 'three' @@ -26,6 +25,7 @@ import { runRoomHandleDrag } from '../../lib/room-handle-drag' import useEditor, { isGridSnapActive } from '../../store/use-editor' import useInteractionScope from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' +import { Html } from './localized-html' import { ElevationArrows, outlineCentroid, useHandleScale } from './room-handles' export const FOOTPRINT_HEIGHT_DRAG_LABEL = 'footprint-height' diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx index 6f978664e4..c3422aab6f 100644 --- a/packages/editor/src/components/editor/group-floating-action-menu.tsx +++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx @@ -2,7 +2,6 @@ import { type AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' @@ -21,6 +20,7 @@ import { computeGroupPlanBox, levelFrame, } from './group-transform-shared' +import { Html } from './localized-html' import { NodeActionMenu } from './node-action-menu' import { useMeshSettleEpoch } from './use-mesh-settle-epoch' diff --git a/packages/editor/src/components/editor/localized-html.tsx b/packages/editor/src/components/editor/localized-html.tsx new file mode 100644 index 0000000000..6f62a68c8f --- /dev/null +++ b/packages/editor/src/components/editor/localized-html.tsx @@ -0,0 +1,22 @@ +'use client' + +import { PascalI18nProvider, usePascalI18n } from '@pascal-app/core' +import { Html as DreiHtml } from '@react-three/drei' +import { type ComponentProps, forwardRef } from 'react' + +/** + * drei's `Html`, keeping the editor's i18n scope. `Html` renders its children + * in a separate React root, which sees none of the surrounding context, so a + * component inside it would translate with the default (English) scope instead + * of the editor's `?lang=` or `` one. + */ +export const Html = forwardRef, 'ref'>>( + function Html({ children, ...props }, ref) { + const i18n = usePascalI18n() + return ( + + {children} + + ) + }, +) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index f4db42e61e..16bba820bb 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -21,7 +21,6 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { @@ -41,7 +40,6 @@ import { Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' - import { MeshBasicNodeMaterial } from 'three/webgpu' import { useCeilingEditCeilingId } from '../../lib/ceiling-edit-session' import { EDITOR_LAYER } from '../../lib/constants' @@ -78,6 +76,7 @@ import { import { createLinearResizeDragBinding, linearResizeFactor } from './handles/linear-resize-drag' import { resolveResizeSnapValue } from './handles/resize-snap' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' +import { Html } from './localized-html' // Pooled scratch for the handle rig's world-relative pose mapping. const _rigRelative = new Matrix4() diff --git a/packages/editor/src/components/editor/open-wall-ends-3d-layer.tsx b/packages/editor/src/components/editor/open-wall-ends-3d-layer.tsx index 50bacab790..9991ae0e2f 100644 --- a/packages/editor/src/components/editor/open-wall-ends-3d-layer.tsx +++ b/packages/editor/src/components/editor/open-wall-ends-3d-layer.tsx @@ -2,7 +2,6 @@ import { type OpenWallEnd, sceneRegistry } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { memo, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, CircleGeometry, type Group, RingGeometry } from 'three' @@ -19,6 +18,7 @@ import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' import { isViewVisible, VIEW_3D, visibleScene } from '../../store/view-layout' import { JoinWallsPill } from './join-walls-pill' +import { Html } from './localized-html' import { OpenWallEndsHint } from './open-wall-ends-hint' /** diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx index 0c87748719..1c0a502652 100644 --- a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -1,7 +1,6 @@ 'use client' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { memo, useEffect, useLayoutEffect, useMemo } from 'react' import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' @@ -10,6 +9,7 @@ import useOpeningGuides, { type OpeningGuide3D, type OpeningGuideVec3, } from '../../store/use-opening-guides' +import { Html } from './localized-html' import { formatMeasurement } from './measurement-pill' const DIMENSION_COLOR = 0x81_8c_f8 // indigo — a neutral measurement diff --git a/packages/editor/src/components/editor/room-controls.tsx b/packages/editor/src/components/editor/room-controls.tsx index 5ae79d391c..854097295b 100644 --- a/packages/editor/src/components/editor/room-controls.tsx +++ b/packages/editor/src/components/editor/room-controls.tsx @@ -12,7 +12,6 @@ import { useScene, } from '@pascal-app/core' import { getSceneTheme, setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { SquareSplitHorizontal } from 'lucide-react' import Image from 'next/image' @@ -69,6 +68,7 @@ import { } from '../tools/shared/draft-measurement-label' import { ActionMenuButton } from './action-menu-button' import { getMenuYOffset } from './floating-action-menu' +import { Html } from './localized-html' import { NodeActionMenu } from './node-action-menu' import { RoomFloorHighlight3D } from './room-floor-highlight' import { RoomHandleDragPreview3D, RoomHandles3D } from './room-handles' diff --git a/packages/editor/src/components/editor/room-handles.tsx b/packages/editor/src/components/editor/room-handles.tsx index 4cf6660c01..86e7fe5de1 100644 --- a/packages/editor/src/components/editor/room-handles.tsx +++ b/packages/editor/src/components/editor/room-handles.tsx @@ -8,7 +8,6 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { @@ -54,6 +53,7 @@ import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/u import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { ARROW_COLOR, ARROW_SCALE, HandleArrow } from './handles/handle-arrow' import { resolveResizeSnapValue } from './handles/resize-snap' +import { Html } from './localized-html' import { formatMeasurement } from './measurement-pill' import { WallPushArrow } from './wall-push-arrow' diff --git a/packages/editor/src/components/editor/room-labels.tsx b/packages/editor/src/components/editor/room-labels.tsx index 2d102c9a0e..3849ef2ef0 100644 --- a/packages/editor/src/components/editor/room-labels.tsx +++ b/packages/editor/src/components/editor/room-labels.tsx @@ -19,7 +19,6 @@ import { type ZoneNode, } from '@pascal-app/core' import { timeSpan, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { createContext, @@ -58,6 +57,7 @@ import { selectionModifiersFromEvent } from '../../lib/selection-routing' import useEditor from '../../store/use-editor' import useInteractionScope from '../../store/use-interaction-scope' import { isViewVisible, VIEW_2D, VIEW_3D } from '../../store/view-layout' +import { Html } from './localized-html' const CONTEXT_TYPES = new Set(['site', 'building', 'level']) diff --git a/packages/editor/src/components/editor/site-edge-labels.tsx b/packages/editor/src/components/editor/site-edge-labels.tsx index ca7b829e51..7620bdf333 100644 --- a/packages/editor/src/components/editor/site-edge-labels.tsx +++ b/packages/editor/src/components/editor/site-edge-labels.tsx @@ -3,7 +3,6 @@ import type { SiteNode } from '@pascal-app/core' import { sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { createPortal, useFrame, useThree } from '@react-three/fiber' import { useCallback, useMemo, useRef, useState } from 'react' import { type Camera, type Object3D, Vector3 } from 'three' @@ -11,6 +10,7 @@ import { groundHeightAt } from '../../lib/ground-surface' import { formatLinearMeasurement } from '../../lib/measurements' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { useActiveHandleDrag } from '../../store/use-interaction-scope' +import { Html } from './localized-html' type ViewportSize = { width: number diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index fb84eb4e0e..40d155263c 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -20,11 +20,11 @@ import { type WallNode, } from '@pascal-app/core' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { createPortal, useFrame } from '@react-three/fiber' import { useMemo, useState } from 'react' import * as THREE from 'three' import { formatLinearMeasurement } from '../../lib/measurements' +import { Html } from './localized-html' const GUIDE_Y_OFFSET = 0.08 const LABEL_LIFT = 0.08 diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index f659389017..34c7f8d9a0 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -19,7 +19,6 @@ import { type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { @@ -61,6 +60,7 @@ import useInteractionScope, { import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { resolveResizeSnapValue } from './handles/resize-snap' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' +import { Html } from './localized-html' import { MeasurementPill } from './measurement-pill' import { createArrowHitAreaGeometry, diff --git a/packages/editor/src/components/tools/item/placement-history.test.tsx b/packages/editor/src/components/tools/item/placement-history.test.tsx index 667b6818cf..fcdaa3ec3c 100644 --- a/packages/editor/src/components/tools/item/placement-history.test.tsx +++ b/packages/editor/src/components/tools/item/placement-history.test.tsx @@ -22,12 +22,12 @@ import { WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { act, create } from '@react-three/test-renderer' import { Children, Component, cloneElement, isValidElement, type ReactNode } from 'react' import { Group } from 'three' import useEditor from '../../../store/use-editor' import useInteractionScope from '../../../store/use-interaction-scope' +import { Html } from '../../editor/localized-html' import { useDraftNode } from './use-draft-node' import { usePlacementCoordinator } from './use-placement-coordinator' diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 63fb397e79..b258e3f224 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -30,7 +30,6 @@ import { type WallEvent, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -74,6 +73,7 @@ import useEditor, { import useFacingPose from '../../../store/use-facing-pose' import { getMovingNode } from '../../../store/use-interaction-scope' import usePlacementPreview from '../../../store/use-placement-preview' +import { Html } from '../../editor/localized-html' import { createItemSurfaceGridDispatch, createItemSurfacePointerArbitration, diff --git a/packages/editor/src/components/tools/shared/cursor-sphere.tsx b/packages/editor/src/components/tools/shared/cursor-sphere.tsx index 72293191e4..49e5951abd 100644 --- a/packages/editor/src/components/tools/shared/cursor-sphere.tsx +++ b/packages/editor/src/components/tools/shared/cursor-sphere.tsx @@ -1,5 +1,4 @@ import { type MessageDescriptor, usePascalI18n } from '@pascal-app/core' -import { Html } from '@react-three/drei' import type { ThreeElements } from '@react-three/fiber' import { forwardRef } from 'react' import { CircleGeometry, CylinderGeometry, type Group, SphereGeometry } from 'three' @@ -9,6 +8,7 @@ import { EDITOR_LAYER } from '../../../lib/constants' import { wallCursorIcon } from '../../../lib/tool-cursor-icon' import useEditor from '../../../store/use-editor' import useWallSnapIndicator from '../../../store/use-wall-snap-indicator' +import { Html } from '../../editor/localized-html' interface CursorSphereProps extends Omit { color?: string diff --git a/packages/editor/src/components/tools/shared/draft-measurement-label.tsx b/packages/editor/src/components/tools/shared/draft-measurement-label.tsx index 62a7b2e1ca..dad1e5dbe0 100644 --- a/packages/editor/src/components/tools/shared/draft-measurement-label.tsx +++ b/packages/editor/src/components/tools/shared/draft-measurement-label.tsx @@ -1,6 +1,6 @@ 'use client' -import { Html } from '@react-three/drei' +import { Html } from '../../editor/localized-html' /** Lift of a draft's readout above the top of what it measures, in metres. */ export const DRAFT_LABEL_Y_OFFSET = 0.22 diff --git a/packages/editor/src/components/tools/shared/placement-box.tsx b/packages/editor/src/components/tools/shared/placement-box.tsx index 70a1a6451b..74fecb8caf 100644 --- a/packages/editor/src/components/tools/shared/placement-box.tsx +++ b/packages/editor/src/components/tools/shared/placement-box.tsx @@ -2,7 +2,6 @@ import '../../../three-types' -import { Html } from '@react-three/drei' import { useEffect, useMemo } from 'react' import { PlaneGeometry } from 'three' import { distance, smoothstep, uv, vec2 } from 'three/tsl' @@ -13,6 +12,7 @@ import { type LinearUnit, type MetricNotation, } from '../../../lib/measurements' +import { Html } from '../../editor/localized-html' import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry' const VALID_COLOR = 0x22_c5_5e // green-500 diff --git a/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx b/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx index 36074ec744..9a42aa382f 100644 --- a/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx +++ b/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx @@ -1,13 +1,13 @@ 'use client' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useLayoutEffect, useMemo } from 'react' import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import type { PlacementPreviewDimension } from '../../../store/use-placement-preview' import usePlacementPreview from '../../../store/use-placement-preview' +import { Html } from '../../editor/localized-html' import { formatMeasurement } from '../../editor/measurement-pill' const DIMENSION_COLOR = 0x63_66_f1 diff --git a/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx b/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx index 619833c9cc..592c7ba525 100644 --- a/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx +++ b/packages/editor/src/components/tools/site/terrain-brush-cursor.tsx @@ -10,7 +10,6 @@ import { useLiveTerrain, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { type MutableRefObject, useEffect, useMemo, useRef } from 'react' import { @@ -25,6 +24,7 @@ import { EDITOR_LAYER } from '../../../lib/constants' import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt' import { brushRingColor } from '../../../lib/terrain-verb-color' import useEditor from '../../../store/use-editor' +import { Html } from '../../editor/localized-html' import { formatMeasurement } from '../../editor/measurement-pill' /** Segments in the cursor ring. 64 reads as a circle at any usable brush size. */ From 2dc9922051757ebd73fd09c813cb5a8281f189ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:17:20 +0000 Subject: [PATCH 2/5] i18n: migrate the node action menu, riser diagram, procedural inspector and run HUD --- i18n/pseudo-surfaces.ts | 14 ++++ .../components/editor/action-menu-button.tsx | 5 +- .../components/editor/node-action-menu.tsx | 58 +++++++++++--- .../editor/registry-action-contributions.tsx | 8 +- .../components/editor/riser-diagram-panel.tsx | 68 ++++++++++++++--- .../components/procedural-items/inspector.tsx | 71 ++++++++++++++--- .../distribution/distribution-run-tool.tsx | 76 ++++++++++++------- 7 files changed, 239 insertions(+), 61 deletions(-) diff --git a/i18n/pseudo-surfaces.ts b/i18n/pseudo-surfaces.ts index 4d36484968..7f083e5abc 100644 --- a/i18n/pseudo-surfaces.ts +++ b/i18n/pseudo-surfaces.ts @@ -317,6 +317,20 @@ export const surfaces: PseudoSurface[] = [ scope: '[data-editor-inspector]', data: furnishedData, }, + { + name: 'node-action-menu', + path: furnishedPath, + steps: async (page) => { + await openLayer(page, 'Door') + await page + .locator('[data-node-action-menu] button') + .last() + .hover() + .catch(() => undefined) + await page.waitForTimeout(600) + }, + scope: '[data-node-action-menu], [role="tooltip"]', + }, { name: 'icon-rail', path: apartmentPath, diff --git a/packages/editor/src/components/editor/action-menu-button.tsx b/packages/editor/src/components/editor/action-menu-button.tsx index 36b722e055..feeb83498d 100644 --- a/packages/editor/src/components/editor/action-menu-button.tsx +++ b/packages/editor/src/components/editor/action-menu-button.tsx @@ -1,6 +1,8 @@ 'use client' +import { usePascalI18n } from '@pascal-app/core' import type { MouseEventHandler, ReactNode } from 'react' +import { toolHintKey } from '../../i18n/node-messages' import { cn } from '../../lib/utils' import { shortcutDisplayValue } from '../ui/primitives/shortcut-token' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' @@ -37,6 +39,7 @@ export function ActionMenuButton({ pressed?: boolean children: ReactNode }) { + const i18n = usePascalI18n() return ( @@ -72,7 +75,7 @@ export function ActionMenuButton({ className="rounded border border-background/30 px-1 font-mono text-[10px] leading-4 opacity-80" key={key} > - {shortcutDisplayValue(key)} + {toolHintKey(shortcutDisplayValue(key), i18n)} ))} diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index ee5fa7d434..ffcb7d66e7 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,6 +1,7 @@ 'use client' import { Icon } from '@iconify/react' +import { usePascalI18n } from '@pascal-app/core' import { Copy, Group, @@ -65,70 +66,103 @@ export function NodeActionMenu({ onPointerEnter, onPointerLeave, children, - deleteLabel = 'Delete', + deleteLabel, deleteDisabledReason, }: NodeActionMenuProps) { + const i18n = usePascalI18n() return (
{onFind && ( - + )} {onMove && ( - + )} {onEditMesh && ( - + )} {onGroup && ( - + )} {onUngroup && ( )} {onCurve && ( - + )} {onRotateLeft && ( - + )} {onRotateRight && ( - + )} {children} {onDuplicate && ( - + )} {onAddHole && ( - + )} @@ -138,7 +172,7 @@ export function NodeActionMenu({ disabled={!!deleteDisabledReason} disabledReason={deleteDisabledReason} keys={['Delete / Backspace']} - label={deleteLabel} + label={deleteLabel ?? i18n._({ id: 'editor.nodeActionMenu.delete', message: 'Delete' })} onClick={onDelete} > diff --git a/packages/editor/src/components/editor/registry-action-contributions.tsx b/packages/editor/src/components/editor/registry-action-contributions.tsx index c4df35e11c..6009261ba2 100644 --- a/packages/editor/src/components/editor/registry-action-contributions.tsx +++ b/packages/editor/src/components/editor/registry-action-contributions.tsx @@ -8,6 +8,7 @@ import { nodeRegistry, toggleMechanism, useInteractive, + usePascalI18n, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -70,7 +71,12 @@ export function MechanismButton({ mechanism: MechanismCapability running: boolean }) { - const label = mechanism.icon ? (running ? 'Close' : 'Open') : running ? 'Stop' : 'Play' + const i18n = usePascalI18n() + const label = i18n._({ + id: 'editor.mechanismAction', + message: '{action, select, close {Close} open {Open} stop {Stop} other {Play}}', + values: { action: mechanism.icon ? (running ? 'close' : 'open') : running ? 'stop' : 'play' }, + }) return ( s.setRiserOpen) const nodes = useScene((s) => s.nodes) const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -47,15 +57,30 @@ function RiserDiagramContent() { const ty = diagram ? -diagram.bounds.minY * SCALE + PADDING : 0 return ( -
+
- Riser Diagram - DWV plumbing isometric + + {i18n._({ id: 'editor.riserDiagramPanel.riserDiagram', message: 'Riser Diagram' })} + + + {i18n._({ + id: 'editor.riserDiagramPanel.dwvPlumbingIsometric', + message: 'DWV plumbing isometric', + })} +
@@ -63,11 +88,12 @@ function RiserDiagramContent() {
- Waste + {' '} + {i18n._({ id: 'editor.riserDiagramPanel.waste', message: 'Waste' })} {' '} - Vent + {i18n._({ id: 'editor.riserDiagramPanel.vent', message: 'Vent' })}
@@ -76,7 +102,10 @@ function RiserDiagramContent() { @@ -127,7 +156,7 @@ function RiserDiagramContent() { )} - {marker.label} + {markerLabel(marker, nodes, i18n)} ))} @@ -135,10 +164,31 @@ function RiserDiagramContent() { ) : (
- No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram. + {i18n._({ + id: 'editor.riserDiagramPanel.noDrainWasteOrVentPipes', + message: + 'No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram.', + })}
)}
) } + +/** Core's marker labels are English ("2\" P-trap"); the panel words them in the UI language. */ +function markerLabel(marker: RiserMarker, nodes: Record, i18n: PascalI18n) { + const node = nodes[marker.nodeId] + if (marker.kind === 'fitting' && node?.type === 'pipe-fitting') + return i18n._(inspectorOptionDescriptor(node.fittingType)) + if (!(node && 'diameter' in node && typeof node.diameter === 'number')) return marker.label + const values = { diameter: String(node.diameter) } + return marker.kind === 'trap' + ? i18n._({ id: 'editor.riserDiagramPanel.trap', message: '{diameter}" P-trap', values }) + : i18n._({ + id: 'editor.riserDiagramPanel.ventTermination', + message: '{diameter}" VTR', + comment: 'Vent through roof: where a plumbing vent stack exits the roof.', + values, + }) +} diff --git a/packages/editor/src/components/procedural-items/inspector.tsx b/packages/editor/src/components/procedural-items/inspector.tsx index 708d273510..d2535878b2 100644 --- a/packages/editor/src/components/procedural-items/inspector.tsx +++ b/packages/editor/src/components/procedural-items/inspector.tsx @@ -1,5 +1,5 @@ 'use client' -import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { type AnyNodeId, useLiveNodeOverrides, usePascalI18n, useScene } from '@pascal-app/core' import { ProceduralItemNode, snapParameters, @@ -22,6 +22,7 @@ export function ProceduralInspector({ partId: string | null onPartChange: (id: string | null) => void }) { + const i18n = usePascalI18n() const committed = useScene((s) => s.nodes[nodeId as AnyNodeId]) as unknown as | ProceduralItemNode | undefined @@ -42,14 +43,24 @@ export function ProceduralInspector({ const parameters = commit ? snapParameters(node.recipe, values) : values const parsed = ProceduralItemNode.safeParse({ ...node, parameters }) if (!parsed.success) { - setError(parsed.error.issues[0]?.message ?? 'Invalid edit') + setError( + parsed.error.issues[0]?.message ?? + i18n._({ id: 'editor.proceduralItems.inspector.invalidEdit', message: 'Invalid edit' }), + ) if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId) return } try { validateProceduralRelations({ ...node, parameters }, useScene.getState().nodes) } catch (e) { - setError(e instanceof Error ? e.message : 'Invalid attachment') + setError( + e instanceof Error + ? e.message + : i18n._({ + id: 'editor.proceduralItems.inspector.invalidAttachment', + message: 'Invalid attachment', + }), + ) if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId) return } @@ -64,7 +75,12 @@ export function ProceduralInspector({

- {partId ? node.recipe.parts.find((p) => p.id === partId)?.label : 'Adjust design'} + {partId + ? node.recipe.parts.find((p) => p.id === partId)?.label + : i18n._({ + id: 'editor.proceduralItems.inspector.adjustDesign', + message: 'Adjust design', + })}

{partId && ( )}
@@ -128,11 +144,21 @@ export function ProceduralInspector({ useScene.getState().updateNode(nodeId as AnyNodeId, { parameters } as never) setError('') } catch (e) { - setError(e instanceof Error ? e.message : 'Invalid reset') + setError( + e instanceof Error + ? e.message + : i18n._({ + id: 'editor.proceduralItems.inspector.invalidReset', + message: 'Invalid reset', + }), + ) } }} > - Reset this part + {i18n._({ + id: 'editor.proceduralItems.inspector.resetThisPart', + message: 'Reset this part', + })} )} {error && ( @@ -142,7 +168,9 @@ export function ProceduralInspector({ )} {!partId && node.recipe.parameters.some((p) => p.part) && (
-

Edit parts

+

+ {i18n._({ id: 'editor.proceduralItems.inspector.editParts', message: 'Edit parts' })} +

{node.recipe.parts .filter((part) => node.recipe.parameters.some((p) => p.part === part.id)) @@ -188,13 +216,21 @@ function ProceduralPanel({ node }: { node: ProceduralItemNode }) { } function ProceduralPlacementControls({ node }: { node: ProceduralItemNode }) { + const i18n = usePascalI18n() const [error, setError] = useState('') const update = (patch: Partial) => { try { useScene.getState().updateNode(node.id as AnyNodeId, patch as never) setError('') } catch (e) { - setError(e instanceof Error ? e.message : 'Invalid placement') + setError( + e instanceof Error + ? e.message + : i18n._({ + id: 'editor.proceduralItems.inspector.invalidPlacement', + message: 'Invalid placement', + }), + ) } } const changePosition = (index: number, value: number, commit: boolean) => { @@ -213,18 +249,29 @@ function ProceduralPlacementControls({ node }: { node: ProceduralItemNode }) { setError('') } catch (e) { if (commit) useLiveNodeOverrides.getState().clear(node.id as AnyNodeId) - setError(e instanceof Error ? e.message : 'Invalid position') + setError( + e instanceof Error + ? e.message + : i18n._({ + id: 'editor.proceduralItems.inspector.invalidPosition', + message: 'Invalid position', + }), + ) } } return ( - + {(['X', 'Y', 'Z'] as const).map((axis, index) => ( {axis} - pos + + {i18n._({ id: 'editor.proceduralItems.inspector.pos', message: 'pos' })} + } value={node.position[index]!} diff --git a/packages/editor/src/components/tools/distribution/distribution-run-tool.tsx b/packages/editor/src/components/tools/distribution/distribution-run-tool.tsx index 3a772abd50..85a72ccd2d 100644 --- a/packages/editor/src/components/tools/distribution/distribution-run-tool.tsx +++ b/packages/editor/src/components/tools/distribution/distribution-run-tool.tsx @@ -1,6 +1,13 @@ 'use client' -import { type AnyNodeId, emitter, type GridEvent, sceneRegistry, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + type GridEvent, + sceneRegistry, + usePascalI18n, + useScene, +} from '@pascal-app/core' import { CursorSphere, clearPlacementSurface, @@ -17,11 +24,12 @@ import { useEditor, useInteractionScope, } from '@pascal-app/editor' -import { Html } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { type Group, Vector3 } from 'three' +import { messageWithSlot } from '../../../i18n/message-slot' +import { Html } from '../../editor/localized-html' import type { RunSurfaceBounds, RunSurfaceTarget } from './distribution-run-contract' import { clearDrawAlignment } from './draw-alignment' import type { RunBodyHit, ScenePort } from './ports' @@ -1197,6 +1205,7 @@ export function DistributionRunCursor({ onLengthInputChange?: (value: string) => void onDirectionSelect?: (direction: RunPoint) => void }) { + const i18n = usePascalI18n() const lengthFieldRef = useRef(null) useEffect(() => { if (start) lengthFieldRef.current?.focus() @@ -1284,30 +1293,45 @@ export function DistributionRunCursor({ {start ? ( ) : null} {validationMessage ? ( From 8ac627eea78e64420c1af58f1a99d21912195b90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:44:36 +0000 Subject: [PATCH 3/5] i18n: write the remaining #1174 counts ungrouped, as main does Door and window counts, stair risers, surveyed lines, 'shared with # room(s)' and 'The floor's # roofs' render 1234, not 1,234. --- .../src/components/ui/panels/room-construction-rows.tsx | 8 ++++++-- packages/editor/src/components/ui/panels/room-panel.tsx | 4 ++-- packages/nodes/src/roof/panel.tsx | 8 ++++++-- packages/nodes/src/site/panel.tsx | 3 ++- packages/nodes/src/stair/panel.tsx | 3 ++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/ui/panels/room-construction-rows.tsx b/packages/editor/src/components/ui/panels/room-construction-rows.tsx index 197662605a..0063e6314b 100644 --- a/packages/editor/src/components/ui/panels/room-construction-rows.tsx +++ b/packages/editor/src/components/ui/panels/room-construction-rows.tsx @@ -281,8 +281,12 @@ export function DrawnFloorRow({ zoneId }: { zoneId: string }) { /* i18n */ { id: 'editor.panels.roomConstructionRows.drawnFloor', message: - '{ambiguous, select, yes {Floor is several drawn slabs, mostly {name}} other {Floor is a drawn slab: {name}}}{shared, plural, =0 {} one { (shared with # room)} other { (shared with # rooms)}}', - values: { ambiguous: floor.ambiguous ? 'yes' : 'no', shared: floor.shared }, + '{ambiguous, select, yes {Floor is several drawn slabs, mostly {name}} other {Floor is a drawn slab: {name}}}{shared, plural, =0 {} one { (shared with {sharedCount} room)} other { (shared with {sharedCount} rooms)}}', + values: { + ambiguous: floor.ambiguous ? 'yes' : 'no', + shared: floor.shared, + sharedCount: String(floor.shared), + }, }, 'name', {floor.name}, diff --git a/packages/editor/src/components/ui/panels/room-panel.tsx b/packages/editor/src/components/ui/panels/room-panel.tsx index 029f7c7f9c..d4b9a335a8 100644 --- a/packages/editor/src/components/ui/panels/room-panel.tsx +++ b/packages/editor/src/components/ui/panels/room-panel.tsx @@ -365,8 +365,8 @@ function RoomSubline({ room }: { room: RoomSelectionRecord }) { const text = i18n._({ id: 'editor.panels.roomPanel.openingCounts', message: - '{doors, plural, one {# door} other {# doors}} · {windows, plural, one {# window} other {# windows}}', - values: { doors, windows }, + '{doors, plural, one {{doorCount} door} other {{doorCount} doors}} · {windows, plural, one {{windowCount} window} other {{windowCount} windows}}', + values: { doors, windows, doorCount: String(doors), windowCount: String(windows) }, }) return (

diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index d59d97d901..d60ae54981 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -309,8 +309,12 @@ export default function RoofPanel() { {i18n._({ id: 'node.roof.panel.onStoreyWalls', message: - "On {storey} with its walls, so it hides that floor's plan in solo and exploded views.{roofs, plural, =0 {} =1 {} other { The floor's # roofs move together.}}", - values: { storey: levelMove.storey, roofs: levelMove.roofs }, + "On {storey} with its walls, so it hides that floor's plan in solo and exploded views.{roofs, plural, =0 {} =1 {} other { The floor's {roofCount} roofs move together.}}", + values: { + storey: levelMove.storey, + roofs: levelMove.roofs, + roofCount: String(levelMove.roofs), + }, })}