diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx index fb6b65062..65f77da07 100644 --- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -66,6 +66,7 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() { const guides = useAlignmentGuides((s) => s.guides) const levelId = useViewer((s) => s.selection.levelId) const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) const groupRef = useRef(null) // Guides carry only XZ in WORLD coords; their Y has to track the active @@ -85,16 +86,24 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() { return ( {guides.map((guide, i) => ( - + ))} ) }) -function GuideLine({ guide, unit }: { guide: AlignmentGuide; unit: 'metric' | 'imperial' }) { +function GuideLine({ + guide, + metricNotation, + unit, +}: { + guide: AlignmentGuide + metricNotation: 'meters' | 'millimeters' + unit: 'metric' | 'imperial' +}) { const { x: fx, z: fz } = guide.from const { x: tx, z: tz } = guide.to - const distLabel = formatMeasurement(guide.distance, unit) + const distLabel = formatMeasurement(guide.distance, unit, metricNotation) // Lay out the dash centres along the from→to direction. The ribbon // stretches the dash period up if the line is long enough to exceed the diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 5d2f9a627..24fa48a90 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -295,6 +295,7 @@ export function FloatingActionMenu() { const setMovingNode = useEditor((s) => s.setMovingNode) const setSelection = useViewer((s) => s.setSelection) const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) // Drives the height-drag dimension pill below the menu. `activeHandleDrag` // flips only at drag start / end, so subscribing here is cheap — the live // height value is written imperatively in the useFrame below. @@ -418,7 +419,7 @@ export function FloatingActionMenu() { ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes) : FENCE_DEFAULT_HEIGHT const liveHeight = override?.height ?? node.height ?? fallbackHeight - pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}` + pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit, metricNotation)}` } const obj = sceneRegistry.nodes.get(selectedId) @@ -898,7 +899,7 @@ export function FloatingActionMenu() { under it for duct fittings. */} {node && hasPorts(node.type) ? (
- + {hasAxisCycling(node.type) ? (
@@ -932,7 +933,15 @@ export function FloatingActionMenu() { * subscription it needs (connectivity changes when ANY joint moves) doesn't * re-render the always-mounted parent menu on every unrelated scene tick. */ -function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) { +function SystemSummaryPill({ + metricNotation, + nodeId, + unit, +}: { + metricNotation: 'meters' | 'millimeters' + nodeId: AnyNodeId + unit: 'metric' | 'imperial' +}) { const allNodes = useScene((s) => s.nodes) const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes]) if (!summary) return null @@ -949,7 +958,7 @@ function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' · - {formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '} + {formatMeasurement(summary.runLengthM, unit, metricNotation)} · {summary.runCount}{' '} {summary.runCount === 1 ? 'run' : 'runs'} diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx index 11d608d2c..a731183cf 100644 --- a/packages/editor/src/components/editor/measurement-pill.tsx +++ b/packages/editor/src/components/editor/measurement-pill.tsx @@ -1,23 +1,17 @@ 'use client' +import { useViewer } from '@pascal-app/viewer' import { type ForwardedRef, Fragment, forwardRef } from 'react' +import { formatLinearMeasurement, type MetricNotation } from '../../lib/measurements' // Canonical in-world dimension formatter — metric metres or imperial // feet/inches. Shared by every measurement readout so they read the same. export function formatMeasurement( value: number, unit: 'metric' | 'imperial', - metricNotation: 'meters' | 'millimeters' = 'meters', + metricNotation: MetricNotation = 'meters', ): string { - if (unit === 'imperial') { - const feet = value * 3.280_84 - const wholeFeet = Math.floor(feet) - const inches = Math.round((feet - wholeFeet) * 12) - if (inches === 12) return `${wholeFeet + 1}'0"` - return `${wholeFeet}'${inches}"` - } - if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm` - return `${Number.parseFloat(value.toFixed(2))}m` + return formatLinearMeasurement(value, unit, metricNotation) } type MeasurePart = 'height' | 'length' | 'thickness' @@ -55,12 +49,18 @@ export function DimensionPill({ primary?: string primaryRef?: ForwardedRef }) { + const metricNotation = useViewer((state) => state.metricNotation) + return (
{parts.map((part, index) => { const text = part.signed - ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}` - : formatMeasurement(part.value, unit) + ? `${part.value < 0 ? '-' : '+'}${formatMeasurement( + Math.abs(part.value), + unit, + metricNotation, + )}` + : formatMeasurement(part.value, unit, metricNotation) return ( {index > 0 ? ( diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx index 8b39ef50a..0c8774871 100644 --- a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -53,17 +53,26 @@ const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [ export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() { const guides = useOpeningGuides((s) => s.guides) const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) if (guides.length === 0) return null return ( <> {guides.map((guide) => ( - + ))} ) }) -function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | 'imperial' }) { +function OpeningGuide({ + guide, + metricNotation, + unit, +}: { + guide: OpeningGuide3D + metricNotation: 'meters' | 'millimeters' + unit: 'metric' | 'imperial' +}) { if (guide.kind === 'badge') { return ( - {`= ${formatMeasurement(guide.value, unit)}`} + {`= ${formatMeasurement(guide.value, unit, metricNotation)}`}
) @@ -97,7 +106,7 @@ function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white" style={{ backgroundColor: DIMENSION_PILL }} > - {formatMeasurement(guide.value, unit)} + {formatMeasurement(guide.value, unit, metricNotation)}
) : null} diff --git a/packages/editor/src/lib/measurements.test.ts b/packages/editor/src/lib/measurements.test.ts index baa071ecc..e81a570f5 100644 --- a/packages/editor/src/lib/measurements.test.ts +++ b/packages/editor/src/lib/measurements.test.ts @@ -75,6 +75,10 @@ describe('linear measurements', () => { test('formats metric measurements in whole millimeters', () => { expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm') expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm') + expect(formatLinearMeasurement(0.2, 'metric', 'millimeters')).toBe('200mm') + expect(formatLinearMeasurement(18.4, 'metric', 'millimeters')).toBe('18400mm') + expect(formatLinearMeasurement(0.0005, 'metric', 'millimeters')).toBe('1mm') + expect(formatLinearMeasurement(-0.0005, 'metric', 'millimeters')).toBe('-1mm') }) test('formats imperial measurements as feet and inches', () => { diff --git a/packages/nodes/src/dormer/placement-guides.tsx b/packages/nodes/src/dormer/placement-guides.tsx index 345bf48ed..f6d41935c 100644 --- a/packages/nodes/src/dormer/placement-guides.tsx +++ b/packages/nodes/src/dormer/placement-guides.tsx @@ -90,6 +90,7 @@ export function DormerPlacementGuides({ movingId?: string }) { const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) const [cx, , cz] = center const faceBounds = getRoofSurfaceFaceBoundsAt(segment, cx, cz) @@ -181,22 +182,37 @@ export function DormerPlacementGuides({ return ( <> {guides.map((g) => ( - + ))} ) } -function Guide({ guide, unit }: { guide: DormerGuide; unit: 'metric' | 'imperial' }) { +function Guide({ + guide, + metricNotation, + unit, +}: { + guide: DormerGuide + metricNotation: 'meters' | 'millimeters' + unit: 'metric' | 'imperial' +}) { if (guide.kind === 'badge') { - return + return ( + + ) } return ( ) diff --git a/packages/nodes/src/measurement/renderer.tsx b/packages/nodes/src/measurement/renderer.tsx index 3c278231b..d7489beae 100644 --- a/packages/nodes/src/measurement/renderer.tsx +++ b/packages/nodes/src/measurement/renderer.tsx @@ -291,9 +291,10 @@ function buildRenderData(measurement: ResolvedMeasurementPayload): MeasurementRe function formatMeasurement( measurement: ResolvedMeasurementPayload, unit: 'metric' | 'imperial', + metricNotation: 'meters' | 'millimeters', ): string { if (measurement.kind === 'distance') { - return formatLinearMeasurement(measurementDistance(...measurement.points), unit) + return formatLinearMeasurement(measurementDistance(...measurement.points), unit, metricNotation) } if (measurement.kind === 'angle') { return formatAngleRadians(measurementAngle(...measurement.points)) @@ -302,7 +303,11 @@ function formatMeasurement( return `A ${formatAreaLabel(measurementArea(measurement.base), unit)}` } if (measurement.kind === 'perimeter') { - return `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}` + return `P ${formatLinearMeasurement( + measurementPerimeter(measurement.base), + unit, + metricNotation, + )}` } return `V ${formatVolumeLabel(measurementPrismVolume(measurement.base, measurement.extrusion), unit)}` } @@ -325,6 +330,7 @@ export const MeasurementRenderer = ({ node }: { node: MeasurementNode }) => { const handlers = useNodeEvents(node, 'measurement') const showMeasurements = useViewer((state) => state.showMeasurements) const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) const active = useViewer( (state) => state.hoveredId === node.id || state.selection.selectedIds.some((id) => id === node.id), @@ -350,9 +356,9 @@ export const MeasurementRenderer = ({ node }: { node: MeasurementNode }) => { }) const data = buildRenderData(resolved.payload) const label = useMemo(() => { - const value = formatMeasurement(resolved.payload, unit) + const value = formatMeasurement(resolved.payload, unit, metricNotation) return resolved.dangling.length > 0 ? `Unlinked · ${value}` : value - }, [resolved, unit]) + }, [metricNotation, resolved, unit]) const color = measurementPresentationColor(resolved.dangling.length > 0, active) const lineMaterial = useMemo( () => diff --git a/packages/nodes/src/measurement/tool.tsx b/packages/nodes/src/measurement/tool.tsx index 789f7cf68..361b46d5f 100644 --- a/packages/nodes/src/measurement/tool.tsx +++ b/packages/nodes/src/measurement/tool.tsx @@ -1353,6 +1353,7 @@ const MeasurementDraftPreview: FC<{ const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight) const error = useMeasurementDraft((state) => state.error) const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) const axisIntersectionCache = useRef<{ intersections: QueriedAxisSurfaceIntersection[] key: string @@ -1525,7 +1526,7 @@ const MeasurementDraftPreview: FC<{ const end = livePoints[livePoints.length - 1]! label = { position: localToPreviewFrame(levelObject, buildingObject, midpoint(start, end)), - text: formatLinearMeasurement(measurementDistance(start, end), unit), + text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation), } } else if (kind === 'angle' && livePoints.length >= 3) { const anglePoints = livePoints.slice(0, 3) as [ @@ -1547,7 +1548,11 @@ const MeasurementDraftPreview: FC<{ text: kind === 'area' ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}` - : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`, + : `P ${formatLinearMeasurement( + measurementPerimeter(livePoints), + unit, + metricNotation, + )}`, } } } else if (kind === 'volume' && points.length >= 3 && baseNormal) { @@ -1603,6 +1608,7 @@ const MeasurementDraftPreview: FC<{ hoverOwner, kind, levelId, + metricNotation, points, stage, surfaceQuery,