diff --git a/i18n/literal-baseline.json b/i18n/literal-baseline.json index c2a913c2eb..f8da614a90 100644 --- a/i18n/literal-baseline.json +++ b/i18n/literal-baseline.json @@ -418,10 +418,6 @@ "navigate", "select" ], - "packages/editor/src/components/ui/controls/finish-swatch.tsx": [ - "Default", - "Default" - ], "packages/editor/src/components/ui/controls/material-paint-panel.tsx": [ "Add material", "Add material", @@ -494,251 +490,6 @@ "Intensity", "Intensity" ], - "packages/editor/src/components/ui/panels/collections/collections-popover.tsx": [ - "Cancel", - "Collection name…", - "Collections", - "Delete", - "Delete", - "Delete \"{name}\"?", - "New", - "No collections yet. Create one to group elements together.", - "Rename" - ], - "packages/editor/src/components/ui/panels/floor-foundation-panel.tsx": [ - "A thicker floor grows upward: the rooms below keep their height.", - "Edge finish", - "Floor plate", - "Floor plate {thicknessLabel} thick, resting on the walls below", - "Floor thickness", - "Floor top", - "Floor top +", - "Foundation finish", - "Foundation height", - "Not to scale", - "On the ground", - "Raised on a foundation", - "Slab", - "Slab thickness", - "Slab {thicknessLabel} thick on a {foundationLabel} foundation, floor top {?} above the ground", - "Slab {thicknessLabel} thick on the ground, floor top {?} above the ground", - "The floor's outside edge, all around the footprint.", - "The foundation stands on the ground; the slab sits on it.", - "The slab sits on the foundation (or the ground): a thicker slab raises the floor top and everything on it.", - "Under the floor" - ], - "packages/editor/src/components/ui/panels/floor-opening-panel.tsx": [ - " · Drag its corners to change the shape.", - " · Shape follows its owner.", - "A hatch in the mezzanine: it cuts only the mezzanine’s floor.", - "Delete", - "Done", - "Elevator", - "On {?}, as one void with this opening.", - "Owned by", - "Staircase", - "There is no floor above this storey.", - "There is no storey below." - ], - "packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx": [ - "Close" - ], - "packages/editor/src/components/ui/panels/mobile-selection-bar.tsx": [ - "Delete", - "Duplicate", - "Edit properties", - "Edit {resolvedLabel}", - "Move" - ], - "packages/editor/src/components/ui/panels/multi-height-mode.tsx": [ - "Currently {currentLabel}", - "Custom height", - "Follows level", - "Height", - "Top" - ], - "packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx": [ - "{label} (session only). Plain click reselects all members. Not saved with the project." - ], - "packages/editor/src/components/ui/panels/multi-selection-panel.tsx": [ - "Delete", - "Duplicate", - "Group", - "Group (Ctrl/Cmd+G)", - "Merge windows", - "Merge windows into a corner window: both take the height, sill and type of the one you selected first", - "Merge windows into one across both: it keeps the type and style of the one you selected first", - "Select building", - "Select the whole building to move all its levels", - "Ungroup", - "Ungroup (Ctrl/Cmd+Shift+G)", - "Unwrap the corner", - "Unwrap the corner: both windows stay, each framed at the corner", - "{label} (session only). Plain click reselects all members. Not saved with the project.", - "{length} selected" - ], - "packages/editor/src/components/ui/panels/panel-manager.tsx": [ - "Selection" - ], - "packages/editor/src/components/ui/panels/panel-wrapper.tsx": [ - "Close {title}", - "Collapse panel", - "Collections", - "Expand panel", - "Manage collections…", - "Open {title}", - "“{title}” hit an error and was unloaded for this session." - ], - "packages/editor/src/components/ui/panels/parametric-field-control.tsx": [ - "Mixed" - ], - "packages/editor/src/components/ui/panels/parametric-inspector.tsx": [ - "Actions", - "Delete", - "Delete", - "Delete with contents", - "Move" - ], - "packages/editor/src/components/ui/panels/reference-panel.tsx": [ - "Cancel", - "Capture", - "Capture", - "Center", - "Clear Scale", - "Click both ends of a known distance on the plan, then type its real length.", - "Delete", - "Draw a line over a known dimension on the plan, then type its real length to scale the image exactly.", - "Edit Scale", - "Guide Image", - "Hide", - "Hide", - "Hide Scale", - "Image", - "Lock", - "Move", - "Opacity", - "Overlay image unavailable. Replace the image to restore it.", - "Position", - "Quick Actions", - "Reference Scale", - "Replace", - "Replacing...", - "Reset Image Scale", - "Reset Rotation", - "Rotation", - "Scale & Opacity", - "Set Scale", - "Show", - "Show", - "Show Scale", - "Unlock", - "XYZ", - "pos", - "pos", - "pos", - "rot", - "scale" - ], - "packages/editor/src/components/ui/panels/room-construction-rows.tsx": [ - " (shared with {?})", - "+ Add mezzanine", - "+ New floor", - "A drawn slab: set its thickness and finishes here. It has no footprint height or foundation.", - "Built on", - "Built on", - "Built with", - "Ceiling", - "Close with walls", - "Edit shape", - "Floor", - "Floor is a drawn slab: ", - "Floor is several drawn slabs, mostly ", - "Floor thickness", - "Hand-drawn ceiling", - "Height", - "Height", - "Include floor", - "Include walls", - "Include {?}", - "Keep outside dimensions", - "Keep outside dimensions", - "No floor", - "Not included", - "Replace", - "Select", - "Shape", - "Sits on:", - "This floor is a drawn slab: select it to edit or delete it.", - "Turning walls off keeps the ones shared with", - "Use existing", - "Walls", - "Walls", - "Walls thicken inward, so the building's outer size stays the same." - ], - "packages/editor/src/components/ui/panels/room-openings-field.tsx": [ - "Cut opening", - "Done", - "Edit floor", - "Elevator opening", - "No openings", - "No openings yet. Cut one for a stair void or a hatch.", - "Opening {?}", - "Stair opening" - ], - "packages/editor/src/components/ui/panels/room-panel.tsx": [ - "Add stairs", - "Add to a unit", - "Area", - "Built with", - "Ceiling", - "Delete room", - "Edit shape", - "Floor", - "Height", - "Hide code", - "Loading…", - "Mezzanine", - "Mezzanine", - "Mezzanine name", - "Mezzanine names", - "New unit", - "None", - "Perimeter", - "Picking it adds a number", - "Remove", - "Remove from", - "Rename", - "Room", - "Room", - "Room", - "Room colour — used in the plan and labels", - "Room name", - "Room names", - "Scripted finish", - "Shape", - "Show mezzanine names", - "Show room names", - "Stairs", - "Unit", - "Unit", - "Unit", - "Unit", - "Use", - "View code", - "unit", - "{?} · Rename", - "{used} on this level", - "· Close its walls to build its floor and ceiling." - ], - "packages/editor/src/components/ui/panels/shape-choice.tsx": [ - "Polygon", - "Rectangle" - ], - "packages/editor/src/components/ui/panels/wall-region-list.tsx": [ - "Delete region", - "Delete region", - "Paint regions" - ], "packages/editor/src/components/ui/primitives/dialog.tsx": [ "Close" ], @@ -1268,36 +1019,6 @@ "Use {?} as array source", "Waterfall ends" ], - "packages/nodes/src/ceiling/panel.tsx": [ - "(Editing)", - "Add Hole", - "Area", - "Auto", - "Ceiling", - "Currently", - "Custom height", - "Cut opening", - "Done", - "Follows level", - "Height", - "Height", - "High (3.0m)", - "High (9'0\")", - "Hole", - "Holes", - "Info", - "Limited by the level to", - "Low (2.4m)", - "Low (8'0\")", - "Manual", - "Move", - "No holes", - "Standard (2.5m)", - "Standard (8'6\")", - "Taller than this level ({?} available). Raise the level height first.", - "pts ·", - "— raise the level height for a taller ceiling." - ], "packages/nodes/src/chimney/panel.tsx": [ "Above Ridge", "Actions", @@ -1514,98 +1235,6 @@ "Style", "Width" ], - "packages/nodes/src/door/panel.tsx": [ - "+ Add Segment", - "- Remove", - "Actions", - "All", - "All", - "Arch", - "Arch", - "Arch Height", - "Arch Height", - "Bar Height", - "Columns", - "Content Padding", - "Corner Radius", - "Corner Radius", - "Custom: the segments match no style.", - "Delete", - "Depth", - "Depth", - "Dimensions", - "Direction", - "Divider", - "Door", - "Door", - "Door Closer", - "Duplicate", - "Empty", - "Enable Handle", - "Enable Threshold", - "Flip Side", - "Fold", - "Frame", - "Garage", - "Garage", - "Glass", - "Handle", - "Handle Side", - "Hardware", - "Height", - "Height", - "Height", - "Height", - "Hinges Side", - "Horizontal", - "Individual", - "Individual", - "Inset", - "Inward", - "Left", - "Left", - "Left", - "Move", - "Open", - "Open", - "Open", - "Opening", - "Opening Shape", - "Outward", - "Panel", - "Panel", - "Panels", - "Panels", - "Panic Bar", - "Pocket", - "Position", - "Rail", - "Rect", - "Rect", - "Reveal Radius", - "Reveal Radius", - "Right", - "Right", - "Right", - "Roll-up", - "Rounded", - "Rounded", - "Sectional", - "Segment", - "Segments", - "Size is limited to the wall, including clearance for the opening frame.", - "Slide", - "Style", - "Swing", - "Thickness", - "Threshold", - "Tilt-up", - "Top Shape", - "Type", - "Vertical", - "Width", - "wall" - ], "packages/nodes/src/dormer/panel-actions-section.tsx": [ "Actions", "Delete", @@ -1812,37 +1441,6 @@ "packages/nodes/src/hvac-equipment/tool.tsx": [ "R/T rotate" ], - "packages/nodes/src/item/authored-params.tsx": [ - "Couldn't rebuild with these values.", - "Parameters", - "Rebuilding in Blender…", - "Rebuilding…", - "Reset parameters", - "Reset to default", - "Reset {?}" - ], - "packages/nodes/src/item/panel.tsx": [ - "Actions", - "Delete", - "Dimensions", - "Duplicate", - "Info", - "Move", - "Position", - "Rotation", - "Scale", - "Uniform Scale", - "XYZ", - "pos", - "pos", - "pos", - "rot", - "rot", - "scale", - "scale", - "scale", - "scale" - ], "packages/nodes/src/liquid-line/tool.tsx": [ "Click to trace this lineset run", "Follow: hover a lineset" @@ -1883,191 +1481,6 @@ "Style", "Width" ], - "packages/nodes/src/roof-segment/panel.tsx": [ - "Actions", - "Angle", - "Arc", - "Auto gutters", - "Auto ridge vent", - "Barrel", - "Clipped version", - "Conical", - "Conical Shape", - "Deck Thick.", - "Delete", - "Depth", - "Diameter", - "Done editing", - "Drainage", - "Duplicate", - "Dutch", - "Eave overhang", - "Edit footprint", - "End overhang", - "Fascia", - "Fascia height", - "Flat", - "Footprint", - "Gable", - "Gable overhang", - "Gambrel", - "Hip", - "Kink Depth", - "Kink Height", - "Length", - "Mansard", - "Move", - "Pitch", - "Position", - "Reset", - "Roof Segment", - "Roof Type", - "Rotation", - "Shape", - "Shape", - "Shape", - "Shed", - "Shingle Thick.", - "Span", - "Start Angle", - "Structure", - "Top Rake Length", - "Top Rake Thick.", - "Trim", - "Waist Height", - "Waist Height", - "Waist Length", - "Waist Width", - "Waist Width", - "Wall", - "Wall Height", - "Wall Thick.", - "Width" - ], - "packages/nodes/src/roof/barrel-panel.tsx": [ - "Arch bays", - "Barrel arch", - "Flat panes follow a circular arch. Rise is measured from the eaves.", - "Rise" - ], - "packages/nodes/src/roof/glazing-controls.tsx": [ - "Alignment", - "Center", - "Choose a layout to edit the grid.", - "Empty", - "End", - "Fixed spacing", - "Glass", - "Layout", - "Max spacing", - "Maximum spacing", - "Mixed", - "Panel count", - "Panels", - "Solid", - "Spacing", - "Start", - "{title} alignment" - ], - "packages/nodes/src/roof/glazing-panel-editor.tsx": [ - "Choose a panel to override its default infill.", - "Column", - "Custom", - "Default", - "Individual panels", - "Panel infill", - "Reset to default", - "Roof face", - "Row", - "Selected panel infill", - "column", - "columns", - "row", - "rows" - ], - "packages/nodes/src/roof/glazing-panel.tsx": [ - "Changes apply to all {length} roof segments.", - "Choose one roof system to edit settings for all selected segments.", - "Close the roof ends", - "Default panel infill", - "Default panel infill", - "Divides each roof face into columns.", - "Divides the length of each arch bay into columns.", - "End infill fills the space above the walls. Edit room walls and ceilings separately.", - "Frame color", - "Frame depth", - "Frame width", - "Framing", - "Glass", - "Glass tint", - "Glazed", - "Glazing is available for gable, straight shed, and barrel roofs.", - "Grid across the roof", - "Grid along the length", - "Grid along the slope", - "Grid within each arch bay", - "Infill", - "Opacity", - "Pane thickness", - "Roof system", - "Roughness", - "Spacing is measured on the slope, not its horizontal projection.", - "Standard", - "Subdivides each flat arch bay. Arch bays controls the divisions around the curve." - ], - "packages/nodes/src/roof/panel.tsx": [ - " The floor's {roofs} roofs move together.", - "Actions", - "Add Chimney", - "Add Cupola", - "Add Dormer", - "Add Eyebrow Vent", - "Add Gutter", - "Add Skylight", - "Add Solar Panel", - "Add Vent", - "Box", - "Box Vent {?}", - "Chimney {?}", - "Currently", - "Custom", - "Delete", - "Dormer {?}", - "Draw Segment", - "Duplicate", - "Elements", - "Follows walls", - "Gutter {?}", - "Level", - "Move", - "Move to the roof level", - "Move to {?}", - "On", - "Position", - "Ridge", - "Ridge Vent {?}", - "Roof", - "Rotation", - "Segment {?}", - "Segments", - "Skylight {?}", - "Solar Panel {?}", - "Turbine", - "Turbine Vent {?}", - "box vent", - "chimney", - "dormer", - "gutter", - "ridge vent", - "skylight", - "solar panel", - "turbine vent", - "with its walls, so it hides that floor's plan in solo and exploded views." - ], - "packages/nodes/src/shared/item-interaction-actions.tsx": [ - "Turn light off", - "Turn light on" - ], "packages/nodes/src/shared/run-hanger-inspector.tsx": [ "Automatic support", "Hanger", @@ -2082,73 +1495,6 @@ "Skip", "· segment" ], - "packages/nodes/src/shared/system-check-panel.tsx": [ - "About these checks", - "All findings", - "All systems", - "Connections", - "Drainage", - "Error", - "Errors", - "Filter system checks", - "Hangers", - "Intersections", - "Locate {?} ({id})", - "Missing item", - "No findings", - "No findings in this category", - "Open ends", - "Open ends and separate branches may be intentional. Intersection checks use bounding boxes; inspect openings and clearances.", - "Selected connected system", - "Selected:", - "Separate branches", - "Show all findings", - "System checks", - "System findings", - "Warning", - "Warnings", - "run", - "runs" - ], - "packages/nodes/src/site/panel.tsx": [ - "1200 W Cass St, Tampa, FL", - "APN", - "Address", - "Auto — most north-facing edge", - "Contour interval (site plan and 3D)", - "County", - "Find parcel", - "Finding…", - "Front (street) edge", - "Lot area", - "No surveyed lines — contours are drawn from the sculpted ground.", - "North rotation", - "Orientation", - "Parcel", - "Project look", - "Save current look", - "Save the current lighting and display as this project's default. Personal display changes stay in this browser.", - "Setbacks", - "Show ground", - "Show the contour lines in 3D", - "Site", - "Source", - "Source / citation", - "Street address", - "Terrain", - "Use project look", - "Zone", - "e.g. RS-60", - "e.g. Tampa LDC §27-156", - "every 12 in", - "every 24 in", - "every 6 in", - "ft (", - "none", - "side", - "survey", - "surveyed lines at" - ], "packages/nodes/src/skylight/panel.tsx": [ "Actions", "Along X", @@ -2184,42 +1530,6 @@ "Width", "Yes" ], - "packages/nodes/src/slab/panel.tsx": [ - "(Editing)", - "Add Hole", - "Area", - "Auto", - "Base", - "Cut hatch", - "Cut opening", - "Depth", - "Done", - "Elevation", - "Extends the perimeter down to terrain. The flat surface, base, and thickness stay unchanged.", - "Fixed", - "Floor", - "Follows terrain", - "Foundation", - "Hole", - "Holes", - "Info", - "Manual", - "Move", - "No holes", - "Rim", - "Slab", - "Standard (2\")", - "Standard (5cm)", - "Sunken (15cm)", - "Sunken (6\")", - "Surface", - "Thick (15cm)", - "Thick (6\")", - "Thickness", - "Thin (1\")", - "Thin (2cm)", - "pts ·" - ], "packages/nodes/src/solar-panel/panel.tsx": [ "Actions", "Array", @@ -2253,168 +1563,6 @@ "Spawn Point", "Yaw" ], - "packages/nodes/src/stair-segment/panel.tsx": [ - "Actions", - "Attachment", - "Delete", - "Dimensions", - "Duplicate", - "Editing switches the stair to Custom rise", - "Equal angle", - "Equal going", - "Fill to floor", - "Flight", - "Flight shape", - "Front", - "Height", - "Inner gap", - "Landing", - "Left", - "Left turn", - "Length", - "Move", - "Position", - "Right", - "Right turn", - "Rotation", - "Stair Segment", - "Steps", - "Straight", - "Structure", - "Thickness", - "Type", - "Walking line offset", - "Width", - "Winder" - ], - "packages/nodes/src/stair/construction-controls.tsx": [ - "Body", - "Center stringer", - "Closed risers", - "Inherit", - "Nosing", - "Open", - "Original", - "Riser thickness", - "Side stringers", - "Solid", - "Stringer depth", - "Stringer width", - "Tread finish", - "Tread thickness", - "Waist", - "Waist thickness" - ], - "packages/nodes/src/stair/panel.tsx": [ - "Actions", - "Add flight", - "Add landing", - "Apply {label} layout", - "Auto Cutout", - "Balusters", - "Boards", - "Both", - "Cable", - "Center Column", - "Clockwise", - "Connected to {?}. Move the stair to connect to another edge.", - "Construction", - "Counterclockwise", - "Currently", - "Curved", - "Custom rise", - "Deck", - "Delete", - "Design targets", - "Destination", - "Drag near a deck, patio, concrete slab, or landing edge to fit the stair.", - "Duplicate", - "Equal angle", - "Equal going", - "Fit To Floor", - "Fit risers and going", - "Fit uniform risers", - "Flight", - "Floors, ceilings and stairs only.", - "Follows deck", - "Follows level", - "From Level", - "Geometry", - "Glass", - "Headroom:", - "Height", - "Inner Radius", - "Inner gap", - "Integrated", - "Landing", - "Landing", - "Layout", - "Left", - "Left turn", - "Measurements", - "Metal", - "Move", - "Moving this stair keeps its current rise, steps, and run.", - "No overhead surface", - "None", - "None", - "None", - "Not evaluated", - "Opening", - "Opening Offset", - "Position", - "Post & rail", - "Railing", - "Right", - "Right turn", - "Rise", - "Rise", - "Rotation", - "Segment {?}", - "Segments", - "Sizing", - "Snap to nearby surfaces", - "Spiral", - "Staircase", - "Step Supports", - "Steps", - "Straight", - "Style", - "Surface connection", - "Sweep", - "Target going", - "Target headroom", - "Target max riser", - "Target minimum going", - "Thickness", - "To", - "Top Landing", - "Type", - "Unavailable", - "Walking line offset", - "Width", - "Winder", - "risers · R" - ], - "packages/nodes/src/stair/railing-controls.tsx": [ - "Both", - "Continuous", - "Floor", - "Handrail diameter", - "Handrail height", - "Handrail inset", - "Left", - "No return", - "None", - "Original layout", - "Post-facing", - "Return length", - "Right", - "Separate handrail", - "Set the length to reach the wall or post.", - "Wall-facing", - "{?} extension" - ], "packages/nodes/src/turbine-vent/panel.tsx": [ "Actions", "Cylinder", @@ -2436,278 +1584,10 @@ "Turbine Vent", "Vanes" ], - "packages/nodes/src/unit/unit-panel.tsx": [ - "Actions", - "Color", - "Delete", - "Focus", - "Gross area", - "Kind", - "Levels", - "Members", - "Members", - "Name", - "No rooms yet. Pick this unit from a room's panel, or draw zones while it is focused.", - "Remove {?} from unit", - "Report", - "Unit", - "Unit" - ], - "packages/nodes/src/wall/actions.tsx": [ - "Merge walls", - "Merge walls", - "Split wall", - "Split wall" - ], - "packages/nodes/src/wall/curtain-wall-panel.tsx": [ - ", limited by frame depth.", - ", row", - "Apply tint", - "Border / opening frame", - "Bottom", - "Bottom row", - "Cancel tint", - "Center", - "Choose a cell or enter its column and row. Use arrow keys to move between cells. Columns run from the wall start; rows count upward.", - "Clear hidden overrides", - "Column", - "Column", - "Column layout", - "Column {?}, row {?}: {type}", - "Construction", - "Continuous mullions and transoms assembled along the wall.", - "Curtain wall panel selection", - "Custom infill", - "Default panel", - "Edit individual panels", - "Effective thickness:", - "Empty", - "Enable panel editing", - "End", - "Fixed spacing", - "Following wall defaults", - "Frame dimensions", - "Framing", - "Fully capped", - "Glass", - "Glass appearance", - "Glass appearance is controlled by the paint tool.", - "Glass tint", - "Glass tint", - "Glazing gap", - "Grid limited to 32 panels per axis. Spacing expands to fit this wall.", - "Horizontal caps", - "Horizontal frame width", - "Layout", - "Low roughness is smooth; high roughness gives a matte surface in rendered shading.", - "Maximum spacing", - "Module joint width", - "None", - "Opacity", - "Opaque band", - "Opaque bands replace a full row with solid panels. Apply their finish with the paint tool.", - "Panel count", - "Panel infill", - "Panel infill", - "Panel thickness", - "Panels", - "Reset this panel", - "Row", - "Row layout", - "Separate framed modules with joints between units.", - "Solid", - "Spacing", - "Start", - "Stick-built", - "Structural glazing", - "Surface roughness", - "Top", - "Top row", - "Uncapped members sit behind the glazing.", - "Unitized", - "Use glass settings instead", - "Use the Door or Window tool to add an opening through the wall.", - "Vertical caps", - "Vertical frame width", - "Wall system", - "{length} custom panels are outside this grid. They return if the grid expands." - ], "packages/nodes/src/wall/move-endpoint-tool.tsx": [ "Alt to detach", "Detaching corner" ], - "packages/nodes/src/wall/panel.tsx": [ - "Actions", - "Assembly", - "Auto", - "Both", - "Bottom", - "Brick veneer", - "CMU", - "Chair rail", - "Colonial", - "Cove", - "Craft", - "Crown molding", - "Currently", - "Curtain wall", - "Curve", - "Curve", - "Custom", - "Custom height", - "Depth", - "Dimensions", - "Drywall", - "Extends downward to meet the terrain. Height and top stay unchanged.", - "Exterior", - "Fiber cement", - "Fill to terrain", - "Flat", - "Flat", - "Flat", - "Follows level", - "Frame depth", - "Framing", - "Gypsum", - "Height", - "Height", - "Hide code", - "Hide {?}", - "ICF", - "Interior", - "Lap siding", - "Layer thicknesses follow the 2021 IRC assembly data. Drafting aid, not engineering — verify with the authority having jurisdiction.", - "Layered", - "Length", - "Loading…", - "Modern", - "None", - "None", - "None", - "None (single layer)", - "OSB", - "Offset", - "Ogee", - "Ogee", - "Ogee", - "Partition: the interior finish is applied to both faces.", - "Picture", - "Plaster", - "Plywood", - "Preset", - "Reference", - "Remove", - "Round", - "Scripted finish", - "Sheathing", - "Shoe", - "Show {?}", - "Side", - "Side A", - "Side B", - "Skirting", - "Standard", - "Steel studs", - "Step", - "Stone veneer", - "Stucco", - "Thickness", - "Thickness", - "Top", - "Total thickness", - "Total thickness", - "Unverified: {presetNote}", - "View code", - "Wall", - "Wall type", - "Which face is outside is undetermined (no room detected on either side) — the exterior layers are drawn on side B.", - "Wood studs", - "· from assembly" - ], - "packages/nodes/src/window/panel.tsx": [ - "Actions", - "All", - "All", - "Arch", - "Arch", - "Arch Height", - "Arch Height", - "Col Widths", - "Columns", - "Corner", - "Corner Radius", - "Corner Radius", - "Corner post", - "Custom: the panes match no style.", - "Delete", - "Depth", - "Depth", - "Dimensions", - "Divider", - "Divider", - "Down", - "Duplicate", - "Enable Sill", - "Flip Side", - "Frame", - "French", - "Fused glass", - "Grid", - "Height", - "Individual", - "Individual", - "Left", - "Move", - "Opening", - "Opening Shape", - "Other side", - "Position", - "Rect", - "Rect", - "Reveal Radius", - "Reveal Radius", - "Right", - "Rounded", - "Rounded", - "Row Heights", - "Rows", - "Sill", - "Single", - "Size is limited to the wall, including clearance for the opening frame.", - "Style", - "Thickness", - "Thickness", - "This side", - "Top Shape", - "Type", - "Unwrap", - "Up", - "Width", - "Window", - "Window", - "Window Type", - "Wrap the corner", - "Wraps the corner with", - "its partner", - "pos", - "pos" - ], - "packages/nodes/src/zone/quantities-panel.tsx": [ - "Enclosed room", - "Floor surface", - "Footprint only", - "None", - "Not proven", - "Room quantities", - "Top view with zone edge dimensions", - "Unit", - "Unit", - "Unit", - "Volume", - "Wall surface", - "Zone boundary unavailable", - "Zone quantities" - ], "packages/ui/src/button.tsx": [ "Hello from your {appName} app!" ], diff --git a/i18n/pseudo-surfaces.ts b/i18n/pseudo-surfaces.ts index eaa3af3030..4d36484968 100644 --- a/i18n/pseudo-surfaces.ts +++ b/i18n/pseudo-surfaces.ts @@ -2,6 +2,12 @@ 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 { pseudoLocalize } from '../scripts/i18n' +import { + RoofNode, + RoofSegmentNode, + StairNode, + StairSegmentNode, +} from '../packages/core/src/schema' /** * The surface manifest for `bun run i18n:pseudo-audit`. Each migration PR adds @@ -44,6 +50,74 @@ export const auditProject = (base: string) => export const editorPath = async (base: string) => `/project/${await auditProject(base)}` +/** The two-bedroom template with a gable roof and a straight stair, so every inspector has a row. */ +function furnishedScene() { + const graph = structuredClone(twoBedroom) as unknown as { + nodes: Record + } + const nodes = [ + RoofSegmentNode.parse({ id: 'rseg_audit', parentId: 'roof_audit', roofType: 'gable', width: 4, depth: 6 }), + RoofNode.parse({ id: 'roof_audit', parentId: 'level_0', children: ['rseg_audit'] }), + StairSegmentNode.parse({ id: 'sseg_audit', parentId: 'stair_audit', segmentType: 'stair', width: 1, length: 3 }), + StairNode.parse({ id: 'stair_audit', parentId: 'level_0', children: ['sseg_audit'] }), + ] + for (const node of nodes) graph.nodes[node.id] = node + graph.nodes.level_0?.children?.push('roof_audit', 'stair_audit') + return graph as unknown as SceneGraph +} + +export const furnishedPath = async (base: string) => + `/project/${await auditScene(base, 'furnished', furnishedScene())}` +const furnishedData = ['Project #1', 'Living / Kitchen', 'Bedroom 1', 'Bedroom 2', 'Bath'] + +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** The rail and layer names stay English until the left-panel migration lands. */ +const englishOrPseudo = (english: string) => + new RegExp(`^(${escapeRegExp(english)}|${escapeRegExp(pseudo(english))})$`) + +/** Opens the Scene panel; the tab state persists across audit pages, and clicking the active tab collapses it. */ +async function openScenePanel(page: Page): Promise { + await openEditor(page) + const sceneTab = page.getByRole('button', { name: englishOrPseudo('Scene') }).first() + if ((await sceneTab.getAttribute('aria-pressed')) !== 'true') await sceneTab.click() + await page.waitForTimeout(500) +} + +/** Opens the Scene panel, expands every row and selects the first row named `english`. */ +async function openLayer( + page: Page, + english: string | RegExp, + options: { exact?: boolean } = {}, +) { + await openScenePanel(page) + // Expand every row: the tree's chevrons are the small square buttons beside each name. + for (let pass = 0; pass < 3; pass++) { + for (const toggle of await page.$$('button.z-10.h-4.w-4:has(svg.lucide-chevron-right)')) { + const open = await toggle.evaluate((b) => { + const transform = b.querySelector('div')?.style.transform ?? 'none' + return transform !== 'none' && !/rotate\(0/.test(transform) + }) + if (!open) await toggle.click().catch(() => undefined) + } + await page.waitForTimeout(700) + } + const name = + typeof english !== 'string' || options.exact === false ? english : englishOrPseudo(english) + await page.getByText(name, { exact: true }).first().click() + await page.waitForTimeout(1200) + await expandInspector(page) +} + +/** The inspector opens collapsed to its header on small viewports. */ +async function expandInspector(page: Page): Promise { + const expand = page.getByRole('button', { name: pseudo('Expand panel'), exact: true }) + if (await expand.count()) { + await expand.first().click() + await page.waitForTimeout(800) + } +} + /** A furnished project, so trees and level lists have rows to show. */ export const apartmentPath = async (base: string) => `/project/${await auditScene(base, 'two-bedroom', twoBedroom)}` @@ -171,6 +245,78 @@ export const surfaces: PseudoSurface[] = [ // A kind registered by the external @pascal-app/plugin-streetscape, which owns its copy. data: ['Road'], }, + { + name: 'room-panel', + path: furnishedPath, + steps: (page) => openLayer(page, 'Living / Kitchen', { exact: false }), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'floor-foundation-panel', + path: furnishedPath, + steps: async (page) => { + await openLayer(page, 'Living / Kitchen', { exact: false }) + // The template's rooms have no floor yet: build one, then open what it sits on. + await page.locator('[data-room-construction] [role="switch"]').first().click() + await page.locator('[data-sits-on]').first().waitFor() + await page.locator('[data-sits-on]').first().click() + await page.waitForTimeout(1200) + await expandInspector(page) + }, + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'wall-panel', + path: furnishedPath, + steps: (page) => openLayer(page, 'Wall'), + scope: '[data-editor-inspector]', + // An inspector extension registered by the external @pascal-app/plugin-landscape. + data: [...furnishedData, 'Retaining wall'], + }, + { + name: 'door-panel', + path: furnishedPath, + steps: (page) => openLayer(page, 'Door'), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'window-panel', + path: furnishedPath, + steps: (page) => openLayer(page, 'Window'), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'roof-panel', + path: furnishedPath, + steps: (page) => openLayer(page, /^(Roof \(1 segment\)|\[Ŕööƒ \(1 šéĝɱéñţ\) ~+\])$/), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'roof-segment-panel', + path: furnishedPath, + steps: (page) => openLayer(page, /\(4\.0[x×]6\.0/), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'stair-panel', + path: furnishedPath, + steps: (page) => openLayer(page, /^(Staircase \(1 segment\)|\[Šţáîŕçášé \(1 šéĝɱéñţ\) ~+\])$/), + scope: '[data-editor-inspector]', + data: furnishedData, + }, + { + name: 'stair-segment-panel', + path: furnishedPath, + steps: (page) => openLayer(page, /\(1\.0[x×]3\.0/), + scope: '[data-editor-inspector]', + data: furnishedData, + }, { name: 'icon-rail', path: apartmentPath, @@ -186,7 +332,7 @@ export const surfaces: PseudoSurface[] = [ { name: 'layers-panel', path: apartmentPath, - steps: (page) => openInEditor(page, 'Scene'), + steps: openScenePanel, scope: '[data-surface="site-panel"]', data: apartmentData, }, diff --git a/packages/core/src/i18n/format.test.ts b/packages/core/src/i18n/format.test.ts new file mode 100644 index 0000000000..acd7f85fee --- /dev/null +++ b/packages/core/src/i18n/format.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'bun:test' +import { formatFixed, formatList } from './format' + +test('English lists keep the plain comma list; other locales use their own separators', () => { + expect(formatList(['Kitchen', 'Hall'], 'en')).toBe('Kitchen, Hall') + expect(formatList(['Kitchen', 'Hall', 'Bath'])).toBe('Kitchen, Hall, Bath') + expect(formatList(['Kitchen', 'Hall', 'Bath'], 'en-GB')).toBe('Kitchen, Hall, Bath') + expect(formatList(['Kitchen', 'Hall', 'Bath'], 'fr')).toBe('Kitchen, Hall et Bath') + expect(formatList(['Kitchen', 'Hall', 'Bath'], 'ja')).toBe('Kitchen、Hall、Bath') +}) + +test('formatFixed rounds like toFixed, then writes the locale notation without -0', () => { + for (const [value, digits] of [ + [2.55, 1], + [1.005, 2], + [0.125, 2], + [12.345, 1], + [1234.5, 1], + ] as const) { + expect(formatFixed(value, digits, 'en')).toBe(value.toFixed(digits)) + } + expect(formatFixed(2.55, 1, 'fr')).toBe('2,5') + expect(formatFixed(1234.5, 1, 'fr')).toBe('1234,5') + expect(formatFixed(-0.001, 2, 'en')).toBe('0.00') + expect(formatFixed(-0, 1, 'fr')).toBe('0,0') +}) diff --git a/packages/core/src/i18n/format.ts b/packages/core/src/i18n/format.ts index 85c974d391..c6864ca4c7 100644 --- a/packages/core/src/i18n/format.ts +++ b/packages/core/src/i18n/format.ts @@ -92,6 +92,32 @@ export function formatNumber(value: number, options: NumberFormatOptions = {}): } } +/** + * `value.toFixed(digits)` in the locale's notation: the same rounding as + * `toFixed` (so "2.55" stays "2.5"), then the locale's decimal separator, no + * grouping, and "-0.00" as "0.00". + */ +export function formatFixed(value: number, digits: number, locale = 'en'): string { + if (!Number.isFinite(value)) return '--' + const rounded = Number(value.toFixed(digits)) + return formatNumber(Object.is(rounded, -0) ? 0 : rounded, { min: digits, max: digits, locale }) +} + +/** + * Names listed in a sentence. English keeps the plain comma list the editor has + * always written ("Kitchen, Hall, Bath"); other locales use their own list + * separators and conjunction (Japanese "Kitchen、Hall、Bath", French + * "Kitchen, Hall et Bath"), which a comma join gets wrong. + */ +export function formatList(items: readonly string[], locale = 'en'): string { + if (/^en\b/i.test(locale)) return items.join(', ') + try { + return new Intl.ListFormat([locale, 'en'], { type: 'conjunction' }).format(items) + } catch { + return items.join(', ') + } +} + export function formatLength( meters: number, unit: LengthUnit, diff --git a/packages/core/src/i18n/index.ts b/packages/core/src/i18n/index.ts index e1c81ca903..990c4593c7 100644 --- a/packages/core/src/i18n/index.ts +++ b/packages/core/src/i18n/index.ts @@ -1,6 +1,8 @@ export type { MessageDescriptor } from '@lingui/core' export { + formatFixed, formatLength, + formatList, formatNumber, type LengthUnit, levelDisplayNumber, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e4c9cafc1..81903c4a13 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -186,7 +186,11 @@ export { staleFinishScripts, } from './lib/finish-script' export { floorConstructionLift, liftedManualSlab } from './lib/floor-construction-lift' -export { floorFootprintName } from './lib/floor-footprint-name' +export { + type FloorFootprintNaming, + floorFootprintName, + floorFootprintNaming, +} from './lib/floor-footprint-name' export { automaticFloorHeight, floorFootprintSupportClass, diff --git a/packages/core/src/lib/floor-footprint-name.test.ts b/packages/core/src/lib/floor-footprint-name.test.ts index ba7de3e483..d93e5a6eb2 100644 --- a/packages/core/src/lib/floor-footprint-name.test.ts +++ b/packages/core/src/lib/floor-footprint-name.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test' import { type AnyNode, SlabNode, ZoneNode } from '../schema' -import { floorFootprintName } from './floor-footprint-name' +import { floorFootprintName, floorFootprintNaming } from './floor-footprint-name' const square = (x: number, size: number): [number, number][] => [ [x, 0], @@ -47,3 +47,21 @@ test('footprints never read as levels: Shared floor, then the largest room, then const unnamed = { ...annex, zoneIds: [blank.id] } expect(floorFootprintName({ ...nodes, [blank.id]: blank }, unnamed)).toBe('Floor area') }) + +test('the naming parts let a UI word the footprint names in its own language', () => { + const kitchen = room('zone_kitchen', square(0, 6), 'Kitchen') + const hall = room('zone_hall', square(6, 2), 'Hall') + const nine = room('zone_nine', square(20, 3), 'Room 9') + const upper = room('zone_upper', square(30, 3), 'Upper floor') + const house = plate('slab_house', square(0, 8), [kitchen.id, hall.id]) + const annex = plate('slab_annex', square(20, 4), [nine.id]) + const deck = plate('slab_deck', square(30, 4), [upper.id]) + const pad = plate('slab_pad', square(40, 2), [], 'Garden foundation') + const bare = plate('slab_bare', square(50, 2)) + const nodes = scene(kitchen, hall, nine, upper, house, annex, deck, pad, bare) + expect(floorFootprintNaming(nodes, house)).toEqual({ kind: 'shared' }) + expect(floorFootprintNaming(nodes, annex)).toEqual({ kind: 'room', room: 'Room 9' }) + expect(floorFootprintNaming(nodes, deck)).toEqual({ kind: 'named', name: 'Upper floor' }) + expect(floorFootprintNaming(nodes, pad)).toEqual({ kind: 'named', name: 'Garden foundation' }) + expect(floorFootprintNaming(nodes, bare)).toEqual({ kind: 'area' }) +}) diff --git a/packages/core/src/lib/floor-footprint-name.ts b/packages/core/src/lib/floor-footprint-name.ts index 8936d689a8..e685ff3bc2 100644 --- a/packages/core/src/lib/floor-footprint-name.ts +++ b/packages/core/src/lib/floor-footprint-name.ts @@ -19,8 +19,36 @@ export function floorFootprintName( nodes: Readonly>, plate: SlabNode, ): string { + const naming = floorFootprintNaming(nodes, plate) + switch (naming.kind) { + case 'named': + return naming.name + case 'room': + return `${naming.room} floor` + case 'shared': + return 'Shared floor' + case 'area': + return 'Floor area' + } +} + +/** + * How `floorFootprintName` arrives at its name, so a UI can word it in its + * language: `named` is the plate's own name (or a room name that already + * ends in "floor") and shows as is; `room` is " floor". + */ +export type FloorFootprintNaming = + | { kind: 'named'; name: string } + | { kind: 'room'; room: string } + | { kind: 'shared' } + | { kind: 'area' } + +export function floorFootprintNaming( + nodes: Readonly>, + plate: SlabNode, +): FloorFootprintNaming { const own = plate.name?.trim() - if (own && !GENERATED_NAME.test(own)) return own + if (own && !GENERATED_NAME.test(own)) return { kind: 'named', name: own } const rooms = (plate.zoneIds ?? []) .flatMap((id) => { const zone = nodes[id] @@ -34,7 +62,7 @@ export function floorFootprintName( !rooms.some((room) => room.floor?.footprint) && isLargestFootprint(nodes, plate) ) - return 'Shared floor' + return { kind: 'shared' } const key = rooms.find((room) => room.floor?.footprint)?.floor?.footprint const creatorId = key && floorFootprintCreatorId(key) const creator = creatorId ? nodes[creatorId] : undefined @@ -49,8 +77,9 @@ export function floorFootprintName( ? creator.name?.trim() : undefined const named = creatorName || rooms.map((room) => room.name?.trim()).find(Boolean) - if (!named) return rooms.some((room) => room.floor?.footprint) ? 'Shared floor' : 'Floor area' - return /\bfloor$/i.test(named) ? named : `${named} floor` + if (!named) + return rooms.some((room) => room.floor?.footprint) ? { kind: 'shared' } : { kind: 'area' } + return /\bfloor$/i.test(named) ? { kind: 'named', name: named } : { kind: 'room', room: named } } function isLargestFootprint(nodes: Readonly>, plate: SlabNode) { diff --git a/packages/editor/src/components/editor/room-labels.tsx b/packages/editor/src/components/editor/room-labels.tsx index 489a4eb6c1..2d102c9a0e 100644 --- a/packages/editor/src/components/editor/room-labels.tsx +++ b/packages/editor/src/components/editor/room-labels.tsx @@ -8,11 +8,13 @@ import { DEFAULT_LEVEL_HEIGHT, emitter, formatNumber, + type PascalI18n, type Polygon, polygonInteriorPoint, type Ring, sceneRegistry, useLocale, + usePascalI18n, useScene, type ZoneNode, } from '@pascal-app/core' @@ -49,13 +51,9 @@ import { useShallow } from 'zustand/react/shallow' import { useRoomRecords } from '../../hooks/use-selected-room' import { getAreaUnitLabel, type LinearUnit, squareMetersToAreaUnit } from '../../lib/measurements' import { roomFloorElevation } from '../../lib/room-handle-drag' +import { displayRoomName } from '../../lib/room-name-messages' import type { RoomSelectionRecord } from '../../lib/room-selection' -import { - clickZoneArea, - hoverZoneArea, - leaveZoneArea, - zoneKindLabel, -} from '../../lib/room-zone-routing' +import { clickZoneArea, hoverZoneArea, leaveZoneArea } from '../../lib/room-zone-routing' import { selectionModifiersFromEvent } from '../../lib/selection-routing' import useEditor from '../../store/use-editor' import useInteractionScope from '../../store/use-interaction-scope' @@ -1252,7 +1250,13 @@ function useLabelZone(zoneId: string) { return zone && zone.visible !== false ? zone : null } -const labelName = (zone: ZoneNode) => zone.name?.trim() || zoneKindLabel(zone) +const labelName = (zone: ZoneNode, i18n: PascalI18n) => { + const name = zone.name?.trim() + if (name) return displayRoomName(name, i18n) + return zone.spaceRole === 'room' + ? i18n._({ id: 'editor.roomLabel.room', message: 'Room' }) + : i18n._({ id: 'editor.roomLabel.zone', message: 'Zone' }) +} /** * One room's pill in the editor. Hover and click take the same rule as the @@ -1268,6 +1272,7 @@ function RoomPill({ view: '2d' | '3d' elements: Map }) { + const i18n = usePascalI18n() const zone = useLabelZone(entry.zoneId) // A pill that disappears under the cursor (its click selected something) // fires no leave: end the hover it set. @@ -1278,7 +1283,7 @@ function RoomPill({ areaM2={entry.area} color={zone.color} elements={elements} - name={labelName(zone)} + name={labelName(zone, i18n)} onClick={(event) => clickZoneArea(entry.zoneId, selectionModifiersFromEvent(event))} onHover={(event) => hoverZoneArea(entry.zoneId, selectionModifiersFromEvent(event))} onLeave={() => leaveZoneArea(entry.zoneId)} @@ -1289,13 +1294,14 @@ function RoomPill({ } function RoomDetail({ entry, view }: { entry: LabelEntry; view: '2d' | '3d' }) { + const i18n = usePascalI18n() const zone = useLabelZone(entry.zoneId) if (!zone) return null return ( diff --git a/packages/editor/src/components/ui/controls/finish-swatch.tsx b/packages/editor/src/components/ui/controls/finish-swatch.tsx index d7083b1000..18f4e8f694 100644 --- a/packages/editor/src/components/ui/controls/finish-swatch.tsx +++ b/packages/editor/src/components/ui/controls/finish-swatch.tsx @@ -6,6 +6,8 @@ import { getLibraryMaterialIdFromRef, getSceneMaterialIdFromRef, nodeRegistry, + type PascalI18n, + usePascalI18n, useScene, } from '@pascal-app/core' import { useState } from 'react' @@ -16,12 +18,20 @@ type Swatch = { label: string; color: string | null; image: string | null } /** How a finish reference reads in a panel: its catalog tile, its colour, or its scene material. */ function useFinishSwatch(ref: string | undefined, fallback: string): Swatch { + const i18n = usePascalI18n() const materials = useScene((state) => state.materials) - if (!ref) return { ...describe(fallback, materials), label: 'Default' } - return describe(ref, materials) + if (!ref) return { ...describe(fallback, materials, i18n), label: defaultLabel(i18n) } + return describe(ref, materials, i18n) } -function describe(ref: string, materials: ReturnType['materials']) { +const defaultLabel = (i18n: PascalI18n) => + i18n._({ id: 'editor.controls.finishSwatch.default', message: 'Default' }) + +function describe( + ref: string, + materials: ReturnType['materials'], + i18n: PascalI18n, +) { const library = getCatalogMaterialById(getLibraryMaterialIdFromRef(ref) ?? undefined) if (library) return { @@ -33,7 +43,7 @@ function describe(ref: string, materials: ReturnType[' const scene = sceneId ? materials[sceneId as keyof typeof materials] : undefined if (scene) return { label: scene.name, color: scene.material.properties?.color ?? null, image: null } if (ref.startsWith('#')) return { label: ref.toUpperCase(), color: ref, image: null } - return { label: 'Default', color: null, image: null } + return { label: defaultLabel(i18n), color: null, image: null } } /** @@ -70,6 +80,7 @@ export function FinishSwatch({ /** Tooltip on the row. */ hint?: string }) { + const i18n = usePascalI18n() const [open, setOpen] = useState(false) const swatch = useFinishSwatch(value, fallback) return ( @@ -78,7 +89,11 @@ export function FinishSwatch({ @@ -132,7 +133,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp setCreateName('') } }} - placeholder="Collection name…" + placeholder={i18n._({ id: 'editor.panels.collections.collectionsPopover.collectionName', message: 'Collection name…' })} value={createName} /> @@ -314,14 +314,14 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp }} > - Rename + {i18n._({ id: 'editor.panels.collections.collectionsPopover.rename', message: 'Rename' })} setDeletingId(collection.id)} variant="destructive" > - Delete + {i18n._({ id: 'editor.panels.collections.collectionsPopover.delete', message: 'Delete' })} diff --git a/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx b/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx index 9ad351a7e7..b4e9d65ced 100644 --- a/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx +++ b/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx @@ -4,19 +4,22 @@ import { type AnyNode, type AnyNodeId, type FloorFoundationPatch, - floorFootprintName, floorFootprintSupportClass, upperFloorHeightControl, - getLevelDisplayName, getLevelElevations, getStoredLevelHeight, type LevelNode, MIN_GROUND_FLOOR_THICKNESS, MIN_SLAB_THICKNESS, + type PascalI18n, type SlabNode, + useLocale, + usePascalI18n, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { localizedFloorFootprintName } from '../../../i18n/floor-footprint-name' +import { localizedLevelName } from '../../../i18n/level-name' import { useRef, useState } from 'react' import { applyFloorFoundation, @@ -78,6 +81,7 @@ export function FloorSection({ thickness: number thicknessLabel: string }) { + const i18n = usePascalI18n() const base = 100 const foundation = !supported && raised ? Math.min(40, 14 + foundationHeight * 30) : 0 const band = Math.max(7, Math.min(16, thickness * 60)) @@ -98,32 +102,67 @@ export function FloorSection({ const left = 64 const right = 164 const labels: { y: number; text: string; key: string }[] = [ - { key: 'top', y: bandTop - 9, text: 'Floor top' }, + { + key: 'top', + y: bandTop - 9, + text: i18n._({ id: 'editor.panels.floorFoundationPanel.section.floorTop', message: 'Floor top' }), + }, { key: 'plate', y: bandTop + band / 2, - text: supported ? `Floor plate · ${thicknessLabel}` : `Slab · ${thicknessLabel}`, + text: supported + ? i18n._({ + id: 'editor.panels.floorFoundationPanel.section.floorPlate', + message: 'Floor plate · {thickness}', + values: { thickness: thicknessLabel }, + }) + : i18n._({ + id: 'editor.panels.floorFoundationPanel.section.slab', + message: 'Slab · {thickness}', + values: { thickness: thicknessLabel }, + }), }, ...(foundation ? [ { key: 'foundation', y: bandBottom + foundation / 2, - text: foundationLabel ? `Foundation · ${foundationLabel}` : 'Foundation', + text: foundationLabel + ? i18n._({ + id: 'editor.panels.floorFoundationPanel.section.foundationKind', + message: 'Foundation · {foundation}', + values: { foundation: foundationLabel }, + }) + : i18n._({ id: 'editor.panels.floorFoundationPanel.section.foundation', message: 'Foundation' }), }, ] : []), - { key: 'base', y: base + 9, text: supported ? 'Walls below' : 'Ground' }, + { + key: 'base', + y: base + 9, + text: supported + ? i18n._({ id: 'editor.panels.floorFoundationPanel.section.wallsBelow', message: 'Walls below' }) + : i18n._({ id: 'editor.panels.floorFoundationPanel.section.ground', message: 'Ground' }), + }, ] return (
- Not to scale + {i18n._({ id: 'editor.panels.floorFoundationPanel.notToScale', message: 'Not to scale' })}
) @@ -271,15 +310,29 @@ type Notice = { together: { ids: string[]; patch: FloorFoundationPatch; lower: boolean } | null } -export function moveTogetherLabel(count: number, lower: boolean) { - const verb = lower ? 'Lower' : 'Raise' - return count === 2 ? `${verb} both` : `${verb} all ${count}` +export function moveTogetherLabel(count: number, lower: boolean, i18n: PascalI18n) { + const values = { count } + return lower + ? i18n._({ + id: 'editor.panels.floorFoundationPanel.lowerTogether', + message: '{count, plural, =2 {Lower both} other {Lower all #}}', + values, + }) + : i18n._({ + id: 'editor.panels.floorFoundationPanel.raiseTogether', + message: '{count, plural, =2 {Raise both} other {Raise all #}}', + values, + }) } /** "Floor plate · Ground floor", upstairs "Floor plate · Floor 1 · rests on Ground floor". */ -export function floorPlateKicker(nodes: Record, plate: SlabNode): string { +export function floorPlateKicker( + nodes: Record, + plate: SlabNode, + i18n: PascalI18n, + locale: string, +): string { const level = plate.parentId ? nodes[plate.parentId] : undefined - const levelName = level?.type === 'level' ? ` · ${getLevelDisplayName(level)}` : '' // A supported floor rests on the storey right below it. let below: LevelNode | null = null if (level?.type === 'level' && floorFootprintSupportClass(nodes, plate) === 'supported') @@ -291,8 +344,20 @@ export function floorPlateKicker(nodes: Record, plate: SlabNode (!below || node.level > below.level) ) below = node - const restsOn = below ? ` · rests on ${getLevelDisplayName(below)}` : '' - return `Floor plate${levelName}${restsOn}` + if (level?.type !== 'level') + return i18n._({ id: 'editor.panels.floorFoundationPanel.kicker', message: 'Floor plate' }) + const levelName = localizedLevelName(level, i18n, locale) + return below + ? i18n._({ + id: 'editor.panels.floorFoundationPanel.kickerRestsOn', + message: 'Floor plate · {level} · rests on {below}', + values: { level: levelName, below: localizedLevelName(below, i18n, locale) }, + }) + : i18n._({ + id: 'editor.panels.floorFoundationPanel.kickerLevel', + message: 'Floor plate · {level}', + values: { level: levelName }, + }) } /** @@ -311,7 +376,6 @@ export function floorFoundationModel(nodes: Record, plate: Slab : 0 return { upper, - kicker: floorPlateKicker(nodes, plate), preset: upper ? ('ground' as const) : footprintPreset(nodes, plate), /** Upstairs: the floor top (level-local). On the ground: the foundation height. */ height, @@ -332,6 +396,8 @@ export function floorFoundationModel(nodes: Record, plate: Slab * the building live and lands as one undo step. */ export function FloorFoundationPanel({ node, onClose }: { node: SlabNode; onClose: () => void }) { + const i18n = usePascalI18n() + const locale = useLocale() const nodes = useScene((state) => state.nodes) const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) @@ -341,7 +407,8 @@ export function FloorFoundationPanel({ node, onClose }: { node: SlabNode; onClos const plate = nodes[node.id as AnyNodeId] if (plate?.type !== 'slab' || plate.plateRole !== 'base') return null const length = (meters: number) => formatLinearMeasurement(meters, unit, metricNotation) - const { upper, kicker, preset, height, floorTop, minHeight, minThickness, underFloor, thickFloor } = + const kicker = floorPlateKicker(nodes, plate, i18n, locale) + const { upper, preset, height, floorTop, minHeight, minThickness, underFloor, thickFloor } = floorFoundationModel(nodes, plate) const level = plate.parentId ? nodes[plate.parentId as AnyNodeId] : undefined const maxHeight = level?.type === 'level' ? getStoredLevelHeight(level) - 0.5 : 3 @@ -389,7 +456,7 @@ export function FloorFoundationPanel({ node, onClose }: { node: SlabNode; onClos icon="/icons/floor.webp" kicker={kicker} onClose={onClose} - title={floorFootprintName(nodes, plate)} + title={localizedFloorFootprintName(nodes, plate, i18n)} >

- {upper ? 'Floor plate' : 'Slab'} + {upper ? i18n._({ id: 'editor.panels.floorFoundationPanel.floorPlate', message: 'Floor plate' }) : i18n._({ id: 'editor.panels.floorFoundationPanel.slab', message: 'Slab' })}

{upper ? ( -
+
previewHeight(underside + next)} @@ -426,10 +493,10 @@ export function FloorFoundationPanel({ node, onClose }: { node: SlabNode; onClos />
) : ( -
+
{ @@ -454,13 +521,17 @@ export function FloorFoundationPanel({ node, onClose }: { node: SlabNode; onClos )} {upper && (

- Floor top +{length(floorTop)} + {i18n._({ + id: 'editor.panels.floorFoundationPanel.floorTopAbove', + message: 'Floor top +{height}', + values: { height: length(floorTop) }, + })}

)} {underFloor && (
-

Under the floor

+

{i18n._({ id: 'editor.panels.floorFoundationPanel.underTheFloor', message: 'Under the floor' })}

{ if (next === preset) return @@ -481,17 +552,17 @@ export function FloorFoundationPanel({ node, onClose }: { node: SlabNode; onClos report(applyFloorFoundation(plate.id, patch), patch) }} options={[ - { label: 'On the ground', value: 'ground' }, - { label: 'Raised on a foundation', value: 'raised' }, + { label: i18n._({ id: 'editor.panels.floorFoundationPanel.onTheGround', message: 'On the ground' }), value: 'ground' }, + { label: i18n._({ id: 'editor.panels.floorFoundationPanel.raisedOnAFoundation', message: 'Raised on a foundation' }), value: 'raised' }, ]} value={preset} /> {preset === 'raised' && ( <> -
+
)}

- Floor top {length(floorTop)} + {i18n._({ + id: 'editor.panels.floorFoundationPanel.floorTopValue', + message: 'Floor top {height}', + values: { height: length(floorTop) }, + })}

)} @@ -540,7 +615,7 @@ export function FloorFoundationPanel({ node, onClose }: { node: SlabNode; onClos type="button" variant="outline" > - {moveTogetherLabel(notice.together.ids.length, notice.together.lower)} + {moveTogetherLabel(notice.together.ids.length, notice.together.lower, i18n)} )}
diff --git a/packages/editor/src/components/ui/panels/floor-opening-panel.tsx b/packages/editor/src/components/ui/panels/floor-opening-panel.tsx index cef08a9534..ca9b412197 100644 --- a/packages/editor/src/components/ui/panels/floor-opening-panel.tsx +++ b/packages/editor/src/components/ui/panels/floor-opening-panel.tsx @@ -5,8 +5,8 @@ import { adjacentLevelId, area, floorOpeningHints, - getLevelDisplayName, - useScene, + useScene, usePascalI18n, + useLocale, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { @@ -18,6 +18,9 @@ import { formatAreaLabel } from '../../../lib/measurements' import { Button } from '../primitives/button' import { Switch } from '../primitives/switch' import { PanelWrapper } from './panel-wrapper' +import { localizedLevelName } from '../../../i18n/level-name' +import { nodeLabel } from '../../../i18n/node-messages' +import { displayRoomName } from '../../../lib/room-name-messages' /** * A floor (or ceiling) opening: where it is, what it cuts, and the switch for @@ -27,6 +30,8 @@ import { PanelWrapper } from './panel-wrapper' * handles in the scene. Done and Delete return to the room. */ export function FloorOpeningPanel({ openingId }: { openingId: string }) { + const i18n = usePascalI18n() + const locale = useLocale() const nodes = useScene((state) => state.nodes) const unit = useViewer((s) => s.unit) const readOnly = useScene((state) => state.readOnly) @@ -35,7 +40,13 @@ export function FloorOpeningPanel({ openingId }: { openingId: string }) { const onCeiling = opening.drawnOn === 'ceiling' const zoneId = openingRoom(nodes, opening) const zone = zoneId ? nodes[zoneId as AnyNodeId] : undefined - const roomName = zone?.type === 'zone' ? zone.name?.trim() || 'Room' : null + const storedRoomName = zone?.type === 'zone' ? zone.name?.trim() : undefined + const roomName = + zone?.type !== 'zone' + ? null + : storedRoomName + ? displayRoomName(storedRoomName, i18n) + : i18n._({ id: 'editor.panels.floorOpeningPanel.room', message: 'Room' }) const level = opening.parentId ? nodes[opening.parentId as AnyNodeId] : undefined const adjacentId = opening.parentId ? adjacentLevelId(nodes, opening.parentId, onCeiling ? 1 : -1) @@ -47,19 +58,35 @@ export function FloorOpeningPanel({ openingId }: { openingId: string }) { !!opening.ownerId && nodes[opening.ownerId as AnyNodeId]?.type === opening.source const hints = floorOpeningHints(nodes, opening) - const title = opening.source === 'stair' - ? 'Stair opening' - : opening.source === 'elevator' - ? 'Elevator opening' - : onCeiling ? 'Ceiling opening' : 'Floor opening' - const kicker = [ - 'Opening', - roomName ? `in ${roomName}` : null, - level?.type === 'level' ? getLevelDisplayName(level) : null, - ] - .filter(Boolean) - .join(' · ') - const switchLabel = onCeiling ? 'Also cut the floor above' : 'Also cut the ceiling below' + const title = i18n._({ + id: 'editor.panels.floorOpeningPanel.title', + message: + '{source, select, stair {Stair opening} elevator {Elevator opening} ceiling {Ceiling opening} other {Floor opening}}', + values: { source: opening.source === 'stair' || opening.source === 'elevator' ? opening.source : onCeiling ? 'ceiling' : 'floor' }, + }) + const levelName = level?.type === 'level' ? localizedLevelName(level, i18n, locale) : null + const kicker = roomName + ? levelName + ? i18n._({ + id: 'editor.panels.floorOpeningPanel.kickerRoomLevel', + message: 'Opening · in {room} · {level}', + values: { room: roomName, level: levelName }, + }) + : i18n._({ + id: 'editor.panels.floorOpeningPanel.kickerRoom', + message: 'Opening · in {room}', + values: { room: roomName }, + }) + : levelName + ? i18n._({ + id: 'editor.panels.floorOpeningPanel.kickerLevel', + message: 'Opening · {level}', + values: { level: levelName }, + }) + : i18n._({ id: 'editor.panels.floorOpeningPanel.kicker', message: 'Opening' }) + const switchLabel = onCeiling + ? i18n._({ id: 'editor.panels.floorOpeningPanel.alsoCutTheFloorAbove', message: 'Also cut the floor above' }) + : i18n._({ id: 'editor.panels.floorOpeningPanel.alsoCutTheCeilingBelow', message: 'Also cut the ceiling below' }) const setAdjacent = (next: boolean) => useScene.getState().updateNode(opening.id, { cutsAdjacent: next }) @@ -72,8 +99,17 @@ export function FloorOpeningPanel({ openingId }: { openingId: string }) { >

- {formatAreaLabel(area([{ outer: opening.polygon, holes: [] }]), unit, 2)} - {owned ? ' · Shape follows its owner.' : ' · Drag its corners to change the shape.'} + {owned + ? i18n._({ + id: 'editor.panels.floorOpeningPanel.areaOwned', + message: '{area} · Shape follows its owner.', + values: { area: formatAreaLabel(area([{ outer: opening.polygon, holes: [] }]), unit, 2) }, + }) + : i18n._({ + id: 'editor.panels.floorOpeningPanel.areaDragCorners', + message: '{area} · Drag its corners to change the shape.', + values: { area: formatAreaLabel(area([{ outer: opening.polygon, holes: [] }]), unit, 2) }, + })}

{owned && ( )} {hosted ? (

- A hatch in the mezzanine: it cuts only the mezzanine’s floor. + {i18n._({ id: 'editor.panels.floorOpeningPanel.aHatchInTheMezzanineIt', message: 'A hatch in the mezzanine: it cuts only the mezzanine’s floor.' })}

) : owned ? null : (
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 92c086096b..16434304d1 100644 --- a/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx +++ b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx @@ -1,5 +1,6 @@ 'use client' +import { usePascalI18n } from '@pascal-app/core' import { X } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { type ReactNode, useEffect, useState } from 'react' @@ -19,6 +20,7 @@ const HEIGHT_VH = 50 const DRAG_CLOSE_THRESHOLD_PX = 120 export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) { + const i18n = usePascalI18n() const [mounted, setMounted] = useState(false) const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight) @@ -88,7 +90,7 @@ export function MobilePanelSheet({ open, onClose, icon, title, children }: Mobil
)} {parametrics.groups.map((group, gi) => ( {hasKindControls ? ( diff --git a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx index d4b3149754..f4837eb426 100644 --- a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx +++ b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx @@ -6,7 +6,8 @@ import { type BuildingNode, resolveBuildingForLevel, runAsSingleSceneHistoryStep, - useScene, + useScene, usePascalI18n, + type PascalI18n, } from '@pascal-app/core' import { unwrapCorner, windowMergeOffer } from '@pascal-app/core/building' import { useViewer } from '@pascal-app/viewer' @@ -57,36 +58,71 @@ function mergeRefusalNote( refusal: { code: string; details: Record }, nodes: Record, length: (meters: number) => string, + i18n: PascalI18n, ): string { const { blockingId, limit } = refusal.details as { blockingId?: string; limit?: number } const reach = length(limit ?? 0) const blocking = nodes[blockingId ?? ''] - const blockingKind = !blocking - ? 'something' - : blocking.type === 'item' - ? 'an item' - : `a ${blocking.type}` + // A wall's children: the openings and wall-hosted items the merge can run into. + const kind = blocking?.type ?? 'unknown' switch (refusal.code) { case 'window_already_joined': - return 'One of them already wraps another corner. Unwrap it first.' + return i18n._({ + id: 'editor.panels.mergeRefusal.alreadyJoined', + message: 'One of them already wraps another corner. Unwrap it first.', + }) case 'window_not_on_wall': - return 'A roof or dormer window cannot join another window.' + return i18n._({ + id: 'editor.panels.mergeRefusal.notOnWall', + message: 'A roof or dormer window cannot join another window.', + }) case 'windows_not_adjacent': - return `They are too far apart. Windows merge with at most ${reach} between them.` + return i18n._({ + id: 'editor.panels.mergeRefusal.notAdjacent', + message: 'They are too far apart. Windows merge with at most {reach} between them.', + values: { reach }, + }) case 'opening_between': - return `There is ${blockingKind} between them.` + return i18n._({ + id: 'editor.panels.mergeRefusal.between', + message: + '{kind, select, item {There is an item between them.} door {There is a door between them.} window {There is a window between them.} shelf {There is a shelf between them.} other {There is something between them.}}', + values: { kind }, + }) case 'walls_not_meeting': - return 'Their walls do not meet at a corner.' + return i18n._({ + id: 'editor.panels.mergeRefusal.wallsNotMeeting', + message: 'Their walls do not meet at a corner.', + }) case 'corner_angle': - return 'Their walls meet at too sharp or too flat an angle for a corner window.' + return i18n._({ + id: 'editor.panels.mergeRefusal.cornerAngle', + message: 'Their walls meet at too sharp or too flat an angle for a corner window.', + }) case 'window_not_at_corner': - return `One of them is too far from the corner. A window joins one within ${reach} of it.` + return i18n._({ + id: 'editor.panels.mergeRefusal.notAtCorner', + message: 'One of them is too far from the corner. A window joins one within {reach} of it.', + values: { reach }, + }) case 'height_exceeds_wall': - return 'The window selected first is taller than the other wall. Select the other one first.' + return i18n._({ + id: 'editor.panels.mergeRefusal.heightExceedsWall', + message: + 'The window selected first is taller than the other wall. Select the other one first.', + }) case 'opening_overlap': - return `Sliding them to the corner would overlap ${blockingKind}.` + return i18n._({ + id: 'editor.panels.mergeRefusal.overlap', + message: + '{kind, select, item {Sliding them to the corner would overlap an item.} door {Sliding them to the corner would overlap a door.} window {Sliding them to the corner would overlap a window.} shelf {Sliding them to the corner would overlap a shelf.} other {Sliding them to the corner would overlap something.}}', + values: { kind }, + }) default: - return 'These windows cannot be merged.' + return i18n._({ + id: 'editor.panels.mergeRefusal.cannotMerge', + message: 'These windows cannot be merged.', + }) } } @@ -97,6 +133,7 @@ function mergeRefusalNote( * Unwrap instead; two that cannot join, the reason. */ function MergeWindowsAction() { + const i18n = usePascalI18n() const selectedIds = useViewer((s) => s.selection.selectedIds) const nodes = useScene((s) => s.nodes) const readOnly = useScene((s) => s.readOnly) @@ -110,7 +147,7 @@ function MergeWindowsAction() { className="basis-full whitespace-nowrap disabled:cursor-not-allowed disabled:opacity-40" disabled={readOnly} icon={} - label="Unwrap the corner" + label={i18n._({ id: 'editor.panels.multiSelectionPanel.unwrapTheCorner', message: 'Unwrap the corner' })} onClick={() => { writeWindowChanges({ create: null, @@ -119,13 +156,16 @@ function MergeWindowsAction() { }) triggerSFX('sfx:structure-build') }} - title="Unwrap the corner: both windows stay, each framed at the corner" + title={i18n._({ id: 'editor.panels.multiSelectionPanel.unwrapTheCornerBothWindowsStay', message: 'Unwrap the corner: both windows stay, each framed at the corner' })} /> ) const { merge } = offer const note = offer.refusal - ? mergeRefusalNote(offer.refusal, nodes, (meters) => - formatLinearMeasurement(meters, unit, metricNotation), + ? mergeRefusalNote( + offer.refusal, + nodes, + (meters) => formatLinearMeasurement(meters, unit, metricNotation), + i18n, ) : null return ( @@ -134,7 +174,7 @@ function MergeWindowsAction() { className="basis-full whitespace-nowrap disabled:cursor-not-allowed disabled:opacity-40" disabled={readOnly || !merge} icon={} - label="Merge windows" + label={i18n._({ id: 'editor.panels.multiSelectionPanel.mergeWindows', message: 'Merge windows' })} onClick={() => { if (!merge) return runAsSingleSceneHistoryStep(useScene, () => @@ -150,8 +190,8 @@ function MergeWindowsAction() { title={ note ?? (merge?.kind === 'corner' - ? 'Merge windows into a corner window: both take the height, sill and type of the one you selected first' - : 'Merge windows into one across both: it keeps the type and style of the one you selected first') + ? i18n._({ id: 'editor.panels.multiSelectionPanel.mergeWindowsIntoACornerWindow', message: 'Merge windows into a corner window: both take the height, sill and type of the one you selected first' }) + : i18n._({ id: 'editor.panels.multiSelectionPanel.mergeWindowsIntoOneAcrossBoth', message: 'Merge windows into one across both: it keeps the type and style of the one you selected first' })) } /> {note ?

{note}

: null} @@ -160,6 +200,7 @@ function MergeWindowsAction() { } export function MultiSelectionActions() { + const i18n = usePascalI18n() const selectedIds = useViewer((s) => s.selection.selectedIds) const sessionGroups = useSessionGroups((s) => s.groups) const sceneNodes = useScene((s) => s.nodes) @@ -182,39 +223,39 @@ export function MultiSelectionActions() { } - label="Select building" + label={i18n._({ id: 'editor.panels.multiSelectionPanel.selectBuilding', message: 'Select building' })} onClick={() => selectBuilding(buildingId)} - title="Select the whole building to move all its levels" + title={i18n._({ id: 'editor.panels.multiSelectionPanel.selectTheWholeBuildingToMove', message: 'Select the whole building to move all its levels' })} /> )} {showGroup && ( } - label="Group" + label={i18n._({ id: 'editor.panels.multiSelectionPanel.group', message: 'Group' })} onClick={() => groupCurrentSelection()} - title="Group (Ctrl/Cmd+G)" + title={i18n._({ id: 'editor.panels.multiSelectionPanel.groupCtrlCmdG', message: 'Group (Ctrl/Cmd+G)' })} /> )} {showUngroup && ( } - label="Ungroup" + label={i18n._({ id: 'editor.panels.multiSelectionPanel.ungroup', message: 'Ungroup' })} onClick={() => ungroupCurrentSelection()} - title="Ungroup (Ctrl/Cmd+Shift+G)" + title={i18n._({ id: 'editor.panels.multiSelectionPanel.ungroupCtrlCmdShiftG', message: 'Ungroup (Ctrl/Cmd+Shift+G)' })} /> )} } - label="Duplicate" + label={i18n._({ id: 'editor.panels.multiSelectionPanel.duplicate', message: 'Duplicate' })} onClick={() => duplicateSelectionAndPickUp()} /> } - label="Delete" + label={i18n._({ id: 'editor.panels.multiSelectionPanel.delete', message: 'Delete' })} onClick={() => deleteSelection()} /> @@ -225,6 +266,7 @@ export function MultiSelectionActions() { * Docked multi-selection panel. Includes Group / Ungroup for session selection sets. */ export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) { + const i18n = usePascalI18n() const selectedIds = useViewer((s) => s.selection.selectedIds) const setSelection = useViewer((s) => s.setSelection) const sessionGroups = useSessionGroups((s) => s.groups) @@ -246,16 +288,14 @@ export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) { title={ matchedGroup ? `${matchedGroup.label} · ${selectedIds.length}` - : `${selectedIds.length} selected` + : i18n._({ id: 'editor.panels.multiSelectionPanel.lengthSelected', message: '{length} selected', values: { length: selectedIds.length } }) } width={320} > {breakdown &&
{breakdown}
} {matchedGroup && (
- {matchedGroup.label} (session only). Plain click reselects all members. Not saved with the - project. -
+ {i18n._({ id: 'editor.panels.multiSelectionPanel.labelSessionOnlyPlainClickReselects', message: '{label} (session only). Plain click reselects all members. Not saved with the project.', values: { label: matchedGroup.label } })} )}
diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index e5b1227c10..1a8f7c2873 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -1,36 +1,42 @@ -import { type AnyNode, scriptImages } from '@pascal-app/core' +import { type AnyNode, type MessageDescriptor, type PascalI18n, scriptImages } from '@pascal-app/core' +import { displayRoomName } from '../../../lib/room-name-messages' export type NodeDisplay = { icon: string label: string } -const TYPE_DEFAULTS: Record = { - item: { icon: '/icons/item.webp', label: 'Item' }, - wall: { icon: '/icons/wall.webp', label: 'Wall' }, - door: { icon: '/icons/door.webp', label: 'Door' }, - window: { icon: '/icons/window.webp', label: 'Window' }, - slab: { icon: '/icons/floor.webp', label: 'Slab' }, - ceiling: { icon: '/icons/ceiling.webp', label: 'Ceiling' }, - column: { icon: '/icons/column.webp', label: 'Column' }, - elevator: { icon: '/icons/elevator.webp', label: 'Elevator' }, - fence: { icon: '/icons/fence.webp', label: 'Fence' }, - roof: { icon: '/icons/roof.webp', label: 'Roof' }, - 'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' }, - stair: { icon: '/icons/stairs.webp', label: 'Stair' }, - 'stair-segment': { icon: '/icons/stairs.webp', label: 'Stair segment' }, - scan: { icon: '/icons/mesh.webp', label: '3D Scan' }, - guide: { icon: '/icons/floorplan.webp', label: 'Guide image' }, - zone: { icon: '/icons/zone.webp', label: 'Zone' }, +const TYPE_DEFAULTS: Record = { + item: { icon: '/icons/item.webp', label: /* i18n */ { id: 'editor.nodeDisplay.item', message: 'Item' } }, + wall: { icon: '/icons/wall.webp', label: /* i18n */ { id: 'editor.nodeDisplay.wall', message: 'Wall' } }, + door: { icon: '/icons/door.webp', label: /* i18n */ { id: 'editor.nodeDisplay.door', message: 'Door' } }, + window: { icon: '/icons/window.webp', label: /* i18n */ { id: 'editor.nodeDisplay.window', message: 'Window' } }, + slab: { icon: '/icons/floor.webp', label: /* i18n */ { id: 'editor.nodeDisplay.slab', message: 'Slab' } }, + ceiling: { icon: '/icons/ceiling.webp', label: /* i18n */ { id: 'editor.nodeDisplay.ceiling', message: 'Ceiling' } }, + column: { icon: '/icons/column.webp', label: /* i18n */ { id: 'editor.nodeDisplay.column', message: 'Column' } }, + elevator: { icon: '/icons/elevator.webp', label: /* i18n */ { id: 'editor.nodeDisplay.elevator', message: 'Elevator' } }, + fence: { icon: '/icons/fence.webp', label: /* i18n */ { id: 'editor.nodeDisplay.fence', message: 'Fence' } }, + roof: { icon: '/icons/roof.webp', label: /* i18n */ { id: 'editor.nodeDisplay.roof', message: 'Roof' } }, + 'roof-segment': { icon: '/icons/roof.webp', label: /* i18n */ { id: 'editor.nodeDisplay.roofSegment', message: 'Roof segment' } }, + stair: { icon: '/icons/stairs.webp', label: /* i18n */ { id: 'editor.nodeDisplay.stair', message: 'Stair' } }, + 'stair-segment': { icon: '/icons/stairs.webp', label: /* i18n */ { id: 'editor.nodeDisplay.stairSegment', message: 'Stair segment' } }, + scan: { icon: '/icons/mesh.webp', label: /* i18n */ { id: 'editor.nodeDisplay.scan', message: '3D Scan' } }, + guide: { icon: '/icons/floorplan.webp', label: /* i18n */ { id: 'editor.nodeDisplay.guide', message: 'Guide image' } }, + zone: { icon: '/icons/zone.webp', label: /* i18n */ { id: 'editor.nodeDisplay.zone', message: 'Zone' } }, } -export function getTypeDisplay(type: string): NodeDisplay { - return TYPE_DEFAULTS[type] ?? { icon: '/icons/select.webp', label: type } +export function getTypeDisplay(type: string, i18n: PascalI18n): NodeDisplay { + const known = TYPE_DEFAULTS[type] + return known ? { icon: known.icon, label: i18n._(known.label) } : { icon: '/icons/select.webp', label: type } } -export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay { - if (!node) return { icon: '/icons/select.webp', label: 'Selection' } - const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.webp', label: node.type } +export function getNodeDisplay(node: AnyNode | null | undefined, i18n: PascalI18n): NodeDisplay { + if (!node) + return { + icon: '/icons/select.webp', + label: i18n._({ id: 'editor.nodeDisplay.selection', message: 'Selection' }), + } + const fallback = getTypeDisplay(node.type, i18n) const scripted = scriptImages(node)?.thumbnail // Item nodes carry an asset with its own thumbnail/name if (node.type === 'item') { @@ -40,7 +46,12 @@ export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay { } } if (node.type === 'zone' && node.spaceRole === 'room') { - return { icon: '/icons/kitchen.webp', label: node.name || 'Room' } + return { + icon: '/icons/kitchen.webp', + label: + (node.name && displayRoomName(node.name, i18n)) || + i18n._({ id: 'editor.nodeDisplay.room', message: 'Room' }), + } } return { icon: scripted ?? fallback.icon, diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index b3074393b6..fcb5d4caea 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -19,7 +19,7 @@ import { type StairSegmentNode, useScene, type WallNode, - type WindowNode, + type WindowNode, usePascalI18n, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -107,6 +107,7 @@ function MobilePanelLayer({ panel: React.ReactNode isReference: boolean }) { + const i18n = usePascalI18n() const setSelection = useViewer((s) => s.setSelection) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) const setMovingNode = useEditor((s) => s.setMovingNode) @@ -146,7 +147,7 @@ function MobilePanelLayer({ if (!(node || isReference)) return null - const display = getNodeDisplay(node) + const display = getNodeDisplay(node, i18n) return ( <> @@ -180,8 +181,9 @@ function MobileMultiPanelLayer({ panel: React.ReactNode type: string | null }) { + const i18n = usePascalI18n() const [isSheetOpen, setIsSheetOpen] = useState(false) - const display = type ? getTypeDisplay(type) : { icon: '/icons/select.webp', label: 'Selection' } + const display = type ? getTypeDisplay(type, i18n) : { icon: '/icons/select.webp', label: i18n._({ id: 'editor.panels.panelManager.selection', message: 'Selection' }) } const title = breakdown || display.label useEffect(() => { diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index 316ebb2c91..e38f9f8beb 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -8,7 +8,7 @@ import { type IconRef, type InspectorExtension, useRegistryVersion, - useScene, + useScene, usePascalI18n, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { ChevronDown, ChevronLeft, GripHorizontal, RotateCcw, X } from 'lucide-react' @@ -119,6 +119,7 @@ export function PanelWrapper({ className, width = 320, // default width }: PanelWrapperProps) { + const i18n = usePascalI18n() const isMobile = useIsMobile() const inStack = useInRightStack() const contextFooter = useContext(InspectorFooterContext) @@ -341,6 +342,7 @@ export function PanelWrapper({
{onBack && (
) @@ -279,6 +308,7 @@ export function DrawnFloorRow({ zoneId }: { zoneId: string }) { * right here. */ export function BuiltOnField({ zoneId }: { zoneId: string }) { + const i18n = usePascalI18n() const nodes = useScene((scene) => scene.nodes) const model = useMemo(() => roomBuiltOn(nodes, zoneId), [nodes, zoneId]) const [error, setError] = useState(null) @@ -289,16 +319,16 @@ export function BuiltOnField({ zoneId }: { zoneId: string }) { } return (
- + @@ -311,13 +341,13 @@ export function BuiltOnField({ zoneId }: { zoneId: string }) { if (choice.plateId !== model.current.plateId) choose(choice.key) }} > - {choice.name} + {floorChoiceName(nodes, choice, i18n)} {choice.plateId === model.current.plateId && } ))} choose('new')}> - + New floor + {i18n._({ id: 'editor.panels.roomConstructionRows.newFloor', message: '+ New floor' })} @@ -356,6 +386,7 @@ export function roomInsideHeight( * rather than naming the footprint next to it. */ function SitsOn({ zoneId, noFloor }: { zoneId: string; noFloor: boolean }) { + const i18n = usePascalI18n() const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) const summary = useScene( @@ -365,30 +396,33 @@ function SitsOn({ zoneId, noFloor }: { zoneId: string; noFloor: boolean }) { if (plate.plateRole !== 'base') return { id: plate.id as string, - name: plate.name?.trim() || 'Slab', + name: plate.name?.trim() || nodeLabel('slab', 'Slab', i18n), drawn: true, lift: 0, supported: false, } return { id: plate.id as string, - name: floorFootprintName(scene.nodes, plate), + name: localizedFloorFootprintName(scene.nodes, plate, i18n), drawn: false, ...footprintStanding(scene.nodes, plate), } }), ) + const lift = formatLinearMeasurement(Math.abs(summary.lift), unit, metricNotation) const preset = summary.drawn - ? 'Drawn slab' + ? i18n._({ id: 'editor.panels.roomConstructionRows.drawnSlab', message: 'Drawn slab' }) : Math.abs(summary.lift) > 0.005 - ? `${summary.lift > 0 ? 'Raised' : 'Lowered'} ${formatLinearMeasurement(Math.abs(summary.lift), unit, metricNotation)}` + ? summary.lift > 0 + ? i18n._({ id: 'editor.panels.roomConstructionRows.raised', message: 'Raised {lift}', values: { lift } }) + : i18n._({ id: 'editor.panels.roomConstructionRows.lowered', message: 'Lowered {lift}', values: { lift } }) : summary.supported - ? 'On the walls below' - : 'On the ground' + ? i18n._({ id: 'editor.panels.roomConstructionRows.onTheWallsBelow', message: 'On the walls below' }) + : i18n._({ id: 'editor.panels.roomConstructionRows.onTheGround', message: 'On the ground' }) if (noFloor) return ( - No floor + {i18n._({ id: 'editor.panels.roomConstructionRows.noFloor', message: 'No floor' })} ) if (!summary.id) return null @@ -400,7 +434,16 @@ function SitsOn({ zoneId, noFloor }: { zoneId: string; noFloor: boolean }) { type="button" > - Sits on: {summary.name} · {preset} + {messageWithSlot( + i18n, + /* i18n */ { + id: 'editor.panels.roomConstructionRows.sitsOnPreset', + message: 'Sits on: {name} · {preset}', + values: { preset }, + }, + 'name', + {summary.name}, + )} @@ -414,6 +457,7 @@ function SitsOn({ zoneId, noFloor }: { zoneId: string; noFloor: boolean }) { * no footprint height or foundation, and says so. */ export function RoomFloorConstructionField({ zoneId }: { zoneId: string }) { + const i18n = usePascalI18n() const owner = useScene( useShallow((scene) => { // Several drawn slabs show in the room: no one thickness to edit here. @@ -440,7 +484,7 @@ export function RoomFloorConstructionField({ zoneId }: { zoneId: string }) {
{owner.drawn && (

- A drawn slab: set its thickness and finishes here. It has no footprint height or - foundation. + {i18n._({ id: 'editor.panels.roomConstructionRows.aDrawnSlabSetItsThickness', message: 'A drawn slab: set its thickness and finishes here. It has no footprint height or foundation.' })}

)} {error && ( @@ -495,7 +538,7 @@ function sharingRoomNames( node.id !== zoneId && node.spaceRole === 'room' && node.boundaryWallIds.some((id) => wallIds.includes(id)) - ? [node.name?.trim() || 'the next room'] + ? [node.name?.trim() ?? ''] : [], ) } @@ -505,6 +548,8 @@ function sharingRoomNames( * with its switch, and its settings a chevron away. Then Add mezzanine. */ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { + const i18n = usePascalI18n() + const locale = useLocale() const zoneId = room.zoneId const state = useScene((scene) => roomConstructionState(scene.nodes, zoneId)) const nodes = useScene((scene) => scene.nodes) @@ -514,7 +559,10 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { const [message, setMessage] = useState<{ text: string; conflict: boolean } | null>(null) if (!state) return null const length = (meters: number) => formatLinearMeasurement(meters, unit, metricNotation) - const report = (result: RoomConstructionResult, unchanged = 'Nothing to change.') => + const report = ( + result: RoomConstructionResult, + unchanged = i18n._({ id: 'editor.panels.roomConstructionRows.nothingToChange', message: 'Nothing to change.' }), + ) => setMessage( result.status === 'conflict' ? { text: result.message, conflict: true } @@ -525,14 +573,14 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { const toggle = (part: RoomConstructionPart) => () => setExpanded((current) => (current === part ? null : part)) const drawnFloor = roomDrawnFloor(nodes, zoneId) - const partSwitch = (part: RoomConstructionPart, label: string) => { + const partSwitch = (part: RoomConstructionPart) => { const { state: presence, actions } = state[part] if (part === 'walls') { const walls = wallsSwitch(state) return ( span]:translate-x-2!', @@ -561,7 +609,7 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { data-drawn-floor-switch > {!actions.add && ( - {DRAWN_FLOOR_SWITCH_HINT} + {i18n._({ + id: 'editor.panels.roomConstructionRows.drawnFloorSwitchHint', + message: 'This floor is a drawn slab: select it to edit or delete it.', + })} )} @@ -580,7 +631,11 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { const on = presence !== 'absent' return ( @@ -595,17 +650,40 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { const builtOn = roomBuiltOn(nodes, zoneId) const floorMeta = state.floor.state === 'partial' - ? `Partial · ${Math.round(state.floor.coverage * 100)}% covered` + ? i18n._({ + id: 'editor.panels.roomConstructionRows.partialCovered', + message: 'Partial · {coverage, number, percent} covered', + values: { coverage: state.floor.coverage }, + }) : drawnSlab?.type === 'slab' - ? `Drawn slab at ${length(drawnSlab.elevation)}` + ? i18n._({ + id: 'editor.panels.roomConstructionRows.drawnSlabAt', + message: 'Drawn slab at {height}', + values: { height: length(drawnSlab.elevation) }, + }) : builtOn && builtOn.current.key !== null ? separateFloorSummary( - { ...builtOn, offset: separateFloorOffset(nodes, zoneId) }, + { + current: { name: floorChoiceName(nodes, builtOn.current, i18n) }, + shared: builtOn.shared && { name: floorChoiceName(nodes, builtOn.shared, i18n) }, + offset: separateFloorOffset(nodes, zoneId), + }, length, + i18n, ) : Math.abs(floorElevation) < 0.005 - ? 'At floor level' - : `${length(Math.abs(floorElevation))} ${floorElevation > 0 ? 'up' : 'down'}` + ? i18n._({ id: 'editor.panels.roomConstructionRows.atFloorLevel', message: 'At floor level' }) + : floorElevation > 0 + ? i18n._({ + id: 'editor.panels.roomConstructionRows.floorUp', + message: '{distance} up', + values: { distance: length(Math.abs(floorElevation)) }, + }) + : i18n._({ + id: 'editor.panels.roomConstructionRows.floorDown', + message: '{distance} down', + values: { distance: length(Math.abs(floorElevation)) }, + }) // A room on its own floor moves within its plate's range (on the ground, its foundation's). const ownBounds = ownFloorElevationBounds(nodes, zoneId) const floorBase = ownBounds ? roomFloorBase(nodes, zoneId) : 0 @@ -619,27 +697,40 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { const shared = sharingRoomNames(nodes, zoneId, state.walls.sharedWallIds) const wallsMeta = state.walls.state === 'partial' - ? `Partial · ${count(wallIds.length, 'wall')}, ${count(openSides, 'open side')}` - : count(wallIds.length, 'wall') + ? i18n._({ + id: 'editor.panels.roomConstructionRows.partialWalls', + message: + 'Partial · {walls, plural, one {# wall} other {# walls}}, {openSides, plural, one {# open side} other {# open sides}}', + values: { walls: wallIds.length, openSides }, + }) + : i18n._({ + id: 'editor.panels.roomConstructionRows.wallCount', + message: '{walls, plural, one {# wall} other {# walls}}', + values: { walls: wallIds.length }, + }) const locked = roomOutsideFacesLocked(nodes, wallIds) const ceilingHeight = state.ceiling.ceilingId ? roomInsideHeight(room, nodes).ceiling : null const ceilingMeta = state.ceiling.state === 'partial' - ? 'Hand-drawn ceiling' + ? i18n._({ id: 'editor.panels.roomConstructionRows.handDrawnCeiling', message: 'Hand-drawn ceiling' }) : ceilingHeight !== null - ? `At ${length(ceilingHeight)}` - : 'Flat, at wall height' + ? i18n._({ + id: 'editor.panels.roomConstructionRows.ceilingAt', + message: 'At {height}', + values: { height: length(ceilingHeight) }, + }) + : i18n._({ id: 'editor.panels.roomConstructionRows.flatAtWallHeight', message: 'Flat, at wall height' }) return (
-

Built with

+

{i18n._({ id: 'editor.panels.roomConstructionRows.builtWith', message: 'Built with' })}

} @@ -656,44 +747,70 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { - + {wallIds.length} {state.walls.state === 'partial' && state.walls.actions.add && ( - + )} {shared.length > 0 && (

- Turning walls off keeps the ones shared with {[...new Set(shared)].join(', ')}. + {i18n._({ + id: 'editor.panels.roomConstructionRows.sharedWallsNote', + message: 'Turning walls off keeps the ones shared with {rooms}.', + values: { + rooms: formatList( + [ + ...new Set( + shared.map((name) => + name + ? displayRoomName(name, i18n) + : i18n._({ + id: 'editor.panels.roomConstructionRows.theNextRoom', + message: 'the next room', + }), + ), + ), + ], + locale, + ), + }, + })}

)} {locked !== null && (
- Keep outside dimensions + {i18n._({ id: 'editor.panels.roomConstructionRows.keepOutsideDimensions', message: 'Keep outside dimensions' })} - Walls thicken inward, so the building's outer size stays the same. + {i18n._({ id: 'editor.panels.roomConstructionRows.wallsThickenInwardSoTheBuilding', message: 'Walls thicken inward, so the building\'s outer size stays the same.' })} report(next ? lockRoomOutsideFaces(zoneId) : unlockRoomOutsideFaces(wallIds)) @@ -703,40 +820,40 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { )} {ceilingHeight !== null && ( - + {length(ceilingHeight)} )} {state.ceiling.ceilingId && ( - + )} {state.ceiling.ceilingId && } {(state.ceiling.actions.useExisting || state.ceiling.actions.replace) && (
- Hand-drawn ceiling + {i18n._({ id: 'editor.panels.roomConstructionRows.handDrawnCeiling', message: 'Hand-drawn ceiling' })} {state.ceiling.actions.useExisting && ( )} {state.ceiling.actions.replace && ( @@ -745,7 +862,7 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { onClick={() => report(replaceRoomCeiling(zoneId))} type="button" > - Replace + {i18n._({ id: 'editor.panels.roomConstructionRows.replace', message: 'Replace' })} )}
@@ -766,7 +883,7 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { startMezzanineDraft(zoneId, shape)} />
diff --git a/packages/editor/src/components/ui/panels/room-name-options.test.ts b/packages/editor/src/components/ui/panels/room-name-options.test.ts index 6846b426ff..7d02ed6863 100644 --- a/packages/editor/src/components/ui/panels/room-name-options.test.ts +++ b/packages/editor/src/components/ui/panels/room-name-options.test.ts @@ -38,11 +38,13 @@ describe('roomNameOptions', () => { }) }) - test('free text is always offered first unless it is exactly a name', () => { + test('free text is offered first unless it is exactly a name, which is then active', () => { const { typed, options } = roomNameOptions('Wine cellar', SECTIONS) - expect(typed).toEqual({ kind: 'typed', value: 'Wine cellar', match: null }) + expect(typed).toEqual({ kind: 'typed', value: 'Wine cellar', label: 'Wine cellar', match: null }) expect(options).toEqual([typed!]) - expect(roomNameOptions('Kitchen', SECTIONS).typed).toBeNull() + const exact = roomNameOptions('Kitchen', SECTIONS) + expect(exact.typed).toBeNull() + expect(exact.options[exact.active]).toMatchObject({ kind: 'suggestion', value: 'Kitchen' }) // A case-only difference still offers the text as typed. expect(roomNameOptions('kitchen', SECTIONS).typed?.value).toBe('kitchen') }) diff --git a/packages/editor/src/components/ui/panels/room-name-options.ts b/packages/editor/src/components/ui/panels/room-name-options.ts index fdcf6596d7..51a48d6e7a 100644 --- a/packages/editor/src/components/ui/panels/room-name-options.ts +++ b/packages/editor/src/components/ui/panels/room-name-options.ts @@ -1,8 +1,18 @@ -import type { RoomNameSection } from '../../../lib/room-name-catalog' +import { useScene } from '@pascal-app/core' +import { numberedRoomName, type RoomNameSection } from '../../../lib/room-name-catalog' +import { + type DescriptorTranslator, + displayRoomName, + editedRoomName, +} from '../../../lib/room-name-messages' +import { renameRoom } from '../../../lib/room-structure-commands' export type RoomNameOption = { kind: 'typed' | 'suggestion' + /** What a pick stores: the English catalog name, or the typed text as written. */ value: string + /** What the row shows and the search matches, in the UI language. */ + label: string match: { start: number; end: number } | null } @@ -11,25 +21,36 @@ export type RoomNameGroup = { id: string; title: string; options: RoomNameOption /** * The room-name combobox's rows. Until the user types, every section is listed * whole — the field's current name is not a filter. Typing keeps, in every - * section, the names containing the text (ignoring case and surrounding - * spaces), drops the sections left empty, and puts the typed text first as its - * own row unless it already is one of the names. `options` is the flat order - * the keyboard walks: the typed row, then each group's rows. + * section, the names whose label or English name contains the text (ignoring + * case and surrounding spaces), drops the sections left empty, and puts the + * typed text first as its own row unless it is exactly an English catalog + * name. `options` is the flat order the keyboard walks: the typed row, then + * each group's rows. */ export function roomNameOptions( typed: string, sections: readonly RoomNameSection[], filtering = true, -): { typed: RoomNameOption | null; groups: RoomNameGroup[]; options: RoomNameOption[] } { +): { + typed: RoomNameOption | null + groups: RoomNameGroup[] + options: RoomNameOption[] + /** The row Enter picks after typing: an exact name, else the first suggestion. */ + active: number +} { const query = filtering ? typed.trim().toLowerCase() : '' const groups = sections.flatMap((section) => { - const options = section.names.flatMap((value): RoomNameOption[] => { - const start = query ? value.toLowerCase().indexOf(query) : -1 - if (query && start < 0) return [] + const options = section.names.flatMap((value, index): RoomNameOption[] => { + const label = section.labels?.[index] ?? value + // The label is what is shown and highlighted; the English name matches too, + // for packs that have not translated every name yet. + const start = query ? label.toLowerCase().indexOf(query) : -1 + if (query && start < 0 && !value.toLowerCase().includes(query)) return [] return [ { kind: 'suggestion', value, + label, match: start >= 0 ? { start, end: start + query.length } : null, }, ] @@ -38,10 +59,56 @@ export function roomNameOptions( }) const known = sections.some((section) => section.names.includes(typed)) const typedRow: RoomNameOption | null = - query && !known ? { kind: 'typed', value: typed, match: null } : null + query && !known ? { kind: 'typed', value: typed, label: typed, match: null } : null + const options = [...(typedRow ? [typedRow] : []), ...groups.flatMap((group) => group.options)] + const suggestions = options.filter((option) => option.kind === 'suggestion') + // Labels can be shared ("Salon" for both Living room and Lounge): a label is + // only an exact match when no other name has it. + const byLabel = suggestions.filter((option) => option.label.toLowerCase() === query) + const exactRow = + suggestions.find((option) => option.value.toLowerCase() === query) ?? + (byLabel.length === 1 ? byLabel[0] : undefined) + const exact = exactRow ? options.indexOf(exactRow) : -1 return { typed: typedRow, groups, - options: [...(typedRow ? [typedRow] : []), ...groups.flatMap((group) => group.options)], + options, + active: exact >= 0 ? exact : typedRow && options.length > 1 ? 1 : 0, } } + +/** + * The room panel's commit of its edited field, on blur or Enter (unless Enter + * picks a suggestion). `committed` is what the field last committed or showed; + * text still equal to it writes nothing. + */ +export function commitRoomNameEdit( + zoneId: string, + name: string, + committed: string, + text: string, + i18n: DescriptorTranslator, +) { + if (text === committed) return + const stored = editedRoomName(name, text, i18n) + if (stored !== name) renameRoom(zoneId, stored) +} + +/** + * The room panel's pick of a row: a click, or Enter on a suggestion. A suggestion stores its English catalog name, + * numbered against the level's other rooms, in every UI language; the typed + * row stores the text exactly as typed. Returns what the field shows after it. + */ +export function commitRoomNamePick( + zoneId: string, + name: string, + option: RoomNameOption, + i18n: DescriptorTranslator, +): string { + const stored = + option.kind === 'typed' + ? option.value + : numberedRoomName(useScene.getState().nodes, zoneId, option.value) + if (stored !== name) renameRoom(zoneId, stored) + return displayRoomName(stored, i18n) +} diff --git a/packages/editor/src/components/ui/panels/room-openings-field.tsx b/packages/editor/src/components/ui/panels/room-openings-field.tsx index f4bf5a75ab..3f99bbf048 100644 --- a/packages/editor/src/components/ui/panels/room-openings-field.tsx +++ b/packages/editor/src/components/ui/panels/room-openings-field.tsx @@ -1,6 +1,6 @@ 'use client' -import { area, useScene } from '@pascal-app/core' +import { area, useScene, usePascalI18n } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { @@ -15,8 +15,6 @@ import { formatAreaLabel } from '../../../lib/measurements' import { pill } from './room-construction-rows' import { ShapeChoice } from './shape-choice' -const count = (n: number) => `${n} ${n === 1 ? 'opening' : 'openings'}` - /** * A room's openings through its floor (or its ceiling), inside the row's * settings. The floor has "Edit floor": a session that outlines the openings @@ -31,6 +29,7 @@ export function RoomOpeningsField({ zoneId: string surface: OpeningSurface }) { + const i18n = usePascalI18n() const nodes = useScene((scene) => scene.nodes) const unit = useViewer((s) => s.unit) const readOnly = useScene((scene) => scene.readOnly) @@ -38,40 +37,46 @@ export function RoomOpeningsField({ () => roomSurfaceOpenings(nodes, zoneId, surface), [nodes, zoneId, surface], ) + const openingCount = i18n._({ + id: 'editor.panels.roomOpeningsField.openings', + // {number} rather than #: counts are written without grouping, as before ("1000 openings"). + message: '{count, plural, =0 {No openings} one {{number} opening} other {{number} openings}}', + values: { count: openings.length, number: String(openings.length) }, + }) const editing = useFloorEditSession((s) => surface === 'floor' && s.session?.zoneId === zoneId) const cut = ( startOpeningDraft(zoneId, surface, shape)} /> ) if (surface === 'ceiling') return (
- {openings.length > 0 && {count(openings.length)}} + {openings.length > 0 && {openingCount}} {cut}
) if (!editing) return (
- {openings.length ? count(openings.length) : 'No openings'} + {openingCount}
) return (
{openings.length === 0 ? ( -

No openings yet. Cut one for a stair void or a hatch.

+

{i18n._({ id: 'editor.panels.roomOpeningsField.noOpeningsYetCutOneFor', message: 'No openings yet. Cut one for a stair void or a hatch.' })}

) : ( openings.map((opening, index) => (
diff --git a/packages/editor/src/components/ui/panels/room-panel.tsx b/packages/editor/src/components/ui/panels/room-panel.tsx index 334c571cd3..411be674f3 100644 --- a/packages/editor/src/components/ui/panels/room-panel.tsx +++ b/packages/editor/src/components/ui/panels/room-panel.tsx @@ -4,10 +4,11 @@ import { type AnyNode, type AnyNodeId, area, - getLevelDisplayName, runAsSingleSceneHistoryStep, useScene, - type ZoneNode, + type ZoneNode, usePascalI18n, + type PascalI18n, + useLocale, } from '@pascal-app/core' import { setRoomFloorScript } from '@pascal-app/core/agent-operations' import { useViewer } from '@pascal-app/viewer' @@ -23,12 +24,11 @@ import { mezzanineElevationBounds } from '../../../lib/room-handle-drag' import type { RoomSelectionRecord } from '../../../lib/room-selection' import { baseRoomName, - numberedRoomName, type RoomNameSection, roomNameSections, roomNameUseCount, } from '../../../lib/room-name-catalog' -import { renameRoom, requestRoomDeletion } from '../../../lib/room-structure-commands' +import { requestRoomDeletion } from '../../../lib/room-structure-commands' import { addZoneToNewUnit, assignZoneToUnit, zoneUnits } from '../../../lib/units' import { cn } from '../../../lib/utils' import useEditor from '../../../store/use-editor' @@ -53,23 +53,49 @@ import { RoomConstructionRows, roomInsideHeight, } from './room-construction-rows' -import { type RoomNameOption, roomNameOptions } from './room-name-options' +import { + commitRoomNameEdit, + commitRoomNamePick, + type RoomNameOption, + roomNameOptions, +} from './room-name-options' +import { localizedLevelName } from '../../../i18n/level-name' +import { messageWithSlot } from '../../../i18n/message-slot' +import { + displayRoomName, + localizedRoomNameSections, +} from '../../../lib/room-name-messages' /** "Room · Ground floor", "Mezzanine · in Kitchen", "Room · not enclosed". */ export function roomKicker( nodes: Record, zoneId: string, + i18n: PascalI18n, + locale: string, options: { mezzanine?: boolean; open?: boolean } = {}, ): string { const zone = nodes[zoneId] if (options.mezzanine && zone?.type === 'zone') { const host = zone.hostZoneId ? nodes[zone.hostZoneId] : undefined const hostName = host?.type === 'zone' ? host.name?.trim() : '' - return hostName ? `Mezzanine · in ${hostName}` : 'Mezzanine' + return hostName + ? i18n._({ + id: 'editor.panels.roomPanel.kickerMezzanineIn', + message: 'Mezzanine · in {room}', + values: { room: displayRoomName(hostName, i18n) }, + }) + : i18n._({ id: 'editor.panels.roomPanel.mezzanine', message: 'Mezzanine' }) } - if (options.open) return 'Room · not enclosed' + if (options.open) + return i18n._({ id: 'editor.panels.roomPanel.kickerNotEnclosed', message: 'Room · not enclosed' }) const level = zone?.parentId ? nodes[zone.parentId] : undefined - return level?.type === 'level' ? `Room · ${getLevelDisplayName(level)}` : 'Room' + return level?.type === 'level' + ? i18n._({ + id: 'editor.panels.roomPanel.kickerLevel', + message: 'Room · {level}', + values: { level: localizedLevelName(level, i18n, locale) }, + }) + : i18n._({ id: 'editor.panels.roomPanel.room', message: 'Room' }) } export function RoomPanel({ @@ -79,8 +105,15 @@ export function RoomPanel({ room: RoomSelectionRecord onClose?: () => void }) { + const i18n = usePascalI18n() + const locale = useLocale() const nodes = useScene((state) => state.nodes) - const sections = useMemo(() => roomNameSections(nodes, room.zoneId), [nodes, room.zoneId]) + const catalogVersion = i18n.getVersion() + const sections = useMemo( + () => localizedRoomNameSections(roomNameSections(nodes, room.zoneId), i18n), + // biome-ignore lint/correctness/useExhaustiveDependencies: re-translate on a catalog change + [i18n, catalogVersion, nodes, room.zoneId], + ) const name = useScene((state) => state.nodes[room.zoneId as AnyNodeId]?.name ?? '') const visible = useInteractionScope( (state) => resolveOverlayPolicy(state.scope).conflictingControls === 'shown', @@ -89,9 +122,9 @@ export function RoomPanel({ const mezzanine = !!room.mezzanine return ( @@ -125,17 +158,27 @@ export function RoomPanel({ * needs no closed outline — its name, its unit, deleting it. */ export function OpenRoomPanel({ zoneId, onClose }: { zoneId: string; onClose: () => void }) { + const i18n = usePascalI18n() + const locale = useLocale() const nodes = useScene((state) => state.nodes) - const sections = useMemo(() => roomNameSections(nodes, zoneId), [nodes, zoneId]) + const catalogVersion = i18n.getVersion() + const sections = useMemo( + () => localizedRoomNameSections(roomNameSections(nodes, zoneId), i18n), + // biome-ignore lint/correctness/useExhaustiveDependencies: re-translate on a catalog change + [i18n, catalogVersion, nodes, zoneId], + ) const name = useScene((state) => state.nodes[zoneId as AnyNodeId]?.name ?? '') const unit = useViewer((s) => s.unit) const zone = nodes[zoneId as AnyNodeId] if (zone?.type !== 'zone') return null return ( @@ -151,8 +194,11 @@ export function OpenRoomPanel({ zoneId, onClose }: { zoneId: string; onClose: () >

- {formatAreaLabel(area([{ outer: zone.polygon, holes: zone.holes }]), unit, 2)} · Close - its walls to build its floor and ceiling. + {i18n._({ + id: 'editor.panels.roomPanel.openRoomArea', + message: '{area} · Close its walls to build its floor and ceiling.', + values: { area: formatAreaLabel(area([{ outer: zone.polygon, holes: zone.holes }]), unit, 2) }, + })}

@@ -170,16 +216,17 @@ export function OpenRoomPanel({ zoneId, onClose }: { zoneId: string; onClose: () /** "Unit: Flat A ›" — which unit the room belongs to; change it, add it to one, or take it out. */ export function RoomUnitRow({ zoneId }: { zoneId: string }) { + const i18n = usePascalI18n() const nodes = useScene((scene) => scene.nodes) const { buildingId, units, current } = useMemo(() => zoneUnits(nodes, zoneId), [nodes, zoneId]) if (!buildingId) return null return (
- Unit + {i18n._({ id: 'editor.panels.roomPanel.unit', message: 'Unit' })} @@ -207,17 +254,23 @@ export function RoomUnitRow({ zoneId }: { zoneId: string }) { className="size-2 shrink-0 rounded-full" style={{ backgroundColor: unit.color }} /> - {unit.name || 'Unit'} + {unit.name || i18n._({ id: 'editor.panels.roomPanel.unit', message: 'Unit' })} {unit.id === current?.id && } ))} {units.length > 0 && } addZoneToNewUnit(zoneId as ZoneNode['id'])}> - New unit + {i18n._({ id: 'editor.panels.roomPanel.newUnit', message: 'New unit' })} {current && ( assignZoneToUnit(zoneId as ZoneNode['id'], null)}> - Remove from {current.name || 'unit'} + {current.name + ? i18n._({ + id: 'editor.panels.roomPanel.removeFromUnitNamed', + message: 'Remove from {unit}', + values: { unit: current.name }, + }) + : i18n._({ id: 'editor.panels.roomPanel.removeFromUnit', message: 'Remove from unit' })} )} @@ -231,6 +284,7 @@ export function RoomUnitRow({ zoneId }: { zoneId: string }) { * with its code to read and a way to take it off, back to the floor's own finish. */ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) { + const i18n = usePascalI18n() const sha = useScene((scene) => { const zone = scene.nodes[zoneId as AnyNodeId] return zone?.type === 'zone' ? zone.floor?.script?.script : undefined @@ -238,12 +292,16 @@ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) { const [code, setCode] = useState<{ sha: string; text: string } | null>(null) const [open, setOpen] = useState(false) if (!sha) return null + const unreadable = i18n._({ + id: 'editor.panels.scriptUnreadable', + message: 'The script could not be read.', + }) const toggleCode = () => { setOpen((shown) => !shown) if (code?.sha === sha) return finishScriptCode(sha).then( - (text) => setCode({ sha, text: text ?? 'The script could not be read.' }), - () => setCode({ sha, text: 'The script could not be read.' }), + (text) => setCode({ sha, text: text ?? unreadable }), + () => setCode({ sha, text: unreadable }), ) } const remove = () => { @@ -260,38 +318,37 @@ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) { return (
- Scripted finish + {i18n._({ id: 'editor.panels.roomPanel.scriptedFinish', message: 'Scripted finish' })}
{open && (
-          {code?.sha === sha ? code.text : 'Loading…'}
+          {code?.sha === sha ? code.text : i18n._({ id: 'editor.panels.roomPanel.loading', message: 'Loading…' })}
         
)}
) } -const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}` - /** "2 doors · 3 windows" (the level is in the kicker). */ function RoomSubline({ room }: { room: RoomSelectionRecord }) { - const text = useScene((scene) => { + const i18n = usePascalI18n() + const counts = useScene((scene) => { const walls = new Set(room.boundaryWallIds) let doors = 0 let windows = 0 @@ -302,7 +359,14 @@ function RoomSubline({ room }: { room: RoomSelectionRecord }) { if (node.type === 'door') doors++ else windows++ } - return `${count(doors, 'door')} · ${count(windows, 'window')}` + return `${doors}:${windows}` + }) + const [doors, windows] = counts.split(':').map(Number) + const text = i18n._({ + id: 'editor.panels.roomPanel.openingCounts', + message: + '{doors, plural, one {# door} other {# doors}} · {windows, plural, one {# window} other {# windows}}', + values: { doors, windows }, }) return (

@@ -323,6 +387,7 @@ function ringPerimeter(ring: readonly (readonly [number, number])[]) { /** Area, perimeter and inside height, in the user's units. */ function RoomStats({ room }: { room: RoomSelectionRecord }) { + const i18n = usePascalI18n() const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) const nodes = useScene((scene) => scene.nodes) @@ -333,10 +398,10 @@ function RoomStats({ room }: { room: RoomSelectionRecord }) { : 0 const { height } = roomInsideHeight(room, nodes) const stats = [ - { label: 'Area', value: formatAreaLabel(room.area, unit, 2) }, - { label: 'Perimeter', value: formatLinearMeasurement(perimeter, unit, metricNotation) }, + { label: i18n._({ id: 'editor.panels.roomPanel.area', message: 'Area' }), value: formatAreaLabel(room.area, unit, 2) }, + { label: i18n._({ id: 'editor.panels.roomPanel.perimeter', message: 'Perimeter' }), value: formatLinearMeasurement(perimeter, unit, metricNotation) }, { - label: 'Height', + label: i18n._({ id: 'editor.panels.roomPanel.height', message: 'Height' }), value: height === null ? '—' : formatLinearMeasurement(height, unit, metricNotation), }, ] @@ -363,6 +428,7 @@ function RoomStats({ room }: { room: RoomSelectionRecord }) { * it when there is headroom. No walls of its own, so no walls row. */ function MezzanineRows({ room }: { room: RoomSelectionRecord }) { + const i18n = usePascalI18n() const zoneId = room.zoneId const nodes = useScene((scene) => scene.nodes) const unit = useViewer((s) => s.unit) @@ -385,12 +451,12 @@ function MezzanineRows({ room }: { room: RoomSelectionRecord }) { setExpanded((current) => (current === part ? null : part)) return (

-

Built with

+

{i18n._({ id: 'editor.panels.roomPanel.builtWith', message: 'Built with' })}

- Add stairs + {i18n._({ id: 'editor.panels.roomPanel.addStairs', message: 'Add stairs' })} } expanded={false} icon="/icons/stairs.webp" - label="Stairs" + label={i18n._({ id: 'editor.panels.roomPanel.stairs', message: 'Stairs' })} meta="Up from the floor below" on onToggleExpanded={() => {}} @@ -424,22 +490,26 @@ function MezzanineRows({ room }: { room: RoomSelectionRecord }) { {hasCeiling ? ( - + ) : undefined} @@ -459,6 +529,7 @@ function MezzanineRows({ room }: { room: RoomSelectionRecord }) { * the label tint, never a finish (finishes are painted). */ function RoomColorDot({ zoneId }: { zoneId: string }) { + const i18n = usePascalI18n() const color = useScene((state) => { const zone = state.nodes[zoneId as AnyNodeId] return zone?.type === 'zone' ? zone.color : undefined @@ -466,7 +537,7 @@ function RoomColorDot({ zoneId }: { zoneId: string }) { return ( useScene.getState().updateNode(zoneId as AnyNodeId, { color: next })} side="bottom" /> @@ -492,30 +563,33 @@ function RoomName({ sections: readonly RoomNameSection[] mezzanine: boolean }) { + const i18n = usePascalI18n() const nodes = useScene((state) => state.nodes) - const [value, setValue] = useState(name) + // The field shows the name in the UI language; the scene keeps what was stored. + const display = displayRoomName(name, i18n) + const [value, setValue] = useState(display) const [open, setOpen] = useState(false) const [typing, setTyping] = useState(false) const [active, setActive] = useState(-1) - const valueRef = useRef(name) + const valueRef = useRef(display) // Enter/click commit and then blur, and blur commits too; this keeps it one rename. - const committedRef = useRef(name) - const originalRef = useRef(name) + const committedRef = useRef(display) + const originalRef = useRef(display) const inputRef = useRef(null) const listId = useId() const { typed: typedRow, groups, options } = roomNameOptions(value, sections, typing) const expanded = open && options.length > 0 const activeId = expanded && options[active] ? `${listId}-${active}` : undefined - const current = baseRoomName(value).toLowerCase() + const current = baseRoomName(name).toLowerCase() // A rename from elsewhere (undo, a collaborator, the plan) lands in the field // unless the user is editing it (the field stays mounted across renames). useEffect(() => { if (document.activeElement === inputRef.current) return - valueRef.current = name - committedRef.current = name - setValue(name) - }, [name]) + valueRef.current = display + committedRef.current = display + setValue(display) + }, [display]) // Opening and the arrow keys bring the active row into view; the pointer // only highlights (scrolling under it would move the row being clicked). @@ -541,7 +615,7 @@ function RoomName({ setTyping(false) setOpen(true) const all = roomNameOptions(valueRef.current, sections, false).options - const here = baseRoomName(valueRef.current).toLowerCase() + const here = baseRoomName(name).toLowerCase() scrollToActive.current = true setActive(Math.max(0, all.findIndex((option) => option.value.toLowerCase() === here))) } @@ -551,15 +625,14 @@ function RoomName({ } const commit = () => { const next = valueRef.current - if (next === committedRef.current) return + const committed = committedRef.current committedRef.current = next - renameRoom(zoneId, next) + commitRoomNameEdit(zoneId, name, committed, next, i18n) } const pick = (option: RoomNameOption) => { - const next = - option.kind === 'typed' ? option.value : numberedRoomName(nodes, zoneId, option.value) - type(next) - commit() + const shown = commitRoomNamePick(zoneId, name, option, i18n) + type(shown) + committedRef.current = shown setOpen(false) setTyping(false) // A pick is the answer: the field lets go. To fine-tune the name, click it @@ -583,7 +656,9 @@ function RoomName({ event.preventDefault() event.stopPropagation() const chosen = expanded ? options[active] : undefined - if (chosen) pick(chosen) + // Enter on the typed row commits the field, as blur does; only a click + // on it uses the text exactly as typed. + if (chosen?.kind === 'suggestion') pick(chosen) else inputRef.current?.blur() } else if (event.key === 'Escape') { event.preventDefault() @@ -624,10 +699,9 @@ function RoomName({ ) : used > 0 ? ( - {used} on this level - + {i18n._({ id: 'editor.panels.roomPanel.usedOnThisLevel', message: '{used} on this level', values: { used } })} ) : null}
) @@ -645,19 +719,27 @@ function RoomName({ )} data-guide-target="room-name" data-room-name-combobox - title={name.trim() ? `${name.trim()} · Rename` : 'Rename'} + title={ + name.trim() + ? i18n._({ + id: 'editor.panels.roomPanel.renameNamed', + message: '{name} · Rename', + values: { name: displayRoomName(name, i18n) }, + }) + : i18n._({ id: 'editor.panels.roomPanel.rename', message: 'Rename' }) + } > 1 ? 1 : 0) + setActive(roomNameOptions(event.target.value, sections, true).active) }} onFocus={() => { originalRef.current = valueRef.current @@ -689,7 +770,7 @@ function RoomName({ }} /> ))}
diff --git a/packages/editor/src/components/ui/panels/wall-region-list.tsx b/packages/editor/src/components/ui/panels/wall-region-list.tsx index 420f2dccbb..7969151015 100644 --- a/packages/editor/src/components/ui/panels/wall-region-list.tsx +++ b/packages/editor/src/components/ui/panels/wall-region-list.tsx @@ -4,7 +4,7 @@ import { type AnyNodeId, useLiveNodeOverrides, useScene, - type WallFaceRegion, + type WallFaceRegion, usePascalI18n, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Trash2 } from 'lucide-react' @@ -25,6 +25,7 @@ const ROW_ACTION_CLASS = /** The wall panel's "Paint regions" section: one row per region, select or delete. */ export function WallPaintRegionList({ wallId }: { wallId: string }) { + const i18n = usePascalI18n() const nodes = useScene((state) => state.nodes) const materials = useScene((state) => state.materials) const unit = useViewer((state) => state.unit) @@ -48,7 +49,7 @@ export function WallPaintRegionList({ wallId }: { wallId: string }) { return ( // Focus inside this list still lets Delete target the active region.
- +
{rows.map((row) => { const isActive = active?.wallId === wallId && active.regionId === row.id @@ -85,13 +86,13 @@ export function WallPaintRegionList({ wallId }: { wallId: string }) { ) })} @@ -327,7 +355,7 @@ export default function DoorPanel() { )} {!scripted && !isOpening && !isGarageDoor && ( - +
{DOOR_STYLES.map((style) => ( ))}
{!doorStyles.length && (

- Custom: the segments match no style. + {i18n._({ + id: 'node.door.panel.customTheSegmentsMatchNoStyle', + message: 'Custom: the segments match no style.', + })}

)}
)} - + - Xwall + X + + {i18n._({ id: 'node.door.panel.wall', message: 'wall' })} + } max={10} @@ -374,7 +408,7 @@ export default function DoorPanel() { } - label="Flip Side" + label={i18n._({ id: 'node.door.panel.flipSide', message: 'Flip Side' })} onClick={handleFlip} />
@@ -382,11 +416,11 @@ export default function DoorPanel() {
{showFoldSection && ( - +
- Panels + {i18n._({ id: 'node.door.panel.panels', message: 'Panels' })} handleUpdate({ leafCount: v === '2' ? 2 : 4 })} @@ -399,7 +433,7 @@ export default function DoorPanel() {
handleUpdate({ operationState: v / 100 })} @@ -413,24 +447,31 @@ export default function DoorPanel() { )} {showSlideSection && ( - +
- {doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'} + {doorType === 'pocket' + ? i18n._({ id: 'node.door.panel.pocket', message: 'Pocket' }) + : doorType === 'barn' + ? i18n._({ id: 'node.door.panel.rail', message: 'Rail' }) + : i18n._({ id: 'node.door.panel.panel', message: 'Panel' })} handleUpdate({ slideDirection: v })} options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, + { label: i18n._({ id: 'node.door.panel.left', message: 'Left' }), value: 'left' }, + { + label: i18n._({ id: 'node.door.panel.right', message: 'Right' }), + value: 'right', + }, ]} value={node.slideDirection ?? 'left'} />
handleUpdate({ operationState: v / 100 })} @@ -444,9 +485,9 @@ export default function DoorPanel() { )} {showGarageSection && ( - + handleUpdate({ operationState: v / 100 })} @@ -458,7 +499,7 @@ export default function DoorPanel() { /> {isSectionalGarageDoor && ( handleUpdate({ garagePanelCount: Math.round(v) })} @@ -474,14 +515,17 @@ export default function DoorPanel() { {scripted && } {!scripted && ( - + {limits && (

- Size is limited to the wall, including clearance for the opening frame. + {i18n._({ + id: 'node.door.panel.sizeIsLimitedToTheWall', + message: 'Size is limited to the wall, including clearance for the opening frame.', + })}

)} previewDoorUpdate('width', v)} @@ -495,7 +539,7 @@ export default function DoorPanel() { value={node.width} /> preview?.preview(heightUpdates(v))} @@ -512,7 +556,7 @@ export default function DoorPanel() { )} {showDoorShapeSection && ( - +
@@ -530,9 +574,15 @@ export default function DoorPanel() { }) } options={[ - { label: 'Rect', value: 'rectangle' }, - { label: 'Rounded', value: 'rounded' }, - { label: 'Arch', value: 'arch' }, + { + label: i18n._({ id: 'node.door.panel.rect', message: 'Rect' }), + value: 'rectangle', + }, + { + label: i18n._({ id: 'node.door.panel.rounded', message: 'Rounded' }), + value: 'rounded', + }, + { label: i18n._({ id: 'node.door.panel.arch', message: 'Arch' }), value: 'arch' }, ]} value={doorShape} /> @@ -545,15 +595,18 @@ export default function DoorPanel() { handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) } options={[ - { label: 'All', value: 'all' }, - { label: 'Individual', value: 'individual' }, + { label: i18n._({ id: 'node.door.panel.all', message: 'All' }), value: 'all' }, + { + label: i18n._({ id: 'node.door.panel.individual', message: 'Individual' }), + value: 'individual', + }, ]} value={openingRadiusMode} />
{openingRadiusMode === 'all' ? ( previewDoorUpdate('cornerRadius', v)} @@ -569,8 +622,8 @@ export default function DoorPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], + [i18n._({ id: 'node.cornerRadius.topLeft', message: 'Top Left' }), 0], + [i18n._({ id: 'node.cornerRadius.topRight', message: 'Top Right' }), 1], ].map(([label, index]) => ( )} previewDoorUpdate('openingRevealRadius', v)} @@ -608,7 +661,7 @@ export default function DoorPanel() { )} {doorShape === 'arch' && ( previewDoorUpdate('archHeight', v)} @@ -626,7 +679,9 @@ export default function DoorPanel() { )} {showOpeningShapeSection && ( - +
@@ -639,9 +694,15 @@ export default function DoorPanel() { }) } options={[ - { label: 'Rect', value: 'rectangle' }, - { label: 'Rounded', value: 'rounded' }, - { label: 'Arch', value: 'arch' }, + { + label: i18n._({ id: 'node.door.panel.rect', message: 'Rect' }), + value: 'rectangle', + }, + { + label: i18n._({ id: 'node.door.panel.rounded', message: 'Rounded' }), + value: 'rounded', + }, + { label: i18n._({ id: 'node.door.panel.arch', message: 'Arch' }), value: 'arch' }, ]} value={openingShape} /> @@ -654,15 +715,18 @@ export default function DoorPanel() { handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) } options={[ - { label: 'All', value: 'all' }, - { label: 'Individual', value: 'individual' }, + { label: i18n._({ id: 'node.door.panel.all', message: 'All' }), value: 'all' }, + { + label: i18n._({ id: 'node.door.panel.individual', message: 'Individual' }), + value: 'individual', + }, ]} value={openingRadiusMode} />
{openingRadiusMode === 'all' ? ( previewDoorUpdate('cornerRadius', v)} @@ -678,8 +742,8 @@ export default function DoorPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], + [i18n._({ id: 'node.cornerRadius.topLeft', message: 'Top Left' }), 0], + [i18n._({ id: 'node.cornerRadius.topRight', message: 'Top Right' }), 1], ].map(([label, index]) => ( )} previewDoorUpdate('openingRevealRadius', v)} @@ -717,7 +781,7 @@ export default function DoorPanel() { )} {openingShape === 'arch' && ( previewDoorUpdate('archHeight', v)} @@ -737,9 +801,9 @@ export default function DoorPanel() { {!isCutoutOnly && !scripted && ( <> {showFrameSection && ( - + handleUpdate({ frameThickness: v })} @@ -749,7 +813,7 @@ export default function DoorPanel() { value={node.frameThickness} /> handleUpdate({ frameDepth: v })} @@ -762,9 +826,11 @@ export default function DoorPanel() { )} {showContentPaddingSection && ( - + handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} @@ -774,7 +840,7 @@ export default function DoorPanel() { value={node.contentPadding[0]} /> handleUpdate({ contentPadding: [node.contentPadding[0], v] })} @@ -787,18 +853,24 @@ export default function DoorPanel() { )} {showSwingSection && ( - +
{supportsHingeSide && (
- Hinges Side + {i18n._({ id: 'node.door.panel.hingesSide', message: 'Hinges Side' })} handleUpdate({ hingesSide: v })} options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, + { + label: i18n._({ id: 'node.door.panel.left', message: 'Left' }), + value: 'left', + }, + { + label: i18n._({ id: 'node.door.panel.right', message: 'Right' }), + value: 'right', + }, ]} value={node.hingesSide} /> @@ -806,13 +878,19 @@ export default function DoorPanel() { )}
- Direction + {i18n._({ id: 'node.door.panel.direction', message: 'Direction' })} handleUpdate({ swingDirection: v })} options={[ - { label: 'Inward', value: 'inward' }, - { label: 'Outward', value: 'outward' }, + { + label: i18n._({ id: 'node.door.panel.inward', message: 'Inward' }), + value: 'inward', + }, + { + label: i18n._({ id: 'node.door.panel.outward', message: 'Outward' }), + value: 'outward', + }, ]} value={node.swingDirection} /> @@ -822,16 +900,19 @@ export default function DoorPanel() { )} {showThresholdSection && ( - + handleUpdate({ threshold: checked })} /> {node.threshold && (
handleUpdate({ thresholdHeight: v })} @@ -846,18 +927,18 @@ export default function DoorPanel() { )} {showHandleSection && ( - + {isSwingDoor && ( handleUpdate({ handle: checked })} /> )} {(node.handle || !isSwingDoor) && (
handleUpdate({ handleHeight: v })} @@ -869,13 +950,19 @@ export default function DoorPanel() { {supportsHandleSide && (
- Handle Side + {i18n._({ id: 'node.door.panel.handleSide', message: 'Handle Side' })} handleUpdate({ handleSide: v })} options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, + { + label: i18n._({ id: 'node.door.panel.left', message: 'Left' }), + value: 'left', + }, + { + label: i18n._({ id: 'node.door.panel.right', message: 'Right' }), + value: 'right', + }, ]} value={node.handleSide} /> @@ -887,21 +974,21 @@ export default function DoorPanel() { )} {showHardwareSection && ( - + handleUpdate({ doorCloser: checked })} /> handleUpdate({ panicBar: checked })} /> {node.panicBar && (
handleUpdate({ panicBarHeight: v })} @@ -916,7 +1003,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) @@ -925,7 +1012,11 @@ export default function DoorPanel() {
- Segment {i + 1} + {i18n._({ + id: 'node.segmentNumber', + message: 'Segment {number}', + values: { number: i + 1 }, + })}
@@ -937,15 +1028,24 @@ export default function DoorPanel() { handleUpdate({ segments: updated }) }} options={[ - { label: 'Panel', value: 'panel' }, - { label: 'Glass', value: 'glass' }, - { label: 'Empty', value: 'empty' }, + { + label: i18n._({ id: 'node.door.panel.panel', message: 'Panel' }), + value: 'panel', + }, + { + label: i18n._({ id: 'node.door.panel.glass', message: 'Glass' }), + value: 'glass', + }, + { + label: i18n._({ id: 'node.door.panel.empty', message: 'Empty' }), + value: 'empty', + }, ]} value={seg.type} /> setSegmentHeightRatio(i, v / 100)} @@ -956,7 +1056,7 @@ export default function DoorPanel() { /> { @@ -987,7 +1087,7 @@ export default function DoorPanel() { /> ))} { @@ -1007,7 +1107,7 @@ export default function DoorPanel() { {seg.type === 'panel' && (
{ @@ -1022,7 +1122,7 @@ export default function DoorPanel() { value={seg.panelInset} /> { @@ -1044,7 +1144,7 @@ export default function DoorPanel() {
{ const updated = [ ...node.segments, @@ -1063,7 +1163,7 @@ export default function DoorPanel() { {node.segments.length > 1 && ( handleUpdate({ segments: node.segments.slice(0, -1) })} /> )} @@ -1073,18 +1173,22 @@ export default function DoorPanel() { )} - + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.door.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.door.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.door.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/i18n/definition-messages.ts b/packages/nodes/src/i18n/definition-messages.ts index b0109693aa..c162a8c316 100644 --- a/packages/nodes/src/i18n/definition-messages.ts +++ b/packages/nodes/src/i18n/definition-messages.ts @@ -3,6 +3,473 @@ // (packages/editor/src/i18n/node-messages.ts); these descriptors exist so Lingui // extracts and translates those IDs. export const definitionMessages = [ + /* i18n */ { id: "editor.inspector.field.angle.g10zaw", message: "Angle" }, + /* i18n */ { id: "editor.inspector.field.armLengthM.1jclqgw", message: "Arm length m" }, + /* i18n */ { id: "editor.inspector.field.attachmentSide.6n9s5v", message: "Attachment side" }, + /* i18n */ { id: "editor.inspector.field.autoHangers.10554ka", message: "Auto hangers" }, + /* i18n */ { id: "editor.inspector.field.autoMiterCorners.zxr5lt", message: "Auto miter corners" }, + /* i18n */ { id: "editor.inspector.field.autoSpan.1gas9r6", message: "Auto span" }, + /* i18n */ { id: "editor.inspector.field.backRatio.1ughd99", message: "Back ratio" }, + /* i18n */ { id: "editor.inspector.field.baseHeight.1hs4zdf", message: "Base height" }, + /* i18n */ { id: "editor.inspector.field.baseStyle.24vty5", message: "Base style" }, + /* i18n */ { id: "editor.inspector.field.beamHeight.1kq0oh1", message: "Beam height" }, + /* i18n */ { id: "editor.inspector.field.beamWidth.1q1mzbe", message: "Beam width" }, + /* i18n */ { id: "editor.inspector.field.bodyShape.arvkuw", message: "Body shape" }, + /* i18n */ { id: "editor.inspector.field.bracketStyle.1vvoptm", message: "Bracket style" }, + /* i18n */ { id: "editor.inspector.field.branchAngle.v56zme", message: "Branch angle" }, + /* i18n */ { id: "editor.inspector.field.cabHeight.1yn34mk", message: "Cab height" }, + /* i18n */ { id: "editor.inspector.field.canopyForm.1czzggp", message: "Canopy form" }, + /* i18n */ { id: "editor.inspector.field.cap.s0thej", message: "Cap" }, + /* i18n */ { id: "editor.inspector.field.capOverhang.kxxz9j", message: "Cap overhang" }, + /* i18n */ { id: "editor.inspector.field.capShape.17mbxhu", message: "Cap shape" }, + /* i18n */ { id: "editor.inspector.field.capThickness.u835q9", message: "Cap thickness" }, + /* i18n */ { id: "editor.inspector.field.carcassHeight.1aks0zy", message: "Carcass height" }, + /* i18n */ { id: "editor.inspector.field.cleanoutStyle.127zz6z", message: "Cleanout style" }, + /* i18n */ { id: "editor.inspector.field.collarDiameter.1d5k28r", message: "Collar diameter" }, + /* i18n */ { id: "editor.inspector.field.collarHeight.1dqwsrf", message: "Collar height" }, + /* i18n */ { id: "editor.inspector.field.collarShape.1gacv11", message: "Collar shape" }, + /* i18n */ { id: "editor.inspector.field.collarWidth.qvc9ik", message: "Collar width" }, + /* i18n */ { id: "editor.inspector.field.columns.1sy73ks", message: "Columns" }, + /* i18n */ { id: "editor.inspector.field.conicalFullCircle.1pgkpz5", message: "Conical full circle" }, + /* i18n */ { id: "editor.inspector.field.connectionMode.1nty478", message: "Connection mode" }, + /* i18n */ { id: "editor.inspector.field.connectionOffset.2m70s8", message: "Connection offset" }, + /* i18n */ { id: "editor.inspector.field.cornerBevel.16iniho", message: "Corner bevel" }, + /* i18n */ { id: "editor.inspector.field.coveringType.95yd3k", message: "Covering type" }, + /* i18n */ { id: "editor.inspector.field.cricketHeight.pok31n", message: "Cricket height" }, + /* i18n */ { id: "editor.inspector.field.cricketLength.i2aht8", message: "Cricket length" }, + /* i18n */ { id: "editor.inspector.field.cricketSide.1xnwil7", message: "Cricket side" }, + /* i18n */ { id: "editor.inspector.field.cricketStyle.10j0lat", message: "Cricket style" }, + /* i18n */ { id: "editor.inspector.field.curb.5xi2ol", message: "Curb" }, + /* i18n */ { id: "editor.inspector.field.curbHeight.1yn97sg", message: "Curb height" }, + /* i18n */ { id: "editor.inspector.field.curveOffset.l4xhvr", message: "Curve offset" }, + /* i18n */ { id: "editor.inspector.field.damperAngle.h5fxn7", message: "Damper angle" }, + /* i18n */ { id: "editor.inspector.field.deckThickness.4jmqze", message: "Deck thickness" }, + /* i18n */ { id: "editor.inspector.field.defaultLevelId.xmrjzp", message: "Default level id" }, + /* i18n */ { id: "editor.inspector.field.depth.1lu11u2", message: "Depth" }, + /* i18n */ { id: "editor.inspector.field.diameter.s3robe", message: "Diameter" }, + /* i18n */ { id: "editor.inspector.field.diameter2.pm6cl4", message: "Diameter2" }, + /* i18n */ { id: "editor.inspector.field.doorDurationMs.13nx909", message: "Door duration ms" }, + /* i18n */ { id: "editor.inspector.field.doorHeight.92r9k4", message: "Door height" }, + /* i18n */ { id: "editor.inspector.field.doorPanelStyle.2qog4q", message: "Door panel style" }, + /* i18n */ { id: "editor.inspector.field.doorStyle.gerpos", message: "Door style" }, + /* i18n */ { id: "editor.inspector.field.doorWidth.sy732l", message: "Door width" }, + /* i18n */ { id: "editor.inspector.field.downspoutEnabled.1gn00nv", message: "Downspout enabled" }, + /* i18n */ { id: "editor.inspector.field.downspoutPosition.fxcsop", message: "Downspout position" }, + /* i18n */ { id: "editor.inspector.field.ductMaterial.i8eonq", message: "Duct material" }, + /* i18n */ { id: "editor.inspector.field.dutchGabletRake.6paydp", message: "Dutch gablet rake" }, + /* i18n */ { id: "editor.inspector.field.dutchHipHeightRatio.1etnwgg", message: "Dutch hip height ratio" }, + /* i18n */ { id: "editor.inspector.field.dutchHipWidthRatio.1dopfvb", message: "Dutch hip width ratio" }, + /* i18n */ { id: "editor.inspector.field.dutchTopRakeThickness.1w9khxt", message: "Dutch top rake thickness" }, + /* i18n */ { id: "editor.inspector.field.dutchWaistLengthRatio.agtoxw", message: "Dutch waist length ratio" }, + /* i18n */ { id: "editor.inspector.field.dwellMs.1lkk0o5", message: "Dwell ms" }, + /* i18n */ { id: "editor.inspector.field.edgeInset.1obeimx", message: "Edge inset" }, + /* i18n */ { id: "editor.inspector.field.elevation.4jbp44", message: "Elevation" }, + /* i18n */ { id: "editor.inspector.field.endCapLeft.19jel4v", message: "End cap left" }, + /* i18n */ { id: "editor.inspector.field.endCapRight.lhse9c", message: "End cap right" }, + /* i18n */ { id: "editor.inspector.field.endCaps.4fsb5b", message: "End caps" }, + /* i18n */ { id: "editor.inspector.field.endPost.1guwsk0", message: "End post" }, + /* i18n */ { id: "editor.inspector.field.equipmentType.1mc2ywt", message: "Equipment type" }, + /* i18n */ { id: "editor.inspector.field.fillToFloor.1oppnkf", message: "Fill to floor" }, + /* i18n */ { id: "editor.inspector.field.finial.1ertf4s", message: "Finial" }, + /* i18n */ { id: "editor.inspector.field.fittingType.18mz17s", message: "Fitting type" }, + /* i18n */ { id: "editor.inspector.field.flashingHeight.3e8nn6", message: "Flashing height" }, + /* i18n */ { id: "editor.inspector.field.flashingProjection.hhf0fk", message: "Flashing projection" }, + /* i18n */ { id: "editor.inspector.field.flueCount.j3t2ci", message: "Flue count" }, + /* i18n */ { id: "editor.inspector.field.flueDiameter.cjhubg", message: "Flue diameter" }, + /* i18n */ { id: "editor.inspector.field.flueHeight.1dhc4y4", message: "Flue height" }, + /* i18n */ { id: "editor.inspector.field.flueShape.1ealiy4", message: "Flue shape" }, + /* i18n */ { id: "editor.inspector.field.flueSpacing.exhzco", message: "Flue spacing" }, + /* i18n */ { id: "editor.inspector.field.footingStyle.e3btg6", message: "Footing style" }, + /* i18n */ { id: "editor.inspector.field.frameDepth.1934a5n", message: "Frame depth" }, + /* i18n */ { id: "editor.inspector.field.frameThickness.1agxzd8", message: "Frame thickness" }, + /* i18n */ { id: "editor.inspector.field.framingStrategy.3jx9qe", message: "Framing strategy" }, + /* i18n */ { id: "editor.inspector.field.fromLevelId.15ieejs", message: "From level id" }, + /* i18n */ { id: "editor.inspector.field.gableOverhang.2z0obw", message: "Gable overhang" }, + /* i18n */ { id: "editor.inspector.field.gambrelLowerHeightRatio.wqq7lg", message: "Gambrel lower height ratio" }, + /* i18n */ { id: "editor.inspector.field.gambrelLowerWidthRatio.uvyb1v", message: "Gambrel lower width ratio" }, + /* i18n */ { id: "editor.inspector.field.gapX.wjxa7", message: "Gap x" }, + /* i18n */ { id: "editor.inspector.field.gapY.mkbl8", message: "Gap y" }, + /* i18n */ { id: "editor.inspector.field.glassThickness.s97b0x", message: "Glass thickness" }, + /* i18n */ { id: "editor.inspector.field.groundClearance.4os7f0", message: "Ground clearance" }, + /* i18n */ { id: "editor.inspector.field.guardInfill.16heiig", message: "Guard infill" }, + /* i18n */ { id: "editor.inspector.field.gutterEnabled.1oxiltf", message: "Gutter enabled" }, + /* i18n */ { id: "editor.inspector.field.gutterProfile.1ngzqi3", message: "Gutter profile" }, + /* i18n */ { id: "editor.inspector.field.gutterSize.jgavtz", message: "Gutter size" }, + /* i18n */ { id: "editor.inspector.field.hangerMaxReach.1869epv", message: "Hanger max reach" }, + /* i18n */ { id: "editor.inspector.field.hangerSpacing.i2b5d7", message: "Hanger spacing" }, + /* i18n */ { id: "editor.inspector.field.hangerStyle.l7v7gb", message: "Hanger style" }, + /* i18n */ { id: "editor.inspector.field.height.7puwki", message: "Height" }, + /* i18n */ { id: "editor.inspector.field.height2.7b3jlc", message: "Height2" }, + /* i18n */ { id: "editor.inspector.field.heightAboveRidge.1k99brk", message: "Height above ridge" }, + /* i18n */ { id: "editor.inspector.field.highEdgeHeight.1s7v90t", message: "High edge height" }, + /* i18n */ { id: "editor.inspector.field.highOverhang.67w55d", message: "High overhang" }, + /* i18n */ { id: "editor.inspector.field.highSideMode.1rc1r5j", message: "High side mode" }, + /* i18n */ { id: "editor.inspector.field.hoodOverhang.1q1hcin", message: "Hood overhang" }, + /* i18n */ { id: "editor.inspector.field.infillPlacement.11dxzpo", message: "Infill placement" }, + /* i18n */ { id: "editor.inspector.field.inletShape.ypayh2", message: "Inlet shape" }, + /* i18n */ { id: "editor.inspector.field.innerRadius.1fkmg0j", message: "Inner radius" }, + /* i18n */ { id: "editor.inspector.field.insulated.iwziey", message: "Insulated" }, + /* i18n */ { id: "editor.inspector.field.insulationR.663ufx", message: "Insulation r" }, + /* i18n */ { id: "editor.inspector.field.lanternHeight.oz7zpm", message: "Lantern height" }, + /* i18n */ { id: "editor.inspector.field.lanternTopScale.hkmpp2", message: "Lantern top scale" }, + /* i18n */ { id: "editor.inspector.field.ledgerDepth.7av8k9", message: "Ledger depth" }, + /* i18n */ { id: "editor.inspector.field.ledgerHeight.zo2oif", message: "Ledger height" }, + /* i18n */ { id: "editor.inspector.field.ledgerVerticalOffset.1athbgt", message: "Ledger vertical offset" }, + /* i18n */ { id: "editor.inspector.field.leftEndCondition.1syqlaw", message: "Left end condition" }, + /* i18n */ { id: "editor.inspector.field.leftOverhang.1ydl9ma", message: "Left overhang" }, + /* i18n */ { id: "editor.inspector.field.length.1kpdcfp", message: "Length" }, + /* i18n */ { id: "editor.inspector.field.liquidDiameter.w3pk7c", message: "Liquid diameter" }, + /* i18n */ { id: "editor.inspector.field.louverCount.lhw2gr", message: "Louver count" }, + /* i18n */ { id: "editor.inspector.field.lowBeamInset.1g7yw5l", message: "Low beam inset" }, + /* i18n */ { id: "editor.inspector.field.lowEdgeHeight.47mae1", message: "Low edge height" }, + /* i18n */ { id: "editor.inspector.field.lowOverhang.t4nz1h", message: "Low overhang" }, + /* i18n */ { id: "editor.inspector.field.mansardSteepHeightRatio.1hbb8rq", message: "Mansard steep height ratio" }, + /* i18n */ { id: "editor.inspector.field.mansardSteepWidthRatio.6xk6vd", message: "Mansard steep width ratio" }, + /* i18n */ { id: "editor.inspector.field.matchHostRoofMaterial.s56zwj", message: "Match host roof material" }, + /* i18n */ { id: "editor.inspector.field.matchHostRoofStructure.bojht3", message: "Match host roof structure" }, + /* i18n */ { id: "editor.inspector.field.mount.h80tay", message: "Mount" }, + /* i18n */ { id: "editor.inspector.field.mountingType.1xvin1e", message: "Mounting type" }, + /* i18n */ { id: "editor.inspector.field.neckHeight.19ym8n1", message: "Neck height" }, + /* i18n */ { id: "editor.inspector.field.opacity.1pmpfd2", message: "Opacity" }, + /* i18n */ { id: "editor.inspector.field.openingAngle.brhi7c", message: "Opening angle" }, + /* i18n */ { id: "editor.inspector.field.openingOffset.wpoqt6", message: "Opening offset" }, + /* i18n */ { id: "editor.inspector.field.openingSide.18l2iwe", message: "Opening side" }, + /* i18n */ { id: "editor.inspector.field.operationState.1lwrz07", message: "Operation state" }, + /* i18n */ { id: "editor.inspector.field.outletShape.lgpqhx", message: "Outlet shape" }, + /* i18n */ { id: "editor.inspector.field.overhang.b5ljpv", message: "Overhang" }, + /* i18n */ { id: "editor.inspector.field.panelHeight.1kf31vk", message: "Panel height" }, + /* i18n */ { id: "editor.inspector.field.panelWidth.946581", message: "Panel width" }, + /* i18n */ { id: "editor.inspector.field.patternAlignment.jd1ht0", message: "Pattern alignment" }, + /* i18n */ { id: "editor.inspector.field.patternCount.1r37pve", message: "Pattern count" }, + /* i18n */ { id: "editor.inspector.field.patternDistribution.fmam51", message: "Pattern distribution" }, + /* i18n */ { id: "editor.inspector.field.patternRemainder.i47p5y", message: "Pattern remainder" }, + /* i18n */ { id: "editor.inspector.field.picketProfile.1xgzuyc", message: "Picket profile" }, + /* i18n */ { id: "editor.inspector.field.picketRailCount.atgt6w", message: "Picket rail count" }, + /* i18n */ { id: "editor.inspector.field.picketSpacing.p8d98w", message: "Picket spacing" }, + /* i18n */ { id: "editor.inspector.field.picketTop.1tskplu", message: "Picket top" }, + /* i18n */ { id: "editor.inspector.field.picketTopClearance.16eq1k0", message: "Picket top clearance" }, + /* i18n */ { id: "editor.inspector.field.picketVariation.f7vl1c", message: "Picket variation" }, + /* i18n */ { id: "editor.inspector.field.picketWidth.3fr3rh", message: "Picket width" }, + /* i18n */ { id: "editor.inspector.field.pipeMaterial.1ggc2wy", message: "Pipe material" }, + /* i18n */ { id: "editor.inspector.field.pitch.xe6ekx", message: "Pitch" }, + /* i18n */ { id: "editor.inspector.field.position.1quewx6", message: "Position" }, + /* i18n */ { id: "editor.inspector.field.postBracing.crq8nn", message: "Post bracing" }, + /* i18n */ { id: "editor.inspector.field.postCap.1xcvxrv", message: "Post cap" }, + /* i18n */ { id: "editor.inspector.field.postCount.22r56c", message: "Post count" }, + /* i18n */ { id: "editor.inspector.field.postDepth.1ko6j1m", message: "Post depth" }, + /* i18n */ { id: "editor.inspector.field.postInset.le36li", message: "Post inset" }, + /* i18n */ { id: "editor.inspector.field.postLayoutMode.16jre62", message: "Post layout mode" }, + /* i18n */ { id: "editor.inspector.field.postSize.1jodxek", message: "Post size" }, + /* i18n */ { id: "editor.inspector.field.postSpacing.voppfi", message: "Post spacing" }, + /* i18n */ { id: "editor.inspector.field.postThrough.1abgu4a", message: "Post through" }, + /* i18n */ { id: "editor.inspector.field.postWidth.rk26fz", message: "Post width" }, + /* i18n */ { id: "editor.inspector.field.profile.1kwlci6", message: "Profile" }, + /* i18n */ { id: "editor.inspector.field.projection.jhtig0", message: "Projection" }, + /* i18n */ { id: "editor.inspector.field.purlinHeight.eo9cjm", message: "Purlin height" }, + /* i18n */ { id: "editor.inspector.field.purlinSpacing.4gbzb2", message: "Purlin spacing" }, + /* i18n */ { id: "editor.inspector.field.purlinWidth.r6tu3j", message: "Purlin width" }, + /* i18n */ { id: "editor.inspector.field.rafterEndInset.14c0wy5", message: "Rafter end inset" }, + /* i18n */ { id: "editor.inspector.field.rafterHeight.1e1n99w", message: "Rafter height" }, + /* i18n */ { id: "editor.inspector.field.rafterSpacing.19subc0", message: "Rafter spacing" }, + /* i18n */ { id: "editor.inspector.field.rafterWidth.eksj4t", message: "Rafter width" }, + /* i18n */ { id: "editor.inspector.field.railingHeight.6wf4bm", message: "Railing height" }, + /* i18n */ { id: "editor.inspector.field.railingMode.ov7xpu", message: "Railing mode" }, + /* i18n */ { id: "editor.inspector.field.railingStyle.1nyd2u", message: "Railing style" }, + /* i18n */ { id: "editor.inspector.field.resizeLock.b2hbr0", message: "Resize lock" }, + /* i18n */ { id: "editor.inspector.field.returnDiameter.166ozhe", message: "Return diameter" }, + /* i18n */ { id: "editor.inspector.field.returnHeight.b9sq6y", message: "Return height" }, + /* i18n */ { id: "editor.inspector.field.returnShape.1e3a27i", message: "Return shape" }, + /* i18n */ { id: "editor.inspector.field.returnWidth.z9r3h3", message: "Return width" }, + /* i18n */ { id: "editor.inspector.field.rightEndCondition.tjlw3l", message: "Right end condition" }, + /* i18n */ { id: "editor.inspector.field.rightOverhang.yqbeft", message: "Right overhang" }, + /* i18n */ { id: "editor.inspector.field.roofHeight.1uhy6qy", message: "Roof height" }, + /* i18n */ { id: "editor.inspector.field.roofStyle.pawqfy", message: "Roof style" }, + /* i18n */ { id: "editor.inspector.field.roofThickness.1xlr3rx", message: "Roof thickness" }, + /* i18n */ { id: "editor.inspector.field.roofType.1m9kv5h", message: "Roof type" }, + /* i18n */ { id: "editor.inspector.field.rotation.ly2rj", message: "Rotation" }, + /* i18n */ { id: "editor.inspector.field.rows.1ba1neg", message: "Rows" }, + /* i18n */ { id: "editor.inspector.field.scale.b6rpxt", message: "Scale" }, + /* i18n */ { id: "editor.inspector.field.seamDetail.197za48", message: "Seam detail" }, + /* i18n */ { id: "editor.inspector.field.segmentType.l667sy", message: "Segment type" }, + /* i18n */ { id: "editor.inspector.field.shaftStyle.1i5ampa", message: "Shaft style" }, + /* i18n */ { id: "editor.inspector.field.shaftWallThickness.bduslr", message: "Shaft wall thickness" }, + /* i18n */ { id: "editor.inspector.field.shape.iq9t52", message: "Shape" }, + /* i18n */ { id: "editor.inspector.field.shape2.fi0nx8", message: "Shape2" }, + /* i18n */ { id: "editor.inspector.field.shedHighSide.bzzddg", message: "Shed high side" }, + /* i18n */ { id: "editor.inspector.field.shingleThickness.1hn5wxr", message: "Shingle thickness" }, + /* i18n */ { id: "editor.inspector.field.shoulderExtent.11j8uiv", message: "Shoulder extent" }, + /* i18n */ { id: "editor.inspector.field.shoulderHeight.xyqn6g", message: "Shoulder height" }, + /* i18n */ { id: "editor.inspector.field.shoulderStyle.1hkcv2w", message: "Shoulder style" }, + /* i18n */ { id: "editor.inspector.field.showCenterColumn.1uggg45", message: "Show center column" }, + /* i18n */ { id: "editor.inspector.field.showInfill.33rv7u", message: "Show infill" }, + /* i18n */ { id: "editor.inspector.field.showStepSupports.1kg8h8m", message: "Show step supports" }, + /* i18n */ { id: "editor.inspector.field.sideFlashing.cv744o", message: "Side flashing" }, + /* i18n */ { id: "editor.inspector.field.size.1a4x3zw", message: "Size" }, + /* i18n */ { id: "editor.inspector.field.skylightType.1a5vj3i", message: "Skylight type" }, + /* i18n */ { id: "editor.inspector.field.slabOpeningMode.g4f730", message: "Slab opening mode" }, + /* i18n */ { id: "editor.inspector.field.slatGap.slmk93", message: "Slat gap" }, + /* i18n */ { id: "editor.inspector.field.slideDirection.1527q13", message: "Slide direction" }, + /* i18n */ { id: "editor.inspector.field.span.1bmmuw1", message: "Span" }, + /* i18n */ { id: "editor.inspector.field.speed.2w2xwg", message: "Speed" }, + /* i18n */ { id: "editor.inspector.field.spinSpeed.1ozx746", message: "Spin speed" }, + /* i18n */ { id: "editor.inspector.field.stairType.1u05e02", message: "Stair type" }, + /* i18n */ { id: "editor.inspector.field.standoff.4wv2g0", message: "Standoff" }, + /* i18n */ { id: "editor.inspector.field.standoffHeight.1qdnff", message: "Standoff height" }, + /* i18n */ { id: "editor.inspector.field.startPost.1ood9kv", message: "Start post" }, + /* i18n */ { id: "editor.inspector.field.stepCount.nzorrw", message: "Step count" }, + /* i18n */ { id: "editor.inspector.field.strapSpacing.1cyt3dc", message: "Strap spacing" }, + /* i18n */ { id: "editor.inspector.field.strapStyle.fh1oy4", message: "Strap style" }, + /* i18n */ { id: "editor.inspector.field.style.nf3vcm", message: "Style" }, + /* i18n */ { id: "editor.inspector.field.suctionDiameter.nutxtj", message: "Suction diameter" }, + /* i18n */ { id: "editor.inspector.field.supplyDiameter.h5ldh5", message: "Supply diameter" }, + /* i18n */ { id: "editor.inspector.field.supplyHeight.zy6ru1", message: "Supply height" }, + /* i18n */ { id: "editor.inspector.field.supplyShape.k39wnb", message: "Supply shape" }, + /* i18n */ { id: "editor.inspector.field.supplyWidth.g66axi", message: "Supply width" }, + /* i18n */ { id: "editor.inspector.field.supportOffset.1w4cocr", message: "Support offset" }, + /* i18n */ { id: "editor.inspector.field.sweepAngle.ucmmlw", message: "Sweep angle" }, + /* i18n */ { id: "editor.inspector.field.system.13qbhrw", message: "System" }, + /* i18n */ { id: "editor.inspector.field.terminal.1501knl", message: "Terminal" }, + /* i18n */ { id: "editor.inspector.field.terminalType.1ne8lgb", message: "Terminal type" }, + /* i18n */ { id: "editor.inspector.field.thickness.1l8hu45", message: "Thickness" }, + /* i18n */ { id: "editor.inspector.field.tiltAngle.1312yw1", message: "Tilt angle" }, + /* i18n */ { id: "editor.inspector.field.toLevelId.8i5qx7", message: "To level id" }, + /* i18n */ { id: "editor.inspector.field.topLandingDepth.ki7gk2", message: "Top landing depth" }, + /* i18n */ { id: "editor.inspector.field.topLandingMode.zk0h7e", message: "Top landing mode" }, + /* i18n */ { id: "editor.inspector.field.topRailHeight.fscisv", message: "Top rail height" }, + /* i18n */ { id: "editor.inspector.field.transitionMode.f3evgt", message: "Transition mode" }, + /* i18n */ { id: "editor.inspector.field.transitionWidth.rtgz3m", message: "Transition width" }, + /* i18n */ { id: "editor.inspector.field.vaneCount.55vtye", message: "Vane count" }, + /* i18n */ { id: "editor.inspector.field.wallHeight.18vmh5c", message: "Wall height" }, + /* i18n */ { id: "editor.inspector.field.wallShell.vqsyhv", message: "Wall shell" }, + /* i18n */ { id: "editor.inspector.field.wallSkirtHeight.1uea2ad", message: "Wall skirt height" }, + /* i18n */ { id: "editor.inspector.field.wallThickness.17wn4iv", message: "Wall thickness" }, + /* i18n */ { id: "editor.inspector.field.width.gfxz9b", message: "Width" }, + /* i18n */ { id: "editor.inspector.field.width2.1nqawvr", message: "Width2" }, + /* i18n */ { id: "editor.inspector.field.withBack.1ay8nus", message: "With back" }, + /* i18n */ { id: "editor.inspector.field.withBottom.1x1z1vo", message: "With bottom" }, + /* i18n */ { id: "editor.inspector.field.withSides.uq066x", message: "With sides" }, + /* i18n */ { id: "editor.inspector.group.advanced.qwfkor", message: "Advanced" }, + /* i18n */ { id: "editor.inspector.group.air.15jotdj", message: "Air" }, + /* i18n */ { id: "editor.inspector.group.appearance.dli9wf", message: "Appearance" }, + /* i18n */ { id: "editor.inspector.group.body.ign4wl", message: "Body" }, + /* i18n */ { id: "editor.inspector.group.cab.o4z1ox", message: "Cab" }, + /* i18n */ { id: "editor.inspector.group.cabinet.rzvymr", message: "Cabinet" }, + /* i18n */ { id: "editor.inspector.group.cap.s0thej", message: "Cap" }, + /* i18n */ { id: "editor.inspector.group.collar.1am2iqo", message: "Collar" }, + /* i18n */ { id: "editor.inspector.group.connection.2r1h4p", message: "Connection" }, + /* i18n */ { id: "editor.inspector.group.connections.ai4w8u", message: "Connections" }, + /* i18n */ { id: "editor.inspector.group.construction.j6p9kc", message: "Construction" }, + /* i18n */ { id: "editor.inspector.group.cricket.6iwg8g", message: "Cricket" }, + /* i18n */ { id: "editor.inspector.group.curb.5xi2ol", message: "Curb" }, + /* i18n */ { id: "editor.inspector.group.curvePoints.ztdcnn", message: "Curve points" }, + /* i18n */ { id: "editor.inspector.group.dimensions.pewa8g", message: "Dimensions" }, + /* i18n */ { id: "editor.inspector.group.doors.18w2kpa", message: "Doors" }, + /* i18n */ { id: "editor.inspector.group.dormer.1fjs5tq", message: "Dormer" }, + /* i18n */ { id: "editor.inspector.group.dormerRoof.u9uy2g", message: "Dormer roof" }, + /* i18n */ { id: "editor.inspector.group.drainage.7easy0", message: "Drainage" }, + /* i18n */ { id: "editor.inspector.group.elevation.4jbp44", message: "Elevation" }, + /* i18n */ { id: "editor.inspector.group.endCaps.4fsb5b", message: "End caps" }, + /* i18n */ { id: "editor.inspector.group.equipment.1xtq0ev", message: "Equipment" }, + /* i18n */ { id: "editor.inspector.group.face.1531fr0", message: "Face" }, + /* i18n */ { id: "editor.inspector.group.fitting.171z1r4", message: "Fitting" }, + /* i18n */ { id: "editor.inspector.group.flues.nq6pye", message: "Flues" }, + /* i18n */ { id: "editor.inspector.group.footprint.g8q2pg", message: "Footprint" }, + /* i18n */ { id: "editor.inspector.group.frame.q7q9fq", message: "Frame" }, + /* i18n */ { id: "editor.inspector.group.geometry.a21os1", message: "Geometry" }, + /* i18n */ { id: "editor.inspector.group.grid.d8gwxd", message: "Grid" }, + /* i18n */ { id: "editor.inspector.group.hangers.agnfzn", message: "Hangers" }, + /* i18n */ { id: "editor.inspector.group.hardware.1nygwm1", message: "Hardware" }, + /* i18n */ { id: "editor.inspector.group.hungWall.1ftr1gz", message: "Hung wall" }, + /* i18n */ { id: "editor.inspector.group.insulation.1kyo5e7", message: "Insulation" }, + /* i18n */ { id: "editor.inspector.group.lantern.1h7o2xb", message: "Lantern" }, + /* i18n */ { id: "editor.inspector.group.levels.1s6yaru", message: "Levels" }, + /* i18n */ { id: "editor.inspector.group.line.166mv5z", message: "Line" }, + /* i18n */ { id: "editor.inspector.group.lines.12bghr0", message: "Lines" }, + /* i18n */ { id: "editor.inspector.group.motion.1be7sjr", message: "Motion" }, + /* i18n */ { id: "editor.inspector.group.mounting.feutd2", message: "Mounting" }, + /* i18n */ { id: "editor.inspector.group.opening.dfet3", message: "Opening" }, + /* i18n */ { id: "editor.inspector.group.panelDimensions.b59qe6", message: "Panel dimensions" }, + /* i18n */ { id: "editor.inspector.group.patternDistribution.fmam51", message: "Pattern distribution" }, + /* i18n */ { id: "editor.inspector.group.placement.13lcvts", message: "Placement" }, + /* i18n */ { id: "editor.inspector.group.position.1quewx6", message: "Position" }, + /* i18n */ { id: "editor.inspector.group.profile.1kwlci6", message: "Profile" }, + /* i18n */ { id: "editor.inspector.group.railing.19a5bnr", message: "Railing" }, + /* i18n */ { id: "editor.inspector.group.return.1klrjrz", message: "Return" }, + /* i18n */ { id: "editor.inspector.group.settings.ktdb8o", message: "Settings" }, + /* i18n */ { id: "editor.inspector.group.shaft.mad9cv", message: "Shaft" }, + /* i18n */ { id: "editor.inspector.group.shape.iq9t52", message: "Shape" }, + /* i18n */ { id: "editor.inspector.group.shoulder.1nyfx25", message: "Shoulder" }, + /* i18n */ { id: "editor.inspector.group.size.1a4x3zw", message: "Size" }, + /* i18n */ { id: "editor.inspector.group.structure.gezdra", message: "Structure" }, + /* i18n */ { id: "editor.inspector.group.style.nf3vcm", message: "Style" }, + /* i18n */ { id: "editor.inspector.group.supply.1oy2zwu", message: "Supply" }, + /* i18n */ { id: "editor.inspector.group.surface.wtamqk", message: "Surface" }, + /* i18n */ { id: "editor.inspector.group.terminal.1501knl", message: "Terminal" }, + /* i18n */ { id: "editor.inspector.group.topology.18t8dyc", message: "Topology" }, + /* i18n */ { id: "editor.inspector.group.transform.1c55ayj", message: "Transform" }, + /* i18n */ { id: "editor.inspector.group.trap.1qc7vhe", message: "Trap" }, + /* i18n */ { id: "editor.inspector.group.type.1m2zofh", message: "Type" }, + /* i18n */ { id: "editor.inspector.option.abs.132mz8b", message: "Abs" }, + /* i18n */ { id: "editor.inspector.option.accessPanel.13sptb5", message: "Access panel" }, + /* i18n */ { id: "editor.inspector.option.airHandler.1crnyg3", message: "Air handler" }, + /* i18n */ { id: "editor.inspector.option.alternating.5jashi", message: "Alternating" }, + /* i18n */ { id: "editor.inspector.option.arched.14188w0", message: "Arched" }, + /* i18n */ { id: "editor.inspector.option.auto.540b06", message: "Auto" }, + /* i18n */ { id: "editor.inspector.option.automatic.r122ro", message: "Automatic" }, + /* i18n */ { id: "editor.inspector.option.back.1hzmxtu", message: "Back" }, + /* i18n */ { id: "editor.inspector.option.balusters.1j3w6ng", message: "Balusters" }, + /* i18n */ { id: "editor.inspector.option.band.160umau", message: "Band" }, + /* i18n */ { id: "editor.inspector.option.basePlate.17fcg8k", message: "Base plate" }, + /* i18n */ { id: "editor.inspector.option.boards.u14hig", message: "Boards" }, + /* i18n */ { id: "editor.inspector.option.bookshelf.ls0zqk", message: "Bookshelf" }, + /* i18n */ { id: "editor.inspector.option.both.mkyb42", message: "Both" }, + /* i18n */ { id: "editor.inspector.option.bottom.9mpruy", message: "Bottom" }, + /* i18n */ { id: "editor.inspector.option.box.1nbjcuq", message: "Box" }, + /* i18n */ { id: "editor.inspector.option.break.17spnvc", message: "Break" }, + /* i18n */ { id: "editor.inspector.option.butterfly.pvv0g4", message: "Butterfly" }, + /* i18n */ { id: "editor.inspector.option.cable.fp50zq", message: "Cable" }, + /* i18n */ { id: "editor.inspector.option.castIron.1q2z4qk", message: "Cast iron" }, + /* i18n */ { id: "editor.inspector.option.ceiling.1t2zaqo", message: "Ceiling" }, + /* i18n */ { id: "editor.inspector.option.center.kzdxno", message: "Center" }, + /* i18n */ { id: "editor.inspector.option.centerOpening.bn1rvw", message: "Center opening" }, + /* i18n */ { id: "editor.inspector.option.cleanout.lww8bm", message: "Cleanout" }, + /* i18n */ { id: "editor.inspector.option.concretePad.m1zdij", message: "Concrete pad" }, + /* i18n */ { id: "editor.inspector.option.condenser.a74nps", message: "Condenser" }, + /* i18n */ { id: "editor.inspector.option.corbeled.bovs4v", message: "Corbeled" }, + /* i18n */ { id: "editor.inspector.option.count.1qoi4r8", message: "Count" }, + /* i18n */ { id: "editor.inspector.option.coupling.111aap2", message: "Coupling" }, + /* i18n */ { id: "editor.inspector.option.coveringSpecific.1h9fo8w", message: "Covering specific" }, + /* i18n */ { id: "editor.inspector.option.cross.1elubzv", message: "Cross" }, + /* i18n */ { id: "editor.inspector.option.cubby.ivq4qc", message: "Cubby" }, + /* i18n */ { id: "editor.inspector.option.curved.6noz1s", message: "Curved" }, + /* i18n */ { id: "editor.inspector.option.cylinder.eol6vj", message: "Cylinder" }, + /* i18n */ { id: "editor.inspector.option.damper.wgri80", message: "Damper" }, + /* i18n */ { id: "editor.inspector.option.destination.gfzm1r", message: "Destination" }, + /* i18n */ { id: "editor.inspector.option.diffuser.1igj0jh", message: "Diffuser" }, + /* i18n */ { id: "editor.inspector.option.dogEar.xhtvjt", message: "Dog ear" }, + /* i18n */ { id: "editor.inspector.option.dome.1c87e56", message: "Dome" }, + /* i18n */ { id: "editor.inspector.option.double.13h5354", message: "Double" }, + /* i18n */ { id: "editor.inspector.option.ductBoard.lc3o4t", message: "Duct board" }, + /* i18n */ { id: "editor.inspector.option.dutch.1djx429", message: "Dutch" }, + /* i18n */ { id: "editor.inspector.option.elbow.1flup74", message: "Elbow" }, + /* i18n */ { id: "editor.inspector.option.end.1llf32i", message: "End" }, + /* i18n */ { id: "editor.inspector.option.endCap.1p4v4na", message: "End cap" }, + /* i18n */ { id: "editor.inspector.option.equalFit.11vw52c", message: "Equal fit" }, + /* i18n */ { id: "editor.inspector.option.fixedCount.1o3dznu", message: "Fixed count" }, + /* i18n */ { id: "editor.inspector.option.fixedSpacing.hl4dr4", message: "Fixed spacing" }, + /* i18n */ { id: "editor.inspector.option.flat.ly0bei", message: "Flat" }, + /* i18n */ { id: "editor.inspector.option.flex.ktpamq", message: "Flex" }, + /* i18n */ { id: "editor.inspector.option.floating.1oadpln", message: "Floating" }, + /* i18n */ { id: "editor.inspector.option.floor.q9caf1", message: "Floor" }, + /* i18n */ { id: "editor.inspector.option.flush.olwwu5", message: "Flush" }, + /* i18n */ { id: "editor.inspector.option.front.uhugl4", message: "Front" }, + /* i18n */ { id: "editor.inspector.option.furnace.w0mzpf", message: "Furnace" }, + /* i18n */ { id: "editor.inspector.option.gable.1ekyx6", message: "Gable" }, + /* i18n */ { id: "editor.inspector.option.gambrel.1yo90d9", message: "Gambrel" }, + /* i18n */ { id: "editor.inspector.option.generic.1074oey", message: "Generic" }, + /* i18n */ { id: "editor.inspector.option.glass.163tc5n", message: "Glass" }, + /* i18n */ { id: "editor.inspector.option.glassFrame.jp9s3m", message: "Glass frame" }, + /* i18n */ { id: "editor.inspector.option.globe.1avkqoy", message: "Globe" }, + /* i18n */ { id: "editor.inspector.option.grounded.jwoq73", message: "Grounded" }, + /* i18n */ { id: "editor.inspector.option.guard.1m575se", message: "Guard" }, + /* i18n */ { id: "editor.inspector.option.halfRound.m5hob0", message: "Half round" }, + /* i18n */ { id: "editor.inspector.option.hidden.gp48rd", message: "Hidden" }, + /* i18n */ { id: "editor.inspector.option.hip.r0jnjy", message: "Hip" }, + /* i18n */ { id: "editor.inspector.option.horizontal.tyapah", message: "Horizontal" }, + /* i18n */ { id: "editor.inspector.option.include.1a27vnr", message: "Include" }, + /* i18n */ { id: "editor.inspector.option.independentHighBeam.1tn0zvy", message: "Independent high beam" }, + /* i18n */ { id: "editor.inspector.option.industrial.8sd9u", message: "Industrial" }, + /* i18n */ { id: "editor.inspector.option.inline.1uh05lg", message: "Inline" }, + /* i18n */ { id: "editor.inspector.option.integrated.1xd9lv0", message: "Integrated" }, + /* i18n */ { id: "editor.inspector.option.joined.1x2o8ds", message: "Joined" }, + /* i18n */ { id: "editor.inspector.option.kickout.vt8s6p", message: "Kickout" }, + /* i18n */ { id: "editor.inspector.option.knee.186hcui", message: "Knee" }, + /* i18n */ { id: "editor.inspector.option.kStyle.m8kfwz", message: "K style" }, + /* i18n */ { id: "editor.inspector.option.landing.tyqarg", message: "Landing" }, + /* i18n */ { id: "editor.inspector.option.lantern.1h7o2xb", message: "Lantern" }, + /* i18n */ { id: "editor.inspector.option.leave.1xf0sbk", message: "Leave" }, + /* i18n */ { id: "editor.inspector.option.left.14n06mo", message: "Left" }, + /* i18n */ { id: "editor.inspector.option.level.i4lhtp", message: "Level" }, + /* i18n */ { id: "editor.inspector.option.lowProfile.1h9lgzg", message: "Low profile" }, + /* i18n */ { id: "editor.inspector.option.mansard.dwzck9", message: "Mansard" }, + /* i18n */ { id: "editor.inspector.option.manual.1wu7r43", message: "Manual" }, + /* i18n */ { id: "editor.inspector.option.maximumSpacing.n1fzl4", message: "Maximum spacing" }, + /* i18n */ { id: "editor.inspector.option.metal.1az9fgc", message: "Metal" }, + /* i18n */ { id: "editor.inspector.option.metalPanel.1xdz476", message: "Metal panel" }, + /* i18n */ { id: "editor.inspector.option.minimal.fwmiwg", message: "Minimal" }, + /* i18n */ { id: "editor.inspector.option.mono.oszvd6", message: "Mono" }, + /* i18n */ { id: "editor.inspector.option.none.deku7v", message: "None" }, + /* i18n */ { id: "editor.inspector.option.omit.okcs7w", message: "Omit" }, + /* i18n */ { id: "editor.inspector.option.open.n6hn1l", message: "Open" }, + /* i18n */ { id: "editor.inspector.option.opening.dfet3", message: "Opening" }, + /* i18n */ { id: "editor.inspector.option.openRack.mvt1x0", message: "Open rack" }, + /* i18n */ { id: "editor.inspector.option.oval.1jiqzxx", message: "Oval" }, + /* i18n */ { id: "editor.inspector.option.picket.zyeoy1", message: "Picket" }, + /* i18n */ { id: "editor.inspector.option.pointed.effedk", message: "Pointed" }, + /* i18n */ { id: "editor.inspector.option.postAndRail.lajk2u", message: "Post and rail" }, + /* i18n */ { id: "editor.inspector.option.preserveHighEdge.y6ywf0", message: "Preserve high edge" }, + /* i18n */ { id: "editor.inspector.option.preserveLowEdge.196m5pk", message: "Preserve low edge" }, + /* i18n */ { id: "editor.inspector.option.preservePitch.1uq973r", message: "Preserve pitch" }, + /* i18n */ { id: "editor.inspector.option.privacy.1th4hrb", message: "Privacy" }, + /* i18n */ { id: "editor.inspector.option.purlins.od124c", message: "Purlins" }, + /* i18n */ { id: "editor.inspector.option.pvc.1oc4u2w", message: "Pvc" }, + /* i18n */ { id: "editor.inspector.option.pyramid.17ucsqv", message: "Pyramid" }, + /* i18n */ { id: "editor.inspector.option.rafters.16tzbby", message: "Rafters" }, + /* i18n */ { id: "editor.inspector.option.rail.39zqc9", message: "Rail" }, + /* i18n */ { id: "editor.inspector.option.raised.4kyx9h", message: "Raised" }, + /* i18n */ { id: "editor.inspector.option.rect.tpfvaf", message: "Rect" }, + /* i18n */ { id: "editor.inspector.option.reducer.18byt67", message: "Reducer" }, + /* i18n */ { id: "editor.inspector.option.return.1klrjrz", message: "Return" }, + /* i18n */ { id: "editor.inspector.option.returnGrille.r28ncm", message: "Return grille" }, + /* i18n */ { id: "editor.inspector.option.right.8humpx", message: "Right" }, + /* i18n */ { id: "editor.inspector.option.round.1vx0wyj", message: "Round" }, + /* i18n */ { id: "editor.inspector.option.rounded.199nwpm", message: "Rounded" }, + /* i18n */ { id: "editor.inspector.option.sanitaryTee.oro3d4", message: "Sanitary tee" }, + /* i18n */ { id: "editor.inspector.option.scalloped.1eltba0", message: "Scalloped" }, + /* i18n */ { id: "editor.inspector.option.scoop.qy5zdv", message: "Scoop" }, + /* i18n */ { id: "editor.inspector.option.segmentedPanel.5r3hwl", message: "Segmented panel" }, + /* i18n */ { id: "editor.inspector.option.shed.6t994v", message: "Shed" }, + /* i18n */ { id: "editor.inspector.option.sheetMetal.l164lv", message: "Sheet metal" }, + /* i18n */ { id: "editor.inspector.option.shingle.pfmynh", message: "Shingle" }, + /* i18n */ { id: "editor.inspector.option.shingled.1nm9inf", message: "Shingled" }, + /* i18n */ { id: "editor.inspector.option.simple.12mfu73", message: "Simple" }, + /* i18n */ { id: "editor.inspector.option.single.1uzxurt", message: "Single" }, + /* i18n */ { id: "editor.inspector.option.singleLeft.1atdh02", message: "Single left" }, + /* i18n */ { id: "editor.inspector.option.singleRight.1axbu67", message: "Single right" }, + /* i18n */ { id: "editor.inspector.option.slantBox.1ta99vy", message: "Slant box" }, + /* i18n */ { id: "editor.inspector.option.slat.1p9njbz", message: "Slat" }, + /* i18n */ { id: "editor.inspector.option.sliding.cd0jox", message: "Sliding" }, + /* i18n */ { id: "editor.inspector.option.slope.1bn2qw", message: "Slope" }, + /* i18n */ { id: "editor.inspector.option.sloped.lo4sh0", message: "Sloped" }, + /* i18n */ { id: "editor.inspector.option.solid.o4uy6s", message: "Solid" }, + /* i18n */ { id: "editor.inspector.option.solidPanel.2yaw62", message: "Solid panel" }, + /* i18n */ { id: "editor.inspector.option.spiral.91hcl4", message: "Spiral" }, + /* i18n */ { id: "editor.inspector.option.splash.1uke4cm", message: "Splash" }, + /* i18n */ { id: "editor.inspector.option.spread.1g1zysu", message: "Spread" }, + /* i18n */ { id: "editor.inspector.option.square.1xl1lk6", message: "Square" }, + /* i18n */ { id: "editor.inspector.option.stair.1xf86e", message: "Stair" }, + /* i18n */ { id: "editor.inspector.option.standard.vuco", message: "Standard" }, + /* i18n */ { id: "editor.inspector.option.start.30xvgf", message: "Start" }, + /* i18n */ { id: "editor.inspector.option.step.jtn9kf", message: "Step" }, + /* i18n */ { id: "editor.inspector.option.stepped.naxzh0", message: "Stepped" }, + /* i18n */ { id: "editor.inspector.option.straight.lxqbfr", message: "Straight" }, + /* i18n */ { id: "editor.inspector.option.strap.14q0ps9", message: "Strap" }, + /* i18n */ { id: "editor.inspector.option.supply.1oy2zwu", message: "Supply" }, + /* i18n */ { id: "editor.inspector.option.supplyRegister.1lzdbbn", message: "Supply register" }, + /* i18n */ { id: "editor.inspector.option.tapered.3y5blo", message: "Tapered" }, + /* i18n */ { id: "editor.inspector.option.targetSpacing.14uibc9", message: "Target spacing" }, + /* i18n */ { id: "editor.inspector.option.tee.7d6tjp", message: "Tee" }, + /* i18n */ { id: "editor.inspector.option.tilted.6n0u5j", message: "Tilted" }, + /* i18n */ { id: "editor.inspector.option.top.2nypcs", message: "Top" }, + /* i18n */ { id: "editor.inspector.option.transition.1rziz9e", message: "Transition" }, + /* i18n */ { id: "editor.inspector.option.vent.xda5j2", message: "Vent" }, + /* i18n */ { id: "editor.inspector.option.walkOn.1n5sg9l", message: "Walk on" }, + /* i18n */ { id: "editor.inspector.option.wall.blj585", message: "Wall" }, + /* i18n */ { id: "editor.inspector.option.wallAbutment.lza941", message: "Wall abutment" }, + /* i18n */ { id: "editor.inspector.option.wallLedger.5m8ana", message: "Wall ledger" }, + /* i18n */ { id: "editor.inspector.option.wallShelf.yiozfl", message: "Wall shelf" }, + /* i18n */ { id: "editor.inspector.option.waste.3xrp79", message: "Waste" }, + /* i18n */ { id: "editor.inspector.option.wye.14vupc8", message: "Wye" }, /* i18n */ { id: "editor.toolHint.aCurtainWallTakesNoCorner.1qxjv5i", message: "A curtain wall takes no corner window" }, /* i18n */ { id: "editor.toolHint.aCurvedWallTakesNoCorner.1vdnaw5", message: "A curved wall takes no corner window" }, /* i18n */ { id: "editor.toolHint.addACornerClickTheFirst.o4r67t", message: "Add a corner · click the first to close" }, diff --git a/packages/nodes/src/item/authored-params.tsx b/packages/nodes/src/item/authored-params.tsx index bd2f80858d..d20b22b2df 100644 --- a/packages/nodes/src/item/authored-params.tsx +++ b/packages/nodes/src/item/authored-params.tsx @@ -5,6 +5,7 @@ import type { GeometryScriptParamValue, ScriptedNode, } from '@pascal-app/core' +import { usePascalI18n } from '@pascal-app/core' import { PanelSection, rebuildAuthoredObject, @@ -31,6 +32,7 @@ export function AuthoredParams({ }: { node: { id: string; source?: ScriptedNode['source'] } }) { + const i18n = usePascalI18n() const source = node.source const [drafts, setDrafts] = useState>({}) const [busy, setBusy] = useState(false) @@ -46,7 +48,12 @@ export function AuthoredParams({ rebuildAuthoredObject(node.id, patch) .catch((reason: unknown) => { console.error('[authored object] rebuild failed', reason) - setError("Couldn't rebuild with these values.") + setError( + i18n._({ + id: 'node.item.authoredParams.couldnTRebuildWithTheseValues', + message: "Couldn't rebuild with these values.", + }), + ) }) .finally(() => { rebuilding.current = false @@ -115,7 +122,11 @@ export function AuthoredParams({
{input}
{isScriptDefault(spec, source.params[spec.id]) ? null : ( rebuild({ [spec.id]: scriptDefault(spec) })} /> )} @@ -126,7 +137,9 @@ export function AuthoredParams({ const resetAll = scriptDefaultsPatch(source.manifest.params, source.params) return ( - + {source.manifest.params.map(row)} {Object.keys(resetAll).length > 0 ? ( ) : null} {busy ? (
- {source.language === 'bpy' ? 'Rebuilding in Blender…' : 'Rebuilding…'} + {source.language === 'bpy' + ? i18n._({ + id: 'node.item.authoredParams.rebuildingInBlender', + message: 'Rebuilding in Blender…', + }) + : i18n._({ id: 'node.item.authoredParams.rebuilding', message: 'Rebuilding…' })}
) : null} {error ?
{error}
: null} @@ -151,6 +169,7 @@ export function AuthoredParams({ } function ResetButton({ label, onClick }: { label: string; onClick: () => void }) { + const i18n = usePascalI18n() return ( @@ -164,7 +183,9 @@ function ResetButton({ label, onClick }: { label: string; onClick: () => void }) - Reset to default + + {i18n._({ id: 'node.item.authoredParams.resetToDefault', message: 'Reset to default' })} + ) } diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx index 14780379be..9a515c5598 100644 --- a/packages/nodes/src/item/panel.tsx +++ b/packages/nodes/src/item/panel.tsx @@ -5,6 +5,7 @@ import { getScaledDimensions, type ItemNode, scriptImages, + usePascalI18n, useScene, } from '@pascal-app/core' import { @@ -36,6 +37,7 @@ import { AuthoredParams } from './authored-params' * (see the wiki / plan recipe). */ export default function ItemPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) @@ -113,11 +115,14 @@ export default function ItemPanel() { )} - + - Xpos + X + + {i18n._({ id: 'node.item.panel.pos', message: 'pos' })} + } max={node.position[0] + 2} @@ -133,7 +138,10 @@ export default function ItemPanel() { - Ypos + Y + + {i18n._({ id: 'node.item.panel.pos', message: 'pos' })} + } max={node.position[1] + 2} @@ -149,7 +157,10 @@ export default function ItemPanel() { - Zpos + Z + + {i18n._({ id: 'node.item.panel.pos', message: 'pos' })} + } max={node.position[2] + 2} @@ -164,12 +175,15 @@ export default function ItemPanel() { /> - + - Yrot + Y + + {i18n._({ id: 'node.item.panel.rot', message: 'rot' })} + } max={Math.round((node.rotation[1] * 180) / Math.PI) + 45} @@ -206,10 +220,10 @@ export default function ItemPanel() {
- +
- Uniform Scale + {i18n._({ id: 'node.item.panel.uniformScale', message: 'Uniform Scale' })} ))}
} - label="Draw Segment" + label={i18n._({ id: 'node.roof.panel.drawSegment', message: 'Draw Segment' })} onClick={handleAddSegment} />
- + { @@ -350,15 +380,25 @@ export default function RoofPanel() { handleUpdate({ support: { kind: mode === 'walls' ? 'walls' : 'level' } }) } options={[ - { label: 'Follows walls', value: 'walls' }, - { label: 'Custom', value: 'custom' }, + { + label: i18n._({ id: 'node.roof.panel.followsWalls', message: 'Follows walls' }), + value: 'walls', + }, + { + label: i18n._({ id: 'node.roof.panel.custom', message: 'Custom' }), + value: 'custom', + }, ]} value={node.support?.kind === 'walls' ? 'walls' : 'custom'} /> )} {node.support?.kind === 'walls' ? (
- Currently {formatLinearMeasurement(node.position[1], unit, metricNotation)} + {i18n._({ + id: 'node.panel.currently', + message: 'Currently {value}', + values: { value: formatLinearMeasurement(node.position[1], unit, metricNotation) }, + })}
) : ( { @@ -424,7 +464,7 @@ export default function RoofPanel() {
- +
{chimneys.map((chimney, i) => ( @@ -434,14 +474,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(chimney.id)} type="button" > - {chimney.name || `Chimney ${i + 1}`} - chimney + + {chimney.name || numberedName(nodeLabel('chimney', 'Chimney', i18n), i + 1, i18n)} + + + {i18n._({ id: 'node.roof.panel.chimney', message: 'chimney' })} + ))} } - label="Add Chimney" + label={i18n._({ id: 'node.roof.panel.addChimney', message: 'Add Chimney' })} onClick={() => activateTool('chimney')} /> @@ -455,14 +499,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(dormer.id)} type="button" > - {dormer.name || `Dormer ${i + 1}`} - dormer + + {dormer.name || numberedName(nodeLabel('dormer', 'Dormer', i18n), i + 1, i18n)} + + + {i18n._({ id: 'node.roof.panel.dormer', message: 'dormer' })} + ))} } - label="Add Dormer" + label={i18n._({ id: 'node.roof.panel.addDormer', message: 'Add Dormer' })} onClick={() => activateTool('dormer')} /> @@ -476,14 +524,19 @@ export default function RoofPanel() { onClick={() => handleSelectElement(skylight.id)} type="button" > - {skylight.name || `Skylight ${i + 1}`} - skylight + + {skylight.name || + numberedName(nodeLabel('skylight', 'Skylight', i18n), i + 1, i18n)} + + + {i18n._({ id: 'node.roof.panel.skylight', message: 'skylight' })} + ))} } - label="Add Skylight" + label={i18n._({ id: 'node.roof.panel.addSkylight', message: 'Add Skylight' })} onClick={() => activateTool('skylight')} /> @@ -497,14 +550,19 @@ export default function RoofPanel() { onClick={() => handleSelectElement(panel.id)} type="button" > - {panel.name || `Solar Panel ${i + 1}`} - solar panel + + {panel.name || + numberedName(nodeLabel('solar-panel', 'Solar Panel', i18n), i + 1, i18n)} + + + {i18n._({ id: 'node.roof.panel.solarPanel', message: 'solar panel' })} + ))} } - label="Add Solar Panel" + label={i18n._({ id: 'node.roof.panel.addSolarPanel', message: 'Add Solar Panel' })} onClick={() => activateTool('solar-panel')} /> @@ -520,34 +578,44 @@ 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}`)} + numberedName( + vent.type === 'box-vent' + ? nodeLabel('box-vent', 'Box Vent', i18n) + : vent.type === 'ridge-vent' + ? nodeLabel('ridge-vent', 'Ridge Vent', i18n) + : nodeLabel('turbine-vent', 'Turbine Vent', i18n), + i + 1, + i18n, + )} {vent.type === 'box-vent' - ? 'box vent' + ? i18n._({ id: 'node.roof.panel.boxVent', message: 'box vent' }) : vent.type === 'ridge-vent' - ? 'ridge vent' - : 'turbine vent'} + ? i18n._({ id: 'node.roof.panel.ridgeVent', message: 'ridge vent' }) + : i18n._({ id: 'node.roof.panel.turbineVent', message: 'turbine vent' })} ))} onChange={setVentType} options={[ - { label: 'Box', value: 'box-vent' }, - { label: 'Ridge', value: 'ridge-vent' }, - { label: 'Turbine', value: 'turbine-vent' }, + { label: i18n._({ id: 'node.roof.panel.box', message: 'Box' }), value: 'box-vent' }, + { + label: i18n._({ id: 'node.roof.panel.ridge', message: 'Ridge' }), + value: 'ridge-vent', + }, + { + label: i18n._({ id: 'node.roof.panel.turbine', message: 'Turbine' }), + value: 'turbine-vent', + }, ]} value={ventType} /> } - label="Add Vent" + label={i18n._({ id: 'node.roof.panel.addVent', message: 'Add Vent' })} onClick={() => activateTool(ventType)} /> @@ -557,7 +625,7 @@ export default function RoofPanel() { } - label="Add Cupola" + label={i18n._({ id: 'node.roof.panel.addCupola', message: 'Add Cupola' })} onClick={() => activateTool('cupola')} /> @@ -567,7 +635,10 @@ export default function RoofPanel() { } - label="Add Eyebrow Vent" + label={i18n._({ + id: 'node.roof.panel.addEyebrowVent', + message: 'Add Eyebrow Vent', + })} onClick={() => activateTool('eyebrow-vent')} /> @@ -581,14 +652,18 @@ export default function RoofPanel() { onClick={() => handleSelectElement(gutter.id)} type="button" > - {gutter.name || `Gutter ${i + 1}`} - gutter + + {gutter.name || numberedName(nodeLabel('gutter', 'Gutter', i18n), i + 1, i18n)} + + + {i18n._({ id: 'node.roof.panel.gutter', message: 'gutter' })} + ))} } - label="Add Gutter" + label={i18n._({ id: 'node.roof.panel.addGutter', message: 'Add Gutter' })} onClick={() => activateTool('gutter')} /> @@ -596,18 +671,22 @@ export default function RoofPanel() {
- + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.roof.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.roof.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.roof.panel.delete', message: 'Delete' })} onClick={handleDelete} /> @@ -615,3 +694,8 @@ export default function RoofPanel() { ) } + +/** "Chimney 2": a list row for an element without a name of its own. */ +function numberedName(kind: string, number: number, i18n: PascalI18n): string { + return i18n._({ id: 'node.numberedName', message: '{kind} {number}', values: { kind, number } }) +} diff --git a/packages/nodes/src/shared/item-interaction-actions.tsx b/packages/nodes/src/shared/item-interaction-actions.tsx index c3fde3b654..7ae4533ab7 100644 --- a/packages/nodes/src/shared/item-interaction-actions.tsx +++ b/packages/nodes/src/shared/item-interaction-actions.tsx @@ -1,5 +1,11 @@ 'use client' -import { type AnyNodeId, nodeMechanism, useInteractive, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + nodeMechanism, + useInteractive, + usePascalI18n, + useScene, +} from '@pascal-app/core' import { ActionMenuButton } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Lightbulb, LightbulbOff } from 'lucide-react' @@ -10,6 +16,7 @@ import { itemHasLights, itemLightsOn, toggleItemLights } from './item-interactio * comes from the action menu's `capabilities.mechanism` button. */ export default function ItemInteractionActions() { + const i18n = usePascalI18n() const selected = useViewer((s) => s.selection.selectedIds) const node = useScene((s) => selected.length === 1 ? s.nodes[selected[0] as AnyNodeId] : undefined, @@ -21,7 +28,17 @@ export default function ItemInteractionActions() { { event.stopPropagation() toggleItemLights(node) diff --git a/packages/nodes/src/shared/opening-labels.ts b/packages/nodes/src/shared/opening-labels.ts new file mode 100644 index 0000000000..db574bca31 --- /dev/null +++ b/packages/nodes/src/shared/opening-labels.ts @@ -0,0 +1,50 @@ +import type { MessageDescriptor, WindowNode } from '@pascal-app/core' +import type { DoorStyle, WindowStyle } from '@pascal-app/core/building' + +/** + * The door and window panels' card names. Core's `DOOR_STYLE_LABELS`, + * `WINDOW_STYLE_LABELS` and the placement options stay English: the tool + * chips and agents read them. + */ +export const DOOR_STYLE_MESSAGES: Record = { + panel: /* i18n */ { id: 'node.door.style.panel', message: 'Panel' }, + glass: /* i18n */ { id: 'node.door.style.glass', message: 'Glass' }, + modern: /* i18n */ { id: 'node.door.style.modern', message: 'Modern' }, + 'paneled-glass': /* i18n */ { id: 'node.door.style.paneledGlass', message: 'Paneled glass' }, + french: /* i18n */ { id: 'node.door.style.french', message: 'French' }, + shaker: /* i18n */ { id: 'node.door.style.shaker', message: 'Shaker' }, + 'six-panel': /* i18n */ { id: 'node.door.style.sixPanel', message: 'Six-panel' }, + craftsman: /* i18n */ { id: 'node.door.style.craftsman', message: 'Craftsman' }, + 'half-louvered': /* i18n */ { id: 'node.door.style.halfLouvered', message: 'Half-louvered' }, + barn: /* i18n */ { id: 'node.door.style.barn', message: 'Barn' }, +} + +export const WINDOW_STYLE_MESSAGES: Record = { + single: /* i18n */ { id: 'node.window.style.single', message: 'Single' }, + 'double-hung': /* i18n */ { id: 'node.window.style.doubleHung', message: 'Double-hung' }, + 'triple-hung': /* i18n */ { id: 'node.window.style.tripleHung', message: 'Triple-hung' }, + casement: /* i18n */ { id: 'node.window.style.casement', message: 'Casement' }, + sliding: /* i18n */ { id: 'node.window.style.sliding', message: 'Sliding' }, + grid: /* i18n */ { id: 'node.window.style.grid', message: 'Grid' }, + 'tall-grid': /* i18n */ { id: 'node.window.style.tallGrid', message: 'Tall grid' }, + 'wide-grid': /* i18n */ { id: 'node.window.style.wideGrid', message: 'Wide grid' }, + 'horizontal-bands': /* i18n */ { + id: 'node.window.style.horizontalBands', + message: 'Horizontal bands', + }, + transom: /* i18n */ { id: 'node.window.style.transom', message: 'Transom' }, + picture: /* i18n */ { id: 'node.window.style.picture', message: 'Picture' }, +} + +export const WINDOW_TYPE_MESSAGES: Record = { + fixed: /* i18n */ { id: 'node.window.type.fixed', message: 'Fixed' }, + sliding: /* i18n */ { id: 'node.window.type.sliding', message: 'Sliding' }, + casement: /* i18n */ { id: 'node.window.type.casement', message: 'Casement' }, + awning: /* i18n */ { id: 'node.window.type.awning', message: 'Awning' }, + hopper: /* i18n */ { id: 'node.window.type.hopper', message: 'Hopper' }, + 'single-hung': /* i18n */ { id: 'node.window.type.singleHung', message: 'Single Hung' }, + 'double-hung': /* i18n */ { id: 'node.window.type.doubleHung', message: 'Double Hung' }, + bay: /* i18n */ { id: 'node.window.type.bay', message: 'Bay' }, + bow: /* i18n */ { id: 'node.window.type.bow', message: 'Bow' }, + louvered: /* i18n */ { id: 'node.window.type.louvered', message: 'Louvered' }, +} diff --git a/packages/nodes/src/shared/system-check-panel.tsx b/packages/nodes/src/shared/system-check-panel.tsx index a55609ee74..117596f6ff 100644 --- a/packages/nodes/src/shared/system-check-panel.tsx +++ b/packages/nodes/src/shared/system-check-panel.tsx @@ -4,8 +4,10 @@ import { type AnyNode, type AnyNodeId, emitter, + formatFixed, getLevelElevations, summarizeSystemFor, + usePascalI18n, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { @@ -21,13 +23,40 @@ import { useId, useMemo, useState } from 'react' import { checkDistributionSystems } from './system-checks' const CATEGORIES = [ - { id: 'all', label: 'All findings' }, - { id: 'open-end', label: 'Open ends' }, - { id: 'connection-mismatch', label: 'Connections' }, - { id: 'disconnected-branch', label: 'Separate branches' }, - { id: 'possible-intersection', label: 'Intersections' }, - { id: 'drainage', label: 'Drainage' }, - { id: 'unsupported-hanger', label: 'Hangers' }, + { + id: 'all', + label: /* i18n */ { id: 'node.shared.systemCheckPanel.allFindings', message: 'All findings' }, + }, + { + id: 'open-end', + label: /* i18n */ { id: 'node.shared.systemCheckPanel.openEnds', message: 'Open ends' }, + }, + { + id: 'connection-mismatch', + label: /* i18n */ { id: 'node.shared.systemCheckPanel.connections', message: 'Connections' }, + }, + { + id: 'disconnected-branch', + label: /* i18n */ { + id: 'node.shared.systemCheckPanel.separateBranches', + message: 'Separate branches', + }, + }, + { + id: 'possible-intersection', + label: /* i18n */ { + id: 'node.shared.systemCheckPanel.intersections', + message: 'Intersections', + }, + }, + { + id: 'drainage', + label: /* i18n */ { id: 'node.shared.systemCheckPanel.drainage', message: 'Drainage' }, + }, + { + id: 'unsupported-hanger', + label: /* i18n */ { id: 'node.shared.systemCheckPanel.hangers', message: 'Hangers' }, + }, ] as const const categoryFor = (code: string) => ['slope-too-flat', 'slope-too-steep', 'trap-arm-too-long'].includes(code) ? 'drainage' : code @@ -39,6 +68,7 @@ export default function SystemCheckPanel({ nodeId: AnyNodeId nodes: Record }) { + const i18n = usePascalI18n() const contentId = useId() const [open, setOpen] = useState(false) const [filter, setFilter] = useState('all') @@ -84,7 +114,9 @@ export default function SystemCheckPanel({ onClick={() => setOpen((value) => !value)} >
{ setQuery(event.target.value) setPicked(null) }} - placeholder="1200 W Cass St, Tampa, FL" + placeholder={i18n._({ + id: 'node.site.panel.n1200WCassStTampaFl', + message: '1200 W Cass St, Tampa, FL', + })} value={query} /> @@ -218,7 +235,11 @@ export function SiteNodePanel() { } - label={busy ? 'Finding…' : 'Find parcel'} + label={ + busy + ? i18n._({ id: 'node.site.panel.finding', message: 'Finding…' }) + : i18n._({ id: 'node.site.panel.findParcel', message: 'Find parcel' }) + } onClick={findParcel} /> @@ -227,18 +248,27 @@ export function SiteNodePanel() { ) : null} {node.parcel ? ( - - - + + + - + {node.parcel.notes?.length ? (

{node.parcel.notes.join(' ')} @@ -247,42 +277,60 @@ export function SiteNodePanel() { ) : null} - - {setbackField('front', 'Front')} - {setbackField('side', 'Side')} - {setbackField('rear', 'Rear')} - {setbackField('left', 'Left (override)', true)} - {setbackField('right', 'Right (override)', true)} + + {setbackField('front', i18n._({ id: 'node.site.setback.front', message: 'Front' }))} + {setbackField('side', i18n._({ id: 'node.site.setback.side', message: 'Side' }))} + {setbackField('rear', i18n._({ id: 'node.site.setback.rear', message: 'Rear' }))} + {setbackField( + 'left', + i18n._({ id: 'node.site.setback.leftOverride', message: 'Left (override)' }), + true, + )} + {setbackField( + 'right', + i18n._({ id: 'node.site.setback.rightOverride', message: 'Right (override)' }), + true, + )} - +

- Save the current lighting and display as this project's default. Personal display changes - stay in this browser. + {i18n._({ + id: 'node.site.panel.saveTheCurrentLightingAndDisplay', + message: + "Save the current lighting and display as this project's default. Personal display changes stay in this browser.", + })}

{ const viewer = useViewer.getState() const look = viewer.sceneAppearance @@ -310,32 +358,45 @@ export function SiteNodePanel() { }} /> useViewer.getState().resetProjectLook()} />
- + {node.terrainContours ? (

- {node.terrainContours.lines.length} surveyed lines at {node.terrainContours.intervalFt}{' '} - ft ({node.terrainContours.datum}) — {node.terrainContours.source ?? 'survey'} + {i18n._({ + id: 'node.site.panel.surveyedContours', + message: + '{count, plural, one {# surveyed line} other {# surveyed lines}} at {interval} ft ({datum}) — {source}', + values: { + count: node.terrainContours.lines.length, + interval: node.terrainContours.intervalFt, + datum: node.terrainContours.datum, + source: + node.terrainContours.source ?? + i18n._({ id: 'node.site.panel.survey', message: 'survey' }), + }, + })}

) : (

- No surveyed lines — contours are drawn from the sculpted ground. + {i18n._({ + id: 'node.site.panel.noSurveyedLinesContoursAreDrawn', + message: 'No surveyed lines — contours are drawn from the sculpted ground.', + })}

)}
- +
- +
- Area + {i18n._({ id: 'node.slab.panel.area', message: 'Area' })} {area.toFixed(2)} m²
- + {node.holes && node.holes.length > 0 ? (
{node.holes.map((hole, index) => { @@ -402,12 +449,12 @@ export function SlabPanel() { const isAutoHole = source !== 'manual' || derived const autoLabel = source === 'elevator' - ? 'Auto elevator cutout' + ? i18n._({ id: 'node.cutout.autoElevator', message: 'Auto elevator cutout' }) : source === 'stair' - ? 'Auto stair cutout' + ? i18n._({ id: 'node.cutout.autoStair', message: 'Auto stair cutout' }) : source === 'floor-opening' - ? 'Opening' - : 'Room cutout' + ? i18n._({ id: 'node.cutout.opening', message: 'Opening' }) + : i18n._({ id: 'node.cutout.room', message: 'Room cutout' }) return (
- Hole {index + 1} {isEditing && '(Editing)'} + {isEditing + ? i18n._({ + id: 'node.holeEditing', + message: 'Hole {number} (Editing)', + values: { number: index + 1 }, + }) + : i18n._({ + id: 'node.holeNumber', + message: 'Hole {number}', + values: { number: index + 1 }, + })}

- {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} - {isAutoHole ? autoLabel : 'Manual'} + {i18n._({ + id: 'node.holeSummary', + message: '{area} m² · {count, plural, one {# pt} other {# pts}} · {source}', + values: { + area: formatFixed(holeArea, 2, i18n.getLocale()), + count: hole.length, + source: isAutoHole + ? autoLabel + : i18n._({ id: 'node.hole.manual', message: 'Manual' }), + }, + })}

{isEditing ? ( useInteractionScope .getState() @@ -452,7 +518,7 @@ export function SlabPanel() { ) : isAutoHole ? (
- Auto + {i18n._({ id: 'node.slab.panel.auto', message: 'Auto' })}
) : ( <> @@ -478,7 +544,9 @@ export function SlabPanel() { })}
) : ( -
No holes
+
+ {i18n._({ id: 'node.slab.panel.noHoles', message: 'No holes' })} +
)}
@@ -488,7 +556,11 @@ export function SlabPanel() { openingZoneId && startOpeningDraft(openingZoneId, 'floor', shape)} /> ) : ( @@ -496,14 +568,18 @@ export function SlabPanel() { className="w-full" disabled={editingHole?.nodeId === selectedId} icon={} - label="Add Hole" + label={i18n._({ id: 'node.slab.panel.addHole', message: 'Add Hole' })} onClick={handleAddHole} /> )}
- } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.slab.panel.move', message: 'Move' })} + onClick={handleMove} + /> ) diff --git a/packages/nodes/src/stair-segment/panel.tsx b/packages/nodes/src/stair-segment/panel.tsx index 04d99703a7..f2c5a38029 100644 --- a/packages/nodes/src/stair-segment/panel.tsx +++ b/packages/nodes/src/stair-segment/panel.tsx @@ -5,12 +5,14 @@ import { type AnyNodeId, type AttachmentSide, DEFAULT_LEVEL_HEIGHT, + type MessageDescriptor, planStairFlightHeightEdit, resolveStairTotalRise, type StairSegmentNode, type StairSegmentType, stairSegmentConstructionError, stairSegmentDetailError, + usePascalI18n, useScene, } from '@pascal-app/core' import { @@ -30,18 +32,22 @@ import { Copy, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { StairConstructionControls } from '../stair/construction-controls' -const SEGMENT_TYPE_OPTIONS: { label: string; value: StairSegmentType }[] = [ - { label: 'Flight', value: 'stair' }, - { label: 'Landing', value: 'landing' }, +const SEGMENT_TYPE_OPTIONS: { label: MessageDescriptor; value: StairSegmentType }[] = [ + { label: /* i18n */ { id: 'node.stairSegment.panel.flight', message: 'Flight' }, value: 'stair' }, + { + label: /* i18n */ { id: 'node.stairSegment.panel.landing', message: '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: { label: MessageDescriptor; value: AttachmentSide }[] = [ + { label: /* i18n */ { id: 'node.stairSegment.panel.front', message: 'Front' }, value: 'front' }, + { label: /* i18n */ { id: 'node.stairSegment.panel.left', message: 'Left' }, value: 'left' }, + { label: /* i18n */ { id: 'node.stairSegment.panel.right', message: 'Right' }, value: 'right' }, ] export default function StairSegmentPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) @@ -147,10 +153,13 @@ export default function StairSegmentPanel() { icon="/icons/stairs.webp" onBack={handleBack} onClose={handleClose} - title={node.name || 'Stair Segment'} + title={ + node.name || + i18n._({ id: 'node.stairSegment.panel.stairSegment', message: 'Stair Segment' }) + } width={300} > - + { const updates: Partial = { segmentType: v } @@ -166,16 +175,24 @@ export default function StairSegmentPanel() { } handleUpdate(updates) }} - options={SEGMENT_TYPE_OPTIONS} + options={SEGMENT_TYPE_OPTIONS.map((option) => ({ + ...option, + label: i18n._(option.label), + }))} value={node.segmentType} /> {!isFirstSegment && ( - + handleUpdate({ attachmentSide: v })} - options={ATTACHMENT_SIDE_OPTIONS} + options={ATTACHMENT_SIDE_OPTIONS.map((option) => ({ + ...option, + label: i18n._(option.label), + }))} value={node.attachmentSide} /> @@ -187,7 +204,9 @@ export default function StairSegmentPanel() {
) : null} {node.segmentType === 'stair' && ( - + @@ -204,8 +223,14 @@ export default function StairSegmentPanel() { }) } options={[ - { label: 'Straight', value: 'straight' }, - { label: 'Winder', value: 'winder' }, + { + label: i18n._({ id: 'node.stairSegment.panel.straight', message: 'Straight' }), + value: 'straight', + }, + { + label: i18n._({ id: 'node.stairSegment.panel.winder', message: 'Winder' }), + value: 'winder', + }, ]} /> {node.winder && ( @@ -214,12 +239,21 @@ export default function StairSegmentPanel() { value={node.winder.turn} onChange={(turn) => handleUpdate({ winder: { ...node.winder!, turn } })} options={[ - { label: 'Left turn', value: 'left' }, - { label: 'Right turn', value: 'right' }, + { + label: i18n._({ id: 'node.stairSegment.panel.leftTurn', message: 'Left turn' }), + value: 'left', + }, + { + label: i18n._({ + id: 'node.stairSegment.panel.rightTurn', + message: 'Right turn', + }), + value: 'right', + }, ]} /> handleUpdate({ winder: { ...node.winder!, innerGap } })} /> handleUpdate({ winder: { ...node.winder!, division } })} options={[ - { label: 'Equal going', value: 'equal-going' }, - { label: 'Equal angle', value: 'equal-angle' }, + { + label: i18n._({ + id: 'node.stairSegment.panel.equalGoing', + message: 'Equal going', + }), + value: 'equal-going', + }, + { + label: i18n._({ + id: 'node.stairSegment.panel.equalAngle', + message: 'Equal angle', + }), + value: 'equal-angle', + }, ]} /> )} )} - + handleUpdate({ width: v })} precision={2} @@ -263,7 +314,7 @@ export default function StairSegmentPanel() { /> {!node.winder && ( handleUpdate({ length: v })} precision={2} @@ -275,7 +326,7 @@ export default function StairSegmentPanel() { {node.segmentType === 'stair' && ( <> {parentFollowsLevel && (
- Editing switches the stair to Custom rise + {i18n._({ + id: 'node.stairSegment.panel.editingSwitchesTheStairToCustom', + message: 'Editing switches the stair to Custom rise', + })}
)} handleUpdate({ stepCount: Math.round(v) })} precision={0} @@ -301,7 +355,9 @@ export default function StairSegmentPanel() { )}
- +
handleUpdate({ fillToFloor: checked })} /> ) : null} {!(node.construction ?? parentConstruction) && !node.fillToFloor && ( handleUpdate({ thickness: v })} precision={2} @@ -329,7 +388,7 @@ export default function StairSegmentPanel() {
- + { @@ -367,7 +426,7 @@ export default function StairSegmentPanel() { value={node.position[2]} /> { @@ -396,18 +455,22 @@ export default function StairSegmentPanel() {
- + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.stairSegment.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.stairSegment.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.stairSegment.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/stair/construction-controls.tsx b/packages/nodes/src/stair/construction-controls.tsx index 86daabfc95..b359b5667e 100644 --- a/packages/nodes/src/stair/construction-controls.tsx +++ b/packages/nodes/src/stair/construction-controls.tsx @@ -5,17 +5,42 @@ import { StairConstruction, type StairNode, type StairSegmentNode, + usePascalI18n, useScene, } from '@pascal-app/core' import { MetricControl, SelectControl, ToggleControl } from '@pascal-app/editor' const OPTIONS = [ - { label: 'Original', value: 'original' }, - { label: 'Solid', value: 'solid' }, - { label: 'Waist', value: 'waist' }, - { label: 'Open', value: 'open' }, - { label: 'Side stringers', value: 'side-stringers' }, - { label: 'Center stringer', value: 'center-stringer' }, + { + label: /* i18n */ { id: 'node.stair.constructionControls.original', message: 'Original' }, + value: 'original', + }, + { + label: /* i18n */ { id: 'node.stair.constructionControls.solid', message: 'Solid' }, + value: 'solid', + }, + { + label: /* i18n */ { id: 'node.stair.constructionControls.waist', message: 'Waist' }, + value: 'waist', + }, + { + label: /* i18n */ { id: 'node.stair.constructionControls.open', message: 'Open' }, + value: 'open', + }, + { + label: /* i18n */ { + id: 'node.stair.constructionControls.sideStringers', + message: 'Side stringers', + }, + value: 'side-stringers', + }, + { + label: /* i18n */ { + id: 'node.stair.constructionControls.centerStringer', + message: 'Center stringer', + }, + value: 'center-stringer', + }, ] export function StairConstructionControls({ @@ -27,16 +52,25 @@ export function StairConstructionControls({ inherited?: StairConstruction onChange: (construction: StairConstruction | undefined) => void }) { + const i18n = usePascalI18n() const effective = node.construction ?? inherited const update = (patch: Partial) => onChange(StairConstruction.parse({ ...effective, ...patch })) return (
- option.value === 'original' && inherited ? { ...option, label: 'Inherit' } : option, + option.value === 'original' && inherited + ? { + ...option, + label: i18n._({ + id: 'node.stair.constructionControls.inherit', + message: 'Inherit', + }), + } + : { ...option, label: i18n._(option.label) }, )} onChange={(mode) => mode === 'original' @@ -48,7 +82,10 @@ export function StairConstructionControls({ <> {effective.mode === 'waist' ? ( update({ waistThickness })} @@ -58,7 +95,10 @@ export function StairConstructionControls({ /> ) : null} update({ treadThickness })} @@ -67,7 +107,7 @@ export function StairConstructionControls({ precision={3} /> update({ nosing })} @@ -76,7 +116,10 @@ export function StairConstructionControls({ precision={3} /> update({ finishThickness })} @@ -87,13 +130,19 @@ export function StairConstructionControls({ {effective.mode === 'solid' || effective.mode === 'waist' ? null : ( <> update({ closedRisers })} /> {effective.closedRisers ? ( update({ riserThickness })} @@ -107,7 +156,10 @@ export function StairConstructionControls({ {effective.mode === 'side-stringers' || effective.mode === 'center-stringer' ? ( <> update({ stringerWidth })} @@ -116,7 +168,10 @@ export function StairConstructionControls({ precision={3} /> update({ stringerDepth })} diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 32e42de676..bb166652f0 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -6,6 +6,7 @@ import { getBoundarySurfaces, getLevelDisplayName, type LevelNode, + type MessageDescriptor, measureStair, planStairPreset, planStairRiseEdit, @@ -24,6 +25,7 @@ import { type StairSlabOpeningMode, type StairTopLandingMode, type StairType, + usePascalI18n, useRegistryVersion, useScene, } from '@pascal-app/core' @@ -55,35 +57,102 @@ import { getStairDestinationUpdates } from './destination' import { StairRailingControls } from './railing-controls' import { getStairTypeChange } from './stair-type' -const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ - { label: 'None', value: 'none' }, - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - { label: 'Both', value: 'both' }, +/** Core's stair diagnostics in the UI language; codes without fixed text show core's message. */ +const STAIR_DIAGNOSTIC_MESSAGES: Partial> = { + 'invalid-rise': /* i18n */ { + id: 'node.stair.diagnostic.invalidRise', + message: 'The destination must be above the stair base.', + }, + 'landing-overlap': /* i18n */ { + id: 'node.stair.diagnostic.landingOverlap', + message: + 'Landing depth exceeds a full circle at the walking line; the visible landing is limited to one circle.', + }, + 'narrow-winder-end': /* i18n */ { + id: 'node.stair.diagnostic.narrowWinderEnd', + message: 'The narrow end of a winder tread is below 0.10 m; check the intended stair use.', + }, + 'invalid-flight': /* i18n */ { + id: 'node.stair.diagnostic.invalidFlight', + message: 'Flight dimensions and integer riser count must be positive.', + }, + 'riser-target': /* i18n */ { + id: 'node.stair.diagnostic.riserTarget', + message: 'Riser height exceeds the design target.', + }, + 'going-target': /* i18n */ { + id: 'node.stair.diagnostic.goingTarget', + message: 'Going is below the design target.', + }, + 'comfort-target': /* i18n */ { + id: 'node.stair.diagnostic.comfortTarget', + message: 'Two risers plus going is outside the 0.60–0.65 m design range.', + }, + 'nonuniform-risers': /* i18n */ { + id: 'node.stair.diagnostic.nonuniformRisers', + message: + 'Flight riser heights differ. Use uniform-riser repair only if this is a proposed design.', + }, + 'raised-landing': /* i18n */ { + id: 'node.stair.diagnostic.raisedLanding', + message: 'A landing has a stored rise; inspect its transition before repair.', + }, + 'arrival-mismatch': /* i18n */ { + id: 'node.stair.diagnostic.arrivalMismatch', + message: 'The flight chain does not reach the declared destination.', + }, + 'headroom-unresolved': /* i18n */ { + id: 'node.stair.diagnostic.headroomUnresolved', + message: + 'Detailed headroom could not be evaluated: check riser counts and the surface query budget.', + }, + 'headroom-target': /* i18n */ { + id: 'node.stair.diagnostic.headroomTarget', + message: 'Overhead surfaces leave less than the target headroom.', + }, +} + +const RAILING_MODE_OPTIONS: { label: MessageDescriptor; value: StairRailingMode }[] = [ + { label: /* i18n */ { id: 'node.stair.panel.none', message: 'None' }, value: 'none' }, + { label: /* i18n */ { id: 'node.stair.panel.left', message: 'Left' }, value: 'left' }, + { label: /* i18n */ { id: 'node.stair.panel.right', message: 'Right' }, value: 'right' }, + { label: /* i18n */ { id: 'node.stair.panel.both', message: 'Both' }, value: 'both' }, ] -const RAILING_STYLE_OPTIONS: { label: string; value: StairRailingStyle }[] = [ - { label: 'Balusters', value: 'balusters' }, - { label: 'Post & rail', value: 'post-and-rail' }, - { label: 'Cable', value: 'cable' }, - { label: 'Boards', value: 'boards' }, - { label: 'Glass', value: 'glass' }, - { label: 'Metal', value: 'metal' }, +const RAILING_STYLE_OPTIONS: { label: MessageDescriptor; value: StairRailingStyle }[] = [ + { + label: /* i18n */ { id: 'node.stair.panel.balusters', message: 'Balusters' }, + value: 'balusters', + }, + { + label: /* i18n */ { id: 'node.stair.panel.postRail', message: 'Post & rail' }, + value: 'post-and-rail', + }, + { label: /* i18n */ { id: 'node.stair.panel.cable', message: 'Cable' }, value: 'cable' }, + { label: /* i18n */ { id: 'node.stair.panel.boards', message: 'Boards' }, value: 'boards' }, + { label: /* i18n */ { id: 'node.stair.panel.glass', message: 'Glass' }, value: 'glass' }, + { label: /* i18n */ { id: 'node.stair.panel.metal', message: 'Metal' }, value: 'metal' }, ] -const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [ - { label: 'Straight', value: 'straight' }, - { label: 'Curved', value: 'curved' }, - { label: 'Spiral', value: 'spiral' }, +const STAIR_TYPE_OPTIONS: { label: MessageDescriptor; value: StairType }[] = [ + { label: /* i18n */ { id: 'node.stair.panel.straight', message: 'Straight' }, value: 'straight' }, + { label: /* i18n */ { id: 'node.stair.panel.curved', message: 'Curved' }, value: 'curved' }, + { label: /* i18n */ { id: 'node.stair.panel.spiral', message: '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: { label: MessageDescriptor; value: StairTopLandingMode }[] = [ + { label: /* i18n */ { id: 'node.stair.panel.none', message: 'None' }, value: 'none' }, + { + label: /* i18n */ { id: 'node.stair.panel.integrated', message: '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: { label: MessageDescriptor; value: StairSlabOpeningMode }[] = [ + { label: /* i18n */ { id: 'node.stair.panel.none', message: 'None' }, value: 'none' }, + { + label: /* i18n */ { id: 'node.stair.panel.destination', message: 'Destination' }, + value: 'destination', + }, ] // Slabs at least this high off the storey floor read as decks (mezzanines) — @@ -91,6 +160,7 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode } const DECK_DESTINATION_MIN_ELEVATION = 0.5 export default function StairPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const unit = useViewer((s) => s.unit) @@ -390,38 +460,50 @@ export default function StairPanel() { - + ({ ...option, label: i18n._(option.label) }))} value={node.stairType ?? 'straight'} /> - + {turningStrategy === 'winder' && ( <> @@ -451,12 +542,22 @@ export default function StairPanel() {
{layoutOptions.map((option) => { const label = - option.layout === 'straight' ? 'Straight' : `${option.layout.toUpperCase()} turn` + option.layout === 'straight' + ? i18n._({ id: 'node.stair.layout.straight', message: 'Straight' }) + : i18n._({ + id: 'node.stair.layout.turn', + message: '{layout} turn', + values: { layout: option.layout.toUpperCase() }, + }) return ( ) @@ -489,51 +590,78 @@ export default function StairPanel() {
- +
- Measurements + + {i18n._({ id: 'node.stair.panel.measurements', message: 'Measurements' })} +
{measurements.flights.map((flight, index) => (
- Flight {index + 1}: {flight.count} risers · R{' '} - {flight.riserHeight === null - ? '—' - : formatLinearMeasurement(flight.riserHeight, unit, metricNotation)}{' '} - · T{' '} - {flight.going === null - ? '—' - : formatLinearMeasurement(flight.going, unit, metricNotation)}{' '} - · {flight.slope === null ? '—' : `${((flight.slope * 180) / Math.PI).toFixed(1)}°`} + {i18n._({ + id: 'node.stair.panel.flightMeasurements', + message: + 'Flight {number}: {count, plural, one {# riser} other {# risers}} · R {riser} · T {going} · {slope}', + values: { + number: index + 1, + count: flight.count, + riser: + flight.riserHeight === null + ? '—' + : formatLinearMeasurement(flight.riserHeight, unit, metricNotation), + going: + flight.going === null + ? '—' + : formatLinearMeasurement(flight.going, unit, metricNotation), + slope: + flight.slope === null + ? '—' + : `${((flight.slope * 180) / Math.PI).toFixed(1)}°`, + }, + })}
))}
- Headroom:{' '} + {i18n._({ id: 'node.stair.panel.headroom', message: 'Headroom:' })}{' '} {measurements.headroom.status === 'unresolved' - ? 'Not evaluated' + ? i18n._({ id: 'node.stair.panel.notEvaluated', message: 'Not evaluated' }) : measurements.headroom.minimum === null - ? 'No overhead surface' + ? i18n._({ + id: 'node.stair.panel.noOverheadSurface', + message: 'No overhead surface', + }) : formatLinearMeasurement(measurements.headroom.minimum, unit, metricNotation)}
-
Floors, ceilings and stairs only.
+
+ {i18n._({ + id: 'node.stair.panel.floorsCeilingsAndStairsOnly', + message: 'Floors, ceilings and stairs only.', + })} +
- {[...new Set(measurements.diagnostics.map((diagnostic) => diagnostic.message))].map( - (message) => ( -
- {message} -
+ {[ + ...new Set( + measurements.diagnostics.map((diagnostic) => { + const message = STAIR_DIAGNOSTIC_MESSAGES[diagnostic.code] + return message ? i18n._(message) : diagnostic.message + }), ), - )} + ].map((message) => ( +
+ {message} +
+ ))}
- Design targets + {i18n._({ id: 'node.stair.panel.designTargets', message: 'Design targets' })} applySizing(false)} /> applySizing(true)} /> {node.stairType === 'straight' && (boundarySurfaces.length > 0 || node.destinationSurfaceId) && ( - +
handleUpdate({ autoSurfaceSnap: checked })} />

{node.autoSurfaceSnap !== false ? connectedSurface - ? `Connected to ${connectedSurface.name || 'surface'}. Move the stair to connect to another edge.` - : 'Drag near a deck, patio, concrete slab, or landing edge to fit the stair.' - : 'Moving this stair keeps its current rise, steps, and run.'} + ? connectedSurface.name + ? i18n._({ + id: 'node.stair.panel.connectedTo', + message: + 'Connected to {surface}. Move the stair to connect to another edge.', + values: { surface: connectedSurface.name }, + }) + : i18n._({ + id: 'node.stair.panel.connectedToSurface', + message: + 'Connected to a surface. Move the stair to connect to another edge.', + }) + : i18n._({ + id: 'node.stair.panel.dragNearADeckPatioConcrete', + message: + 'Drag near a deck, patio, concrete slab, or landing edge to fit the stair.', + }) + : i18n._({ + id: 'node.stair.panel.movingThisStairKeepsItsCurrent', + message: 'Moving this stair keeps its current rise, steps, and run.', + })}

)} - +
{attachedDeck ? null : ( )}
- From Level + {i18n._({ id: 'node.stair.panel.fromLevel', message: 'From Level' })}
( ))} @@ -659,7 +819,7 @@ export default function StairPanel() {
- Rise + {i18n._({ id: 'node.stair.panel.rise', message: 'Rise' })}
@@ -668,18 +828,30 @@ export default function StairPanel() { ) } options={[ - { label: attachedDeck ? 'Follows deck' : 'Follows level', value: 'follows' }, - { label: 'Custom rise', value: 'custom' }, + { + label: attachedDeck + ? i18n._({ id: 'node.stair.panel.followsDeck', message: 'Follows deck' }) + : i18n._({ id: 'node.stair.panel.followsLevel', message: 'Follows level' }), + value: 'follows', + }, + { + label: i18n._({ id: 'node.stair.panel.customRise', message: 'Custom rise' }), + value: 'custom', + }, ]} value={node.totalRise == null ? 'follows' : 'custom'} /> {node.totalRise == null ? (
- Currently {formatLinearMeasurement(resolvedRise, unit, metricNotation)} + {i18n._({ + id: 'node.panel.currently', + message: 'Currently {value}', + values: { value: formatLinearMeasurement(resolvedRise, unit, metricNotation) }, + })}
) : ( handleUpdate({ totalRise: value })} precision={2} @@ -700,13 +872,19 @@ export default function StairPanel() { <> handleAutoCutoutChange(value === 'destination')} - options={STAIR_SLAB_OPENING_OPTIONS} + options={STAIR_SLAB_OPENING_OPTIONS.map((option) => ({ + ...option, + label: i18n._(option.label), + }))} value={node.slabOpeningMode ?? 'none'} /> {(node.slabOpeningMode ?? 'none') === 'destination' ? ( handleUpdate({ openingOffset: value })} precision={2} @@ -722,17 +900,20 @@ export default function StairPanel() { <>
- Landing + {i18n._({ id: 'node.stair.panel.landing', message: 'Landing' })}
handleUpdate({ topLandingMode: value })} - options={TOP_LANDING_MODE_OPTIONS} + options={TOP_LANDING_MODE_OPTIONS.map((option) => ({ + ...option, + label: i18n._(option.label), + }))} value={node.topLandingMode ?? 'none'} />
{(node.topLandingMode ?? 'none') === 'integrated' && ( handleUpdate({ topLandingDepth: value })} @@ -748,7 +929,7 @@ export default function StairPanel() { {node.stairType === 'straight' && ( - +
{segments.map((seg, i) => ( ))}
} - label="Add flight" + label={i18n._({ id: 'node.stair.panel.addFlight', message: 'Add flight' })} onClick={handleAddFlight} /> } - label="Add landing" + label={i18n._({ id: 'node.stair.panel.addLanding', message: 'Add landing' })} onClick={handleAddLanding} />
@@ -778,9 +972,9 @@ export default function StairPanel() { )} {(node.stairType === 'curved' || node.stairType === 'spiral') && ( - + handleUpdate({ width: value })} precision={2} @@ -789,7 +983,7 @@ export default function StairPanel() { value={node.width ?? 1} /> handleUpdate({ stepCount: Math.max(2, Math.round(value)) })} precision={0} @@ -800,13 +994,13 @@ export default function StairPanel() { {node.stairType !== 'spiral' && ( handleUpdate({ fillToFloor: checked })} /> )} {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && ( handleUpdate({ thickness: value })} precision={2} @@ -816,7 +1010,7 @@ export default function StairPanel() { /> )} handleUpdate({ innerRadius: value })} precision={2} @@ -832,12 +1026,21 @@ export default function StairPanel() { }) } options={[ - { label: 'Clockwise', value: 'positive' }, - { label: 'Counterclockwise', value: 'negative' }, + { + label: i18n._({ id: 'node.stair.panel.clockwise', message: 'Clockwise' }), + value: 'positive', + }, + { + label: i18n._({ + id: 'node.stair.panel.counterclockwise', + message: 'Counterclockwise', + }), + value: 'negative', + }, ]} /> handleUpdate({ @@ -853,12 +1056,12 @@ export default function StairPanel() { <> handleUpdate({ showCenterColumn: checked })} /> handleUpdate({ showStepSupports: checked })} /> @@ -866,13 +1069,15 @@ export default function StairPanel() { )} - + handleUpdate({ construction })} /> - + { @@ -910,7 +1115,7 @@ export default function StairPanel() { value={node.position[2]} /> { @@ -939,28 +1144,34 @@ export default function StairPanel() {
- + handleUpdate({ railingMode: value })} - options={RAILING_MODE_OPTIONS} + options={RAILING_MODE_OPTIONS.map((option) => ({ + ...option, + label: i18n._(option.label), + }))} value={node.railingMode ?? 'none'} /> {(node.railingMode ?? 'none') !== 'none' && ( <> handleUpdate({ railingStyle: value, ...(value === 'glass' || value === 'metal' ? { railingPath: 'continuous' } : {}), }) } - options={RAILING_STYLE_OPTIONS} + options={RAILING_STYLE_OPTIONS.map((option) => ({ + ...option, + label: i18n._(option.label), + }))} value={node.railingStyle ?? 'balusters'} /> handleUpdate({ railingHeight: value })} precision={2} @@ -972,18 +1183,22 @@ export default function StairPanel() { )} - + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.stair.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.stair.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.stair.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/stair/railing-controls.tsx b/packages/nodes/src/stair/railing-controls.tsx index f5d5c28e7d..cd17736483 100644 --- a/packages/nodes/src/stair/railing-controls.tsx +++ b/packages/nodes/src/stair/railing-controls.tsx @@ -1,12 +1,15 @@ 'use client' -import { type AnyNodeId, type StairNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, type StairNode, usePascalI18n, useScene } from '@pascal-app/core' import { MetricControl, SegmentedControl, ToggleControl } from '@pascal-app/editor' const MODES = [ - { label: 'None', value: 'none' }, - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - { label: 'Both', value: 'both' }, + { label: /* i18n */ { id: 'node.stair.railingControls.none', message: 'None' }, value: 'none' }, + { label: /* i18n */ { id: 'node.stair.railingControls.left', message: 'Left' }, value: 'left' }, + { + label: /* i18n */ { id: 'node.stair.railingControls.right', message: 'Right' }, + value: 'right', + }, + { label: /* i18n */ { id: 'node.stair.railingControls.both', message: 'Both' }, value: 'both' }, ] as const export function StairRailingControls({ node, @@ -15,6 +18,7 @@ export function StairRailingControls({ node: StairNode onChange: (patch: Partial) => void }) { + const i18n = usePascalI18n() const config = node.handrail return (
@@ -25,8 +29,17 @@ export function StairRailingControls({ : (node.railingPath ?? 'original') } options={[ - { label: 'Original layout', value: 'original' }, - { label: 'Continuous', value: 'continuous' }, + { + label: i18n._({ + id: 'node.stair.railingControls.originalLayout', + message: 'Original layout', + }), + value: 'original', + }, + { + label: i18n._({ id: 'node.stair.railingControls.continuous', message: 'Continuous' }), + value: 'continuous', + }, ]} onChange={(value) => onChange({ @@ -39,7 +52,10 @@ export function StairRailingControls({ } /> onChange({ @@ -53,7 +69,7 @@ export function StairRailingControls({ <> ({ ...option, label: i18n._(option.label) }))} onChange={(value) => onChange({ handrail: { ...config, mode: value as StairNode['railingMode'] } }) } @@ -63,10 +79,19 @@ export function StairRailingControls({ key={key} label={ key === 'height' - ? 'Handrail height' + ? i18n._({ + id: 'node.stair.railingControls.handrailHeight', + message: 'Handrail height', + }) : key === 'diameter' - ? 'Handrail diameter' - : 'Handrail inset' + ? i18n._({ + id: 'node.stair.railingControls.handrailDiameter', + message: 'Handrail diameter', + }) + : i18n._({ + id: 'node.stair.railingControls.handrailInset', + message: 'Handrail inset', + }) } value={config[key]} min={key === 'offset' ? 0 : 0.001} @@ -87,7 +112,17 @@ export function StairRailingControls({ return (
update({ return: value as typeof detail.return })} /> {detail.return === 'wall' || detail.return === 'post' ? ( <> update({ returnLength })} />

- Set the length to reach the wall or post. + {i18n._({ + id: 'node.stair.railingControls.setTheLengthToReachThe', + message: 'Set the length to reach the wall or post.', + })}

) : null} diff --git a/packages/nodes/src/unit/unit-panel.tsx b/packages/nodes/src/unit/unit-panel.tsx index a5cfcde138..d3218a8161 100644 --- a/packages/nodes/src/unit/unit-panel.tsx +++ b/packages/nodes/src/unit/unit-panel.tsx @@ -5,15 +5,19 @@ import { type AnyNodeId, buildUnitReport, getLevelDisplayName, + type MessageDescriptor, + type PascalI18n, UNIT_KINDS, type UnitKind, type UnitNode, unitWarnings, + usePascalI18n, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, + displayRoomName, formatAreaLabel, PanelSection, PanelWrapper, @@ -24,15 +28,13 @@ import { Trash2, X } from 'lucide-react' import { useCallback, useMemo } from 'react' import { PanelSelect, PanelTextField } from '../shared/panel-fields' -const UNIT_KIND_LABELS: Record = { - apartment: 'Apartment', - 'hotel-room': 'Hotel room', - commercial: 'Commercial', - common: 'Common', +const UNIT_KIND_LABELS: Record = { + apartment: /* i18n */ { id: 'node.unit.kind.apartment', message: 'Apartment' }, + 'hotel-room': /* i18n */ { id: 'node.unit.kind.hotelRoom', message: 'Hotel room' }, + commercial: /* i18n */ { id: 'node.unit.kind.commercial', message: 'Commercial' }, + common: /* i18n */ { id: 'node.unit.kind.common', message: 'Common' }, } -const UNIT_KIND_OPTIONS = UNIT_KINDS.map((value) => ({ label: UNIT_KIND_LABELS[value], value })) - function ReportRow({ label, value }: { label: string; value: string }) { return (
@@ -43,6 +45,7 @@ function ReportRow({ label, value }: { label: string; value: string }) { } export default function UnitPanel({ node }: { node: UnitNode }) { + const i18n = usePascalI18n() const nodes = useScene((state) => state.nodes) const updateNode = useScene((state) => state.updateNode) const setSelection = useViewer((state) => state.setSelection) @@ -98,28 +101,37 @@ export default function UnitPanel({ node }: { node: UnitNode }) { const warningMessages = warnings.map((warning) => { if (warning.code === 'non-adjacent-levels') { - return 'Members sit on levels that are not adjacent.' + return i18n._({ + id: 'node.unit.panel.nonAdjacentLevels', + message: 'Members sit on levels that are not adjacent.', + }) } - return 'No zones assigned yet.' + return i18n._({ id: 'node.unit.panel.noZonesAssigned', message: 'No zones assigned yet.' }) }) return ( - - update({ name })} value={node.name} /> + + update({ name })} + value={node.name} + /> update({ kind: kind as UnitKind })} - options={UNIT_KIND_OPTIONS} + options={UNIT_KINDS.map((value) => ({ label: i18n._(UNIT_KIND_LABELS[value]), value }))} value={node.kind} /> - + {report.members.length === 0 ? (

- No rooms yet. Pick this unit from a room's panel, or draw zones while it is focused. + {i18n._({ + id: 'node.unit.unitPanel.noRoomsYetPickThisUnit', + message: + "No rooms yet. Pick this unit from a room's panel, or draw zones while it is focused.", + })}

) : ( report.members.map((member) => ( @@ -144,7 +160,9 @@ export default function UnitPanel({ node }: { node: UnitNode }) { key={member.zoneId} > - {member.name || memberFallbackName(nodes, member.zoneId)} + {member.name + ? displayRoomName(member.name, i18n) + : memberFallbackName(nodes, member.zoneId, i18n)} {member.levelId ? levelNameById.get(member.levelId) : ''} @@ -153,7 +171,15 @@ export default function UnitPanel({ node }: { node: UnitNode }) { {formatAreaLabel(member.areaM2, areaUnit, 1)}
)}

- Column {column + 1}, row {row + 1} ·{' '} - {overridden ? 'Custom infill' : 'Following wall defaults'} + {i18n._({ + id: 'node.wall.curtainWallPanel.columnRow', + message: 'Column {column}, row {row}', + values: { column: column + 1, row: row + 1 }, + })}{' '} + ·{' '} + {overridden + ? i18n._({ + id: 'node.wall.curtainWallPanel.customInfill', + message: 'Custom infill', + }) + : i18n._({ + id: 'node.wall.curtainWallPanel.followingWallDefaults', + message: 'Following wall defaults', + })}

setColumn(Math.round(value) - 1)} @@ -538,7 +728,7 @@ export function CurtainWallPanel({ value={column + 1} /> setRow(Math.round(value) - 1)} @@ -547,9 +737,12 @@ export function CurtainWallPanel({ value={row + 1} /> ({ ...option, label: i18n._(option.label) }))} value={selectedType} />

- Use the Door or Window tool to add an opening through the wall. + {i18n._({ + id: 'node.wall.curtainWallPanel.useTheDoorOrWindowTool', + message: 'Use the Door or Window tool to add an opening through the wall.', + })}

)} {hasGlass && ( - + {paintedGlass && (
-

Glass appearance is controlled by the paint tool.

+

+ {i18n._({ + id: 'node.wall.curtainWallPanel.glassAppearanceIsControlledByThe', + message: 'Glass appearance is controlled by the paint tool.', + })} +

)} @@ -592,7 +804,7 @@ export function CurtainWallPanel({ onCancel={onCancel} restoreOnCommit={false} previewWhileTyping - label="Opacity" + label={i18n._({ id: 'node.wall.curtainWallPanel.opacity', message: 'Opacity' })} max={100} min={5} onChange={(value) => update({ glassOpacity: value / 100 }, true)} @@ -606,7 +818,10 @@ export function CurtainWallPanel({ onCancel={onCancel} restoreOnCommit={false} previewWhileTyping - label="Surface roughness" + label={i18n._({ + id: 'node.wall.curtainWallPanel.surfaceRoughness', + message: 'Surface roughness', + })} max={1} min={0} onChange={(glassRoughness) => update({ glassRoughness }, true)} @@ -615,9 +830,12 @@ export function CurtainWallPanel({ value={config.glassRoughness} />
)}

- Low roughness is smooth; high roughness gives a matte surface in rendered shading. + {i18n._({ + id: 'node.wall.curtainWallPanel.lowRoughnessIsSmoothHighRoughness', + message: + 'Low roughness is smooth; high roughness gives a matte surface in rendered shading.', + })}

)} diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 72fa69193e..7aab6e4e4c 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -14,6 +14,7 @@ import { getMaxWallCurveOffset, getWallAssemblyPreset, getWallCurveLength, + type MessageDescriptor, normalizeWallCurveOffset, resolveWallAssembly, runAsSingleSceneHistoryStep, @@ -22,6 +23,7 @@ import { STUCCO_3_COAT, terrainSupportLift, useLiveNodeOverrides, + usePascalI18n, useScene, WALL_ASSEMBLY_PRESETS, WALL_CHAIR_RAIL_DEFAULT, @@ -68,7 +70,7 @@ import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' import { CurtainWallPanel } from './curtain-wall-panel' import { hasWallCurveBlockingChildren } from './curve-eligibility' import { buildWallLengthPatch } from './length-patch' -import { wallReferenceModel } from './panel-model' +import { type WallReferenceValue, wallReferenceModel } from './panel-model' import { createWallPropertyPreview } from './property-preview' /** @@ -89,36 +91,57 @@ function wallBaseRepairPatch(n: WallNode): Partial { } } +const WALL_REFERENCE_LABELS: Record = { + outside: /* i18n */ { id: 'node.wall.reference.outside', message: 'Outside face' }, + center: /* i18n */ { id: 'node.wall.reference.center', message: 'Center' }, + inside: /* i18n */ { id: 'node.wall.reference.inside', message: 'Inside face' }, + left: /* i18n */ { id: 'node.wall.reference.left', message: 'Left' }, + right: /* i18n */ { id: 'node.wall.reference.right', message: 'Right' }, +} + type WallTrimKey = 'skirting' | 'crown' | 'chairRail' const WALL_TRIM_PROFILE_OPTIONS: Record< WallTrimKey, - Array<{ label: string; value: WallTrimProfile }> + Array<{ label: MessageDescriptor; 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' }, + { label: /* i18n */ { id: 'node.wall.panel.flat', message: 'Flat' }, value: 'flat' }, + { label: /* i18n */ { id: 'node.wall.panel.modern', message: 'Modern' }, value: 'base-modern' }, + { + label: /* i18n */ { id: 'node.wall.panel.colonial', message: 'Colonial' }, + value: 'base-colonial', + }, + { label: /* i18n */ { id: 'node.wall.panel.shoe', message: 'Shoe' }, value: 'base-shoe' }, + { label: /* i18n */ { id: 'node.wall.panel.ogee', message: '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' }, + { label: /* i18n */ { id: 'node.wall.panel.flat', message: 'Flat' }, value: 'flat' }, + { label: /* i18n */ { id: 'node.wall.panel.cove', message: 'Cove' }, value: 'crown-cove' }, + { label: /* i18n */ { id: 'node.wall.panel.ogee', message: 'Ogee' }, value: 'crown-ogee' }, + { + label: /* i18n */ { id: 'node.wall.panel.craft', message: 'Craft' }, + value: 'crown-craftsman', + }, + { + label: /* i18n */ { id: 'node.wall.panel.layered', message: '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' }, + { label: /* i18n */ { id: 'node.wall.panel.flat', message: 'Flat' }, value: 'flat' }, + { label: /* i18n */ { id: 'node.wall.panel.round', message: 'Round' }, value: 'rail-rounded' }, + { label: /* i18n */ { id: 'node.wall.panel.ogee', message: 'Ogee' }, value: 'rail-ogee' }, + { + label: /* i18n */ { id: 'node.wall.panel.picture', message: 'Picture' }, + value: 'rail-picture', + }, + { label: /* i18n */ { id: 'node.wall.panel.step', message: 'Step' }, value: 'rail-stepped' }, ], } export default function WallPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) @@ -278,26 +301,32 @@ export default function WallPanel() { - + handleUpdate({ wallType })} options={[ - { label: 'Standard', value: 'standard' }, - { label: 'Curtain wall', value: 'curtain' }, + { + label: i18n._({ id: 'node.wall.panel.standard', message: 'Standard' }), + value: 'standard', + }, + { + label: i18n._({ id: 'node.wall.panel.curtainWall', message: 'Curtain wall' }), + value: 'curtain', + }, ]} value={node.wallType ?? 'standard'} /> - + @@ -311,19 +340,29 @@ export default function WallPanel() { value={displayLength} />
- Top + {i18n._({ id: 'node.wall.panel.top', message: 'Top' })}
{isPlaneBound ? (
- Currently {formatLinearMeasurement(height, unit)} + {i18n._({ + id: 'node.panel.currently', + message: 'Currently {value}', + values: { value: formatLinearMeasurement(height, unit) }, + })}
) : ( @@ -346,19 +385,25 @@ export default function WallPanel() { /> )}
- Bottom + {i18n._({ id: 'node.wall.panel.bottom', message: 'Bottom' })}
{followsTerrain && (
- Extends downward to meet the terrain. Height and top stay unchanged. + {i18n._({ + id: 'node.wall.panel.extendsDownwardToMeetTheTerrain', + message: 'Extends downward to meet the terrain. Height and top stay unchanged.', + })}
)} {node.assembly && node.wallType !== 'curtain' ? ( @@ -367,10 +412,14 @@ export default function WallPanel() { // section is the only place thickness changes.
- Thickness + {i18n._({ id: 'node.wall.panel.thickness', message: 'Thickness' })} - {formatLinearMeasurement(thickness, unit)} · from assembly + {i18n._({ + id: 'node.wall.panel.thicknessFromAssembly', + message: '{thickness} · from assembly', + values: { thickness: formatLinearMeasurement(thickness, unit) }, + })}
) : ( @@ -379,7 +428,11 @@ export default function WallPanel() { onCancel={handleCancel} restoreOnCommit={false} previewWhileTyping - label={node.wallType === 'curtain' ? 'Frame depth' : 'Thickness'} + label={ + node.wallType === 'curtain' + ? i18n._({ id: 'node.wall.panel.frameDepth', message: 'Frame depth' }) + : i18n._({ id: 'node.wall.panel.thickness', message: 'Thickness' }) + } max={metersToLinearUnit(1000, unit)} min={metersToLinearUnit(0.05, unit)} onChange={(v) => @@ -396,12 +449,17 @@ export default function WallPanel() { value={Math.round(displayThickness * 1000) / 1000} /> )} -
Reference
+
+ {i18n._({ id: 'node.wall.panel.reference', message: 'Reference' })} +
{reference && ( reference.apply(value)} - options={reference.options} + options={reference.options.map((option) => ({ + ...option, + label: i18n._(WALL_REFERENCE_LABELS[option.value]), + }))} value={reference.value ?? 'center'} /> )} @@ -411,7 +469,7 @@ export default function WallPanel() { onCancel={handleCancel} restoreOnCommit={false} previewWhileTyping - label="Curve" + label={i18n._({ id: 'node.wall.panel.curve', message: 'Curve' })} max={Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)} min={-Math.max(metersToLinearUnit(0.01, unit), displayMaxCurveOffset)} onChange={(v) => @@ -455,7 +513,7 @@ export default function WallPanel() { + } - label="Curve" + label={i18n._({ id: 'node.wall.panel.curve', message: 'Curve' })} onClick={handleCurve} /> @@ -505,6 +563,7 @@ export default function WallPanel() { * its code to read and a way to take it off, back to the face's own finish. */ function WallFaceScriptSection({ wallId }: { wallId: string }) { + const i18n = usePascalI18n() const shas = useScene( useShallow((scene) => { const wall = scene.nodes[wallId as AnyNodeId] @@ -515,7 +574,9 @@ function WallFaceScriptSection({ wallId }: { wallId: string }) { ) if (!(shas[0] || shas[1])) return null return ( - + {(['a', 'b'] as const).map((face, index) => { const sha = shas[index] return sha ? : null @@ -525,14 +586,19 @@ function WallFaceScriptSection({ wallId }: { wallId: string }) { } function WallFaceScriptRow({ face, sha, wallId }: { face: WallFace; sha: string; wallId: string }) { + const i18n = usePascalI18n() const [code, setCode] = useState<{ sha: string; text: string } | null>(null) const [open, setOpen] = useState(false) + const unreadable = i18n._({ + id: 'editor.panels.scriptUnreadable', + message: 'The script could not be read.', + }) const toggleCode = () => { setOpen((shown) => !shown) if (code?.sha === sha) return finishScriptCode(sha).then( - (text) => setCode({ sha, text: text ?? 'The script could not be read.' }), - () => setCode({ sha, text: 'The script could not be read.' }), + (text) => setCode({ sha, text: text ?? unreadable }), + () => setCode({ sha, text: unreadable }), ) } const remove = () => { @@ -549,27 +615,37 @@ function WallFaceScriptRow({ face, sha, wallId }: { face: WallFace; sha: string; return (
- Side {face.toUpperCase()} + + {i18n._({ + id: 'node.wall.panel.sideFace', + message: 'Side {face}', + values: { face: face.toUpperCase() }, + })} +
{open && (
-          {code?.sha === sha ? code.text : 'Loading…'}
+          {code?.sha === sha
+            ? code.text
+            : i18n._({ id: 'node.wall.panel.loading', message: 'Loading…' })}
         
)}
@@ -595,6 +671,7 @@ function WallTrimSection({ unitLabel: string wallHeightMeters: number }) { + const i18n = usePascalI18n() const updateTrim = (patch: Partial>) => onUpdate(wallTrimPatch(node, trimKey, patch)) const profileOptions = WALL_TRIM_PROFILE_OPTIONS[trimKey] @@ -606,7 +683,21 @@ function WallTrimSection({ updateTrim({ enabled: !trimValue.enabled })} /> @@ -615,9 +706,9 @@ function WallTrimSection({ updateTrim({ sides: next as any })} options={[ - { label: 'Side A', value: 'a' }, - { label: 'Side B', value: 'b' }, - { label: 'Both', value: 'both' }, + { label: i18n._({ id: 'node.wall.panel.sideA', message: 'Side A' }), value: 'a' }, + { label: i18n._({ id: 'node.wall.panel.sideB', message: 'Side B' }), value: 'b' }, + { label: i18n._({ id: 'node.wall.panel.both', message: 'Both' }), value: 'both' }, ]} value={ trimValue.sides === 'interior' @@ -629,11 +720,11 @@ function WallTrimSection({ /> updateTrim({ profile: next })} - options={profileOptions} + options={profileOptions.map((option) => ({ ...option, label: i18n._(option.label) }))} value={selectedProfile} /> @@ -650,7 +741,7 @@ function WallTrimSection({ value={metersToLinearUnit(trimValue.height, unit)} /> @@ -668,7 +759,7 @@ function WallTrimSection({ /> {trimKey === 'chairRail' && ( @@ -813,33 +904,49 @@ function ThicknessInput({ ) } -const EXTERIOR_FINISH_OPTIONS: Array<{ value: WallAssemblyExteriorFinish; label: string }> = [ - { value: 'none', label: 'None' }, - { value: 'siding', label: 'Lap siding' }, - { value: 'fiber-cement', label: 'Fiber cement' }, - { value: 'stucco', label: 'Stucco' }, - { value: 'brick', label: 'Brick veneer' }, - { value: 'stone', label: 'Stone veneer' }, +const EXTERIOR_FINISH_OPTIONS: Array<{ + value: WallAssemblyExteriorFinish + label: MessageDescriptor +}> = [ + { value: 'none', label: /* i18n */ { id: 'node.wall.panel.none', message: 'None' } }, + { value: 'siding', label: /* i18n */ { id: 'node.wall.panel.lapSiding', message: 'Lap siding' } }, + { + value: 'fiber-cement', + label: /* i18n */ { id: 'node.wall.panel.fiberCement', message: 'Fiber cement' }, + }, + { value: 'stucco', label: /* i18n */ { id: 'node.wall.panel.stucco', message: 'Stucco' } }, + { + value: 'brick', + label: /* i18n */ { id: 'node.wall.panel.brickVeneer', message: 'Brick veneer' }, + }, + { + value: 'stone', + label: /* i18n */ { id: 'node.wall.panel.stoneVeneer', message: 'Stone veneer' }, + }, ] -const SHEATHING_OPTIONS: Array<{ value: WallAssemblySheathingMaterial; label: string }> = [ - { value: 'none', label: 'None' }, - { value: 'osb', label: 'OSB' }, - { value: 'plywood', label: 'Plywood' }, - { value: 'gypsum', label: 'Gypsum' }, -] +const SHEATHING_OPTIONS: Array<{ value: WallAssemblySheathingMaterial; label: MessageDescriptor }> = + [ + { value: 'none', label: /* i18n */ { id: 'node.wall.panel.none', message: 'None' } }, + { value: 'osb', label: /* i18n */ { id: 'node.wall.panel.osb', message: 'OSB' } }, + { value: 'plywood', label: /* i18n */ { id: 'node.wall.panel.plywood', message: 'Plywood' } }, + { value: 'gypsum', label: /* i18n */ { id: 'node.wall.panel.gypsum', message: 'Gypsum' } }, + ] -const FRAMING_OPTIONS: Array<{ value: WallAssemblyFramingKind; label: string }> = [ - { value: 'wood', label: 'Wood studs' }, - { value: 'lgs', label: 'Steel studs' }, - { value: 'cmu', label: 'CMU' }, - { value: 'icf', label: 'ICF' }, +const FRAMING_OPTIONS: Array<{ value: WallAssemblyFramingKind; label: MessageDescriptor }> = [ + { value: 'wood', label: /* i18n */ { id: 'node.wall.panel.woodStuds', message: 'Wood studs' } }, + { value: 'lgs', label: /* i18n */ { id: 'node.wall.panel.steelStuds', message: 'Steel studs' } }, + { value: 'cmu', label: /* i18n */ { id: 'node.wall.panel.cmu', message: 'CMU' } }, + { value: 'icf', label: /* i18n */ { id: 'node.wall.panel.icf', message: 'ICF' } }, ] -const INTERIOR_FINISH_OPTIONS: Array<{ value: WallAssemblyInteriorFinish; label: string }> = [ - { value: 'none', label: 'None' }, - { value: 'drywall', label: 'Drywall' }, - { value: 'plaster', label: 'Plaster' }, +const INTERIOR_FINISH_OPTIONS: Array<{ + value: WallAssemblyInteriorFinish + label: MessageDescriptor +}> = [ + { value: 'none', label: /* i18n */ { id: 'node.wall.panel.none', message: 'None' } }, + { value: 'drywall', label: /* i18n */ { id: 'node.wall.panel.drywall', message: 'Drywall' } }, + { value: 'plaster', label: /* i18n */ { id: 'node.wall.panel.plaster', message: 'Plaster' } }, ] /** Cited default thickness for a finish the user just switched to. */ @@ -861,6 +968,7 @@ function WallAssemblySection({ onUpdate: (updates: Partial) => void unit: 'metric' | 'imperial' }) { + const i18n = usePascalI18n() const stack = node.assembly // The cladding / sheathing / framing / interior editor works on the WS5 view // of the F2 stack; a stack it cannot express is listed read-only. @@ -878,8 +986,8 @@ function WallAssemblySection({ } return ( - - + + { @@ -918,7 +1030,7 @@ function WallAssemblySection({ > {EXTERIOR_FINISH_OPTIONS.map((option) => ( ))} @@ -936,7 +1048,7 @@ function WallAssemblySection({ )} - + @@ -976,7 +1088,7 @@ function WallAssemblySection({ )} - + @@ -1005,7 +1117,7 @@ function WallAssemblySection({ /> - + @@ -1042,7 +1154,7 @@ function WallAssemblySection({
- Total thickness + {i18n._({ id: 'node.wall.panel.totalThickness', message: 'Total thickness' })} {formatLayerThickness(assemblyThickness(stack!), unit)} @@ -1051,23 +1163,36 @@ function WallAssemblySection({ {resolved.kind === 'partition' && (
- Partition: the interior finish is applied to both faces. + {i18n._({ + id: 'node.wall.panel.partitionTheInteriorFinishIsApplied', + message: 'Partition: the interior finish is applied to both faces.', + })}
)} {resolved.kind === 'envelope' && resolved.exteriorSide == null && (
- Which face is outside is undetermined (no room detected on either side) — the exterior - layers are drawn on side B. + {i18n._({ + id: 'node.wall.panel.whichFaceIsOutsideIsUndetermined', + message: + 'Which face is outside is undetermined (no room detected on either side) — the exterior layers are drawn on side B.', + })}
)} {presetNote && (
- Unverified: {presetNote} + {i18n._({ + id: 'node.wall.panel.unverifiedPresetnote', + message: 'Unverified: {presetNote}', + values: { presetNote }, + })}
)}
- Layer thicknesses follow the 2021 IRC assembly data. Drafting aid, not engineering — - verify with the authority having jurisdiction. + {i18n._({ + id: 'node.wall.panel.layerThicknessesFollowThe2021Irc', + message: + 'Layer thicknesses follow the 2021 IRC assembly data. Drafting aid, not engineering — verify with the authority having jurisdiction.', + })}
)} @@ -1082,7 +1207,7 @@ function WallAssemblySection({ ))}
- Total thickness + {i18n._({ id: 'node.wall.panel.totalThickness', message: 'Total thickness' })} {formatLayerThickness(assemblyThickness(stack), unit)} diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 4aeb8f6688..3ba470e727 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -8,6 +8,7 @@ import { runAsSingleSceneHistoryStep, scriptImages, useInteractive, + usePascalI18n, useScene, type WindowNode, } from '@pascal-app/core' @@ -20,7 +21,6 @@ import { SILLLESS_WINDOW_TYPES, unwrapCorner, WINDOW_STYLE_CHOICES, - WINDOW_STYLE_LABELS, windowStylesOf, windowTakesStyle, windowTypeChange, @@ -45,6 +45,7 @@ import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { AuthoredParams } from '../item/authored-params' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' +import { WINDOW_STYLE_MESSAGES, WINDOW_TYPE_MESSAGES } from '../shared/opening-labels' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { windowTypeOptions } from './placement' @@ -105,6 +106,7 @@ function syncCornerPartner(windowId: string, updates: Partial) { } export default function WindowPanel() { + const i18n = usePascalI18n() const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) @@ -301,13 +303,13 @@ export default function WindowPanel() { const showFlipSide = !isOpening const operationLabel = isTrackSashWindow ? windowType === 'sliding' - ? 'Slide' - : 'Raise' + ? i18n._({ id: 'node.window.operation.slide', message: 'Slide' }) + : i18n._({ id: 'node.window.operation.raise', message: 'Raise' }) : windowType === 'casement' - ? 'Swing' + ? i18n._({ id: 'node.window.operation.swing', message: 'Swing' }) : windowType === 'louvered' - ? 'Slats' - : 'Tilt' + ? i18n._({ id: 'node.window.operation.slats', message: 'Slats' }) + : i18n._({ id: 'node.window.operation.tilt', message: 'Tilt' }) const setOperationState = (value: number) => { useInteractive.getState().cancelWindowAnimation(node.id) @@ -385,11 +387,11 @@ export default function WindowPanel() { {!scripted && ( - + handleUpdate({ @@ -407,8 +409,14 @@ export default function WindowPanel() { }) } options={[ - { value: 'window', label: 'Window' }, - { value: 'opening', label: 'Opening' }, + { + value: 'window', + label: i18n._({ id: 'node.window.panel.window', message: 'Window' }), + }, + { + value: 'opening', + label: i18n._({ id: 'node.window.panel.opening', message: 'Opening' }), + }, ]} value={node.openingKind ?? 'window'} /> @@ -416,7 +424,9 @@ export default function WindowPanel() { )} {showWindowTypeSection && ( - +
{windowTypeOptions.map((option) => { const isSelected = displayedWindowType === option.value @@ -437,7 +447,9 @@ export default function WindowPanel() { } type="button" > - {option.label} + + {i18n._(WINDOW_TYPE_MESSAGES[option.value])} + ) })} @@ -452,8 +464,11 @@ export default function WindowPanel() { }) } options={[ - { value: 'up', label: 'Up' }, - { value: 'down', label: 'Down' }, + { value: 'up', label: i18n._({ id: 'node.window.panel.up', message: 'Up' }) }, + { + value: 'down', + label: i18n._({ id: 'node.window.panel.down', message: 'Down' }), + }, ]} value={awningDirection} /> @@ -466,8 +481,14 @@ export default function WindowPanel() { handleUpdate({ casementStyle: value as WindowNode['casementStyle'] }) } options={[ - { value: 'single', label: 'Single' }, - { value: 'french', label: 'French' }, + { + value: 'single', + label: i18n._({ id: 'node.window.panel.single', message: 'Single' }), + }, + { + value: 'french', + label: i18n._({ id: 'node.window.panel.french', message: 'French' }), + }, ]} value={node.casementStyle ?? 'single'} /> @@ -477,8 +498,14 @@ export default function WindowPanel() { handleUpdate({ hingesSide: value as WindowNode['hingesSide'] }) } options={[ - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' }, + { + value: 'left', + label: i18n._({ id: 'node.window.panel.left', message: 'Left' }), + }, + { + value: 'right', + label: i18n._({ id: 'node.window.panel.right', message: 'Right' }), + }, ]} value={node.hingesSide ?? 'left'} /> @@ -503,7 +530,7 @@ export default function WindowPanel() { )} {showStyleSection && ( - +
{WINDOW_STYLE_CHOICES.map((style) => ( ))}
{!windowStyles.length && (

- Custom: the panes match no style. + {i18n._({ + id: 'node.window.panel.customThePanesMatchNoStyle', + message: 'Custom: the panes match no style.', + })}

)}
)} {corner && ( - + {corner.paired ? (
- Wraps the corner with {corner.partner.name || 'its partner'} + {corner.partner.name + ? i18n._({ + id: 'node.window.panel.wrapsTheCornerWithNamed', + message: 'Wraps the corner with {partner}', + values: { partner: corner.partner.name }, + }) + : i18n._({ + id: 'node.window.panel.wrapsTheCornerWithPartner', + message: 'Wraps the corner with its partner', + })}
selectedId && setCornerWidth(selectedId, v)} precision={2} @@ -561,7 +600,7 @@ export default function WindowPanel() { value={node.width} /> setCornerWidth(corner.partner.id, v)} precision={2} @@ -574,8 +613,14 @@ export default function WindowPanel() { @@ -591,19 +636,22 @@ export default function WindowPanel() { onClick={wrapTheCorner} type="button" > - Wrap the corner + {i18n._({ id: 'node.window.panel.wrapTheCorner', message: 'Wrap the corner' })}
)}
)} - + {!corner?.paired && ( - Xpos + X + + {i18n._({ id: 'node.window.panel.pos', message: 'pos' })} + } onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })} @@ -616,7 +664,10 @@ export default function WindowPanel() { - Ypos + Y + + {i18n._({ id: 'node.window.panel.pos', message: 'pos' })} + } onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })} @@ -630,7 +681,7 @@ export default function WindowPanel() { } - label="Flip Side" + label={i18n._({ id: 'node.window.panel.flipSide', message: 'Flip Side' })} onClick={handleFlip} />
@@ -640,15 +691,18 @@ export default function WindowPanel() { {scripted && } {!scripted && ( - + {limits && (

- Size is limited to the wall, including clearance for the opening frame. + {i18n._({ + id: 'node.window.panel.sizeIsLimitedToTheWall', + message: 'Size is limited to the wall, including clearance for the opening frame.', + })}

)} {!corner?.paired && ( preview?.preview(getDimensionUpdates({ width: v }))} @@ -663,7 +717,7 @@ export default function WindowPanel() { /> )} preview?.preview(getDimensionUpdates({ height: v }))} @@ -680,7 +734,7 @@ export default function WindowPanel() { )} {showWindowShapeSection && ( - + handleUpdate({ @@ -698,9 +752,15 @@ export default function WindowPanel() { }) } options={[ - { value: 'rectangle', label: 'Rect' }, - { value: 'rounded', label: 'Rounded' }, - { value: 'arch', label: 'Arch' }, + { + value: 'rectangle', + label: i18n._({ id: 'node.window.panel.rect', message: 'Rect' }), + }, + { + value: 'rounded', + label: i18n._({ id: 'node.window.panel.rounded', message: 'Rounded' }), + }, + { value: 'arch', label: i18n._({ id: 'node.window.panel.arch', message: 'Arch' }) }, ]} value={windowShape} /> @@ -711,14 +771,17 @@ export default function WindowPanel() { handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) } options={[ - { value: 'all', label: 'All' }, - { value: 'individual', label: 'Individual' }, + { value: 'all', label: i18n._({ id: 'node.window.panel.all', message: 'All' }) }, + { + value: 'individual', + label: i18n._({ id: 'node.window.panel.individual', message: 'Individual' }), + }, ]} value={openingRadiusMode} /> {openingRadiusMode === 'all' ? ( previewWindowUpdate('cornerRadius', value)} @@ -734,10 +797,10 @@ export default function WindowPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], - ['Bottom Right', 2], - ['Bottom Left', 3], + [i18n._({ id: 'node.cornerRadius.topLeft', message: 'Top Left' }), 0], + [i18n._({ id: 'node.cornerRadius.topRight', message: 'Top Right' }), 1], + [i18n._({ id: 'node.cornerRadius.bottomRight', message: 'Bottom Right' }), 2], + [i18n._({ id: 'node.cornerRadius.bottomLeft', message: 'Bottom Left' }), 3], ].map(([label, index]) => ( )} previewWindowUpdate('openingRevealRadius', value)} @@ -776,7 +839,7 @@ export default function WindowPanel() { {windowShape === 'arch' && (
previewWindowUpdate('archHeight', value)} @@ -795,15 +858,23 @@ 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: i18n._({ id: 'node.window.panel.rect', message: 'Rect' }), + }, + { + value: 'rounded', + label: i18n._({ id: 'node.window.panel.rounded', message: 'Rounded' }), + }, + { value: 'arch', label: i18n._({ id: 'node.window.panel.arch', message: 'Arch' }) }, ]} value={openingShape} /> @@ -814,14 +885,17 @@ export default function WindowPanel() { handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) } options={[ - { value: 'all', label: 'All' }, - { value: 'individual', label: 'Individual' }, + { value: 'all', label: i18n._({ id: 'node.window.panel.all', message: 'All' }) }, + { + value: 'individual', + label: i18n._({ id: 'node.window.panel.individual', message: 'Individual' }), + }, ]} value={openingRadiusMode} /> {openingRadiusMode === 'all' ? ( previewWindowUpdate('cornerRadius', value)} @@ -837,10 +911,10 @@ export default function WindowPanel() { ) : ( <> {[ - ['Top Left', 0], - ['Top Right', 1], - ['Bottom Right', 2], - ['Bottom Left', 3], + [i18n._({ id: 'node.cornerRadius.topLeft', message: 'Top Left' }), 0], + [i18n._({ id: 'node.cornerRadius.topRight', message: 'Top Right' }), 1], + [i18n._({ id: 'node.cornerRadius.bottomRight', message: 'Bottom Right' }), 2], + [i18n._({ id: 'node.cornerRadius.bottomLeft', message: 'Bottom Left' }), 3], ].map(([label, index]) => ( )} previewWindowUpdate('openingRevealRadius', value)} @@ -879,7 +953,7 @@ export default function WindowPanel() { {openingShape === 'arch' && (
previewWindowUpdate('archHeight', value)} @@ -900,9 +974,9 @@ export default function WindowPanel() { {!isOpening && !scripted && ( <> {showFrameSection && ( - + previewWindowUpdate('frameThickness', v)} onCommit={(v) => commitWindowPreview('frameThickness', v)} @@ -915,7 +989,7 @@ export default function WindowPanel() { value={node.frameThickness} /> previewWindowUpdate('frameDepth', v)} onCommit={(v) => commitWindowPreview('frameDepth', v)} @@ -931,9 +1005,9 @@ export default function WindowPanel() { )} {showGridSection && ( - + { @@ -945,7 +1019,7 @@ export default function WindowPanel() { value={numCols} /> { @@ -960,7 +1034,7 @@ export default function WindowPanel() { {numCols > 1 && (
- Col Widths + {i18n._({ id: 'node.window.panel.colWidths', message: 'Col Widths' })}
{normCols.map((ratio, i) => ( handleUpdate({ columnDividerThickness: v })} @@ -993,7 +1067,7 @@ export default function WindowPanel() { {numRows > 1 && (
- Row Heights + {i18n._({ id: 'node.window.panel.rowHeights', message: 'Row Heights' })}
{normRows.map((ratio, i) => ( handleUpdate({ rowDividerThickness: v })} @@ -1026,16 +1100,16 @@ export default function WindowPanel() { )} {showSillSection && ( - + handleUpdate({ sill: checked })} /> {node.sill && (
handleUpdate({ sillDepth: v })} precision={3} @@ -1044,7 +1118,7 @@ export default function WindowPanel() { value={node.sillDepth} /> handleUpdate({ sillThickness: v })} precision={3} @@ -1059,18 +1133,22 @@ export default function WindowPanel() { )} - + - } label="Move" onClick={handleMove} /> + } + label={i18n._({ id: 'node.window.panel.move', message: 'Move' })} + onClick={handleMove} + /> } - label="Duplicate" + label={i18n._({ id: 'node.window.panel.duplicate', message: 'Duplicate' })} onClick={handleDuplicate} /> } - label="Delete" + label={i18n._({ id: 'node.window.panel.delete', message: 'Delete' })} onClick={handleDelete} /> diff --git a/packages/nodes/src/zone/quantities-panel.tsx b/packages/nodes/src/zone/quantities-panel.tsx index e0b8b0426f..0ad12f7776 100644 --- a/packages/nodes/src/zone/quantities-panel.tsx +++ b/packages/nodes/src/zone/quantities-panel.tsx @@ -5,11 +5,13 @@ import { deriveZoneQuantityReport, type UnitNode, useLiveNodeOverrides, + usePascalI18n, useScene, type ZoneNode, type ZoneQuantityValue, } from '@pascal-app/core' import { + displayRoomName, formatAreaLabel, formatLinearMeasurement, formatVolumeLabel, @@ -34,10 +36,14 @@ function ZonePlanSketch({ polygon: readonly Point2D[] unit: 'metric' | 'imperial' }) { + const i18n = usePascalI18n() if (polygon.length < 3) { return (
- Zone boundary unavailable + {i18n._({ + id: 'node.zone.quantitiesPanel.zoneBoundaryUnavailable', + message: 'Zone boundary unavailable', + })}
) } @@ -66,7 +72,10 @@ function ZonePlanSketch({ return (
{abbreviation} {label} - {quantity.status === 'available' ? format(quantity.value) : 'Not proven'} + {quantity.status === 'available' + ? format(quantity.value) + : i18n._({ id: 'node.zone.quantitiesPanel.notProven', message: 'Not proven' })}
@@ -157,6 +169,7 @@ function QuantityRow({ } function ZoneUnitPanel({ zone }: { zone: ZoneNode }) { + const i18n = usePascalI18n() const units = useScene(useShallow((state) => buildingUnitsForZone(zone, (id) => state.nodes[id]))) if (units.length === 0) return null @@ -181,13 +194,16 @@ function ZoneUnitPanel({ zone }: { zone: ZoneNode }) { } return ( - + ({ label: unit.name || 'Unit', value: unit.id })), + { label: i18n._({ id: 'node.zone.quantitiesPanel.none', message: 'None' }), value: '' }, + ...units.map((unit) => ({ + label: unit.name || i18n._({ id: 'node.zone.quantitiesPanel.unit', message: 'Unit' }), + value: unit.id, + })), ]} value={current?.id ?? ''} /> @@ -196,6 +212,7 @@ function ZoneUnitPanel({ zone }: { zone: ZoneNode }) { } export default function ZoneQuantitiesPanel() { + const i18n = usePascalI18n() const selectedZoneId = useViewer((state) => state.selection.zoneId) const unit = useViewer((state) => state.unit) const metricNotation = useViewer((state) => state.metricNotation) @@ -230,13 +247,24 @@ export default function ZoneQuantitiesPanel() { <>
- {effectiveZone.name} + + {displayRoomName(effectiveZone.name ?? '', i18n)} + - {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} + {report.classification === 'enclosed-room' + ? i18n._({ id: 'node.zone.quantitiesPanel.enclosedRoom', message: 'Enclosed room' }) + : i18n._({ + id: 'node.zone.quantitiesPanel.footprintOnly', + message: 'Footprint only', + })}
@@ -258,19 +286,22 @@ export default function ZoneQuantitiesPanel() { formatAreaLabel(value, unit, 2)} - label="Wall surface" + label={i18n._({ id: 'node.zone.quantitiesPanel.wallSurface', message: 'Wall surface' })} quantity={report.wallSurface} /> formatAreaLabel(value, unit, 2)} - label="Floor surface" + label={i18n._({ + id: 'node.zone.quantitiesPanel.floorSurface', + message: 'Floor surface', + })} quantity={report.floorSurface} /> formatVolumeLabel(value, unit, 2)} - label="Volume" + label={i18n._({ id: 'node.zone.quantitiesPanel.volume', message: 'Volume' })} quantity={report.volume} />
diff --git a/scripts/i18n-node-messages.ts b/scripts/i18n-node-messages.ts index df728afb53..df9c352f80 100644 --- a/scripts/i18n-node-messages.ts +++ b/scripts/i18n-node-messages.ts @@ -7,11 +7,15 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { MessageDescriptor } from '@lingui/core' import { HINT_KEY_NAMES, + inspectorActionDescriptor, + inspectorFieldDescriptor, + inspectorGroupDescriptor, + inspectorOptionDescriptor, nodeDescriptionDescriptor, nodeLabelDescriptor, toolHintChipDescriptor, @@ -46,6 +50,10 @@ type Definition = { presentation?: { label?: string; description?: string } toolHints?: Hint[] affordanceHints?: Record + parametrics?: { + groups?: { label?: string; fields?: { key?: string; kind?: string; options?: string[] }[] }[] + actions?: { label?: string }[] + } } type Manifest = { @@ -111,6 +119,17 @@ function descriptorsOf(definitions: Definition[]): MessageDescriptor[] { for (const hints of Object.values(definition.affordanceHints ?? {})) { for (const hint of hints) addHint(hint) } + for (const group of definition.parametrics?.groups ?? []) { + if (group.label) add(inspectorGroupDescriptor(group.label)) + for (const field of group.fields ?? []) { + if (field.kind === 'custom' || !field.key) continue + add(inspectorFieldDescriptor(field.key)) + for (const option of field.options ?? []) add(inspectorOptionDescriptor(option)) + } + } + for (const action of definition.parametrics?.actions ?? []) { + if (action.label) add(inspectorActionDescriptor(action.label)) + } } for (const key of HINT_KEY_NAMES) add(toolHintKeyDescriptor(key)) return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)) @@ -150,15 +169,30 @@ export function renderDefinitionMessages(descriptors: MessageDescriptor[]): stri ].join('\n') } +/** + * Only the tests' temporary catalog projects (`I18N_ROOT`) lack the built-in + * nodes; in a real checkout a missing entry means the definitions moved, and + * skipping would silently let their copy fall out of the catalogs. + */ +export function hasNodeDefinitions( + entry = NODES_ENTRY, + temporaryRoot = Boolean(process.env.I18N_ROOT), +): boolean { + if (existsSync(entry)) return true + if (temporaryRoot) return false + throw new Error( + `${relative(ROOT, entry)} is missing; update NODES_ENTRY in scripts/i18n-node-messages.ts`, + ) +} + export async function writeDefinitionMessages(): Promise { - // A checkout without the built-in nodes has no definition copy. - if (!existsSync(NODES_ENTRY)) return + if (!hasNodeDefinitions()) return mkdirSync(dirname(DEFINITION_MESSAGES_PATH), { recursive: true }) writeFileSync(DEFINITION_MESSAGES_PATH, renderDefinitionMessages(await definitionDescriptors())) } export async function checkDefinitionMessages(): Promise { - if (!existsSync(NODES_ENTRY)) return [] + if (!hasNodeDefinitions()) return [] const expected = renderDefinitionMessages(await definitionDescriptors()) const current = existsSync(DEFINITION_MESSAGES_PATH) ? readFileSync(DEFINITION_MESSAGES_PATH, 'utf8') diff --git a/scripts/i18n-pseudo-audit.ts b/scripts/i18n-pseudo-audit.ts index f10dec99fa..91155815e9 100644 --- a/scripts/i18n-pseudo-audit.ts +++ b/scripts/i18n-pseudo-audit.ts @@ -158,7 +158,13 @@ async function main(): Promise { }) const findings: Finding[] = [] try { - const context = await browser.newContext({ deviceScaleFactor: 1 }) + // The editor picks metric or imperial from the timezone (and the locale region): + // pin both so units, and so the accepted data such as areas, match on every machine. + const context = await browser.newContext({ + deviceScaleFactor: 1, + locale: 'en-GB', + timezoneId: 'UTC', + }) const page = await context.newPage() for (const surface of selected) { const result = await auditSurface(page, base, surface, out) diff --git a/scripts/i18n.test.ts b/scripts/i18n.test.ts index 5e352277ab..d1513e45a6 100644 --- a/scripts/i18n.test.ts +++ b/scripts/i18n.test.ts @@ -14,6 +14,7 @@ import { type TranslationProvider, validateTranslation, } from './i18n' +import { hasNodeDefinitions } from './i18n-node-messages' test('AI review notes cannot inject physical PO lines', () => { const escaped = escapePoCommentValue('Check wording.\nmsgid "injected"\r\nmsgstr "value"') @@ -275,3 +276,10 @@ test('translation validation applies the checked-in glossary', () => { }), ).toContain('expected glossary term "天花板" for "ceiling"') }) + +test('a missing nodes entry skips the definition check only in a temporary catalog project', () => { + const missing = join(tmpdir(), 'pascal-no-nodes', 'packages/nodes/src/index.ts') + expect(hasNodeDefinitions(missing, true)).toBe(false) + expect(() => hasNodeDefinitions(missing, false)).toThrow(/is missing/) + expect(hasNodeDefinitions()).toBe(true) +})