diff --git a/apps/editor/package.json b/apps/editor/package.json index ab71645e..a0d35e87 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -32,7 +32,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.184.0", + "three": "^0.185.0", "zod": "^4.3.5" }, "devDependencies": { diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/ifc-converter/package.json b/apps/ifc-converter/package.json index 0827c1c4..647d80c2 100644 --- a/apps/ifc-converter/package.json +++ b/apps/ifc-converter/package.json @@ -29,7 +29,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.184.0", + "three": "^0.185.0", "web-ifc": "^0.0.77", "zod": "^4.3.5" }, diff --git a/bun.lock b/bun.lock index c1420e61..ec214741 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.184.0", + "three": "^0.185.0", "zod": "^4.3.5", }, "devDependencies": { @@ -83,7 +83,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.184.0", + "three": "^0.185.0", "web-ifc": "^0.0.77", "zod": "^4.3.5", }, @@ -119,7 +119,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.184", + "three": "^0.185", }, }, "packages/editor": { @@ -179,7 +179,7 @@ "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.184", + "three": "^0.185", }, }, "packages/eslint-config": { @@ -256,7 +256,7 @@ "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.184", + "three": "^0.185", "zustand": "^5", }, }, @@ -276,7 +276,7 @@ "@types/react": "^19.2.2", "@types/three": "^0.184.0", "react": "^19", - "three": "^0.184", + "three": "^0.185", "typescript": "6.0.3", "zod": "^4", "zustand": "^5", @@ -287,7 +287,7 @@ "@pascal-app/viewer": "*", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.184", + "three": "^0.185", "zod": "^4", "zustand": "^5", }, @@ -333,7 +333,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.184", + "three": "^0.185", }, }, "tooling/typescript": { @@ -345,7 +345,7 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/three": "0.184.1", - "three": "0.184.0", + "three": "0.185.1", }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1816,7 +1816,7 @@ "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], - "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], + "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], diff --git a/package.json b/package.json index d9d2c901..3be284f8 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/three": "0.184.1", - "three": "0.184.0" + "three": "0.185.1" }, "optionalDependencies": { "@tailwindcss/oxide-darwin-arm64": "4.3.0", diff --git a/packages/core/package.json b/packages/core/package.json index 56eced72..dacd8975 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -67,7 +67,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.184" + "three": "^0.185" }, "dependencies": { "dedent": "^1.7.1", diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index c401238f..29d01efd 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1066,14 +1066,6 @@ const useScene: UseSceneStore = create()( } } - set({ - nodes: cleanedNodes, - rootNodeIds, - dirtyNodes: new Set(), - collections: extra?.collections ?? {}, - materials, - }) - const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds) if (normalizedRootNodeIds.length > 0) { @@ -1084,6 +1076,10 @@ const useScene: UseSceneStore = create()( } } + // Single tracked `set`: with zundo, every tracked write pushes the + // pre-write state onto `pastStates`. Writing the scene in two steps + // (as this used to) exposed a half-normalized intermediate state — + // and the pre-load (possibly empty) state — as undo targets. set({ nodes: cleanedNodes, rootNodeIds: normalizedRootNodeIds, @@ -1296,6 +1292,12 @@ let prevNodesSnapshot: Record | null = null export function clearSceneHistory() { resetSceneHistoryPauseDepth() + // Resetting the pause-depth counter without resuming would strand the + // temporal store in `isTracking: false` if a pause window was active when + // the scene was (re)loaded — every edit after the load would then be + // invisible to undo. Resume unconditionally so the cleared history starts + // tracking from the loaded baseline. + useScene.temporal.getState().resume() useScene.temporal.getState().clear() prevPastLength = 0 prevFutureLength = 0 diff --git a/packages/editor/package.json b/packages/editor/package.json index 2b864afa..56ab49b9 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -18,7 +18,7 @@ "next": ">=15", "react": "^18 || ^19", "react-dom": "^18 || ^19", - "three": "^0.184" + "three": "^0.185" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index a987866b..187d6fe1 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -21,6 +21,7 @@ import { create } from 'zustand' import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment' import { clientToPlan } from '../../lib/floorplan/plan-coords' +import { isHistoryShortcut } from '../../lib/history' import { sfxEmitter } from '../../lib/sfx-bus' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { @@ -363,7 +364,8 @@ export function startFloorplanGroupMove( cancel() return } - if (e.key !== 'Escape') return + // ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer. + if (e.key !== 'Escape' && !isHistoryShortcut(e)) return e.preventDefault() e.stopPropagation() swallowNextClick() @@ -536,7 +538,8 @@ export function startFloorplanGroupRotate(event: { cancel() return } - if (e.key !== 'Escape') return + // ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer. + if (e.key !== 'Escape' && !isHistoryShortcut(e)) return e.preventDefault() e.stopPropagation() swallowNextClick() diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 071a25a0..5dc3dc16 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -18,6 +18,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' +import { isHistoryShortcut } from '../../lib/history' import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { movementSfxStepKey } from '../../lib/sfx/movement-tick' @@ -358,7 +359,13 @@ export function FloorplanRegistryMoveOverlay() { sfxEmitter.emit('sfx:item-rotate') return } - if (event.key !== 'Escape') return + if (event.key !== 'Escape' && !isHistoryShortcut(event)) return + if (isHistoryShortcut(event)) { + // ⌘Z mid-move cancels like Escape — keep it from reaching the + // global undo arm (this handler is capture-phase, that one bubbles). + event.preventDefault() + event.stopImmediatePropagation() + } // Claim teardown ownership so the 3D move tool's cleanup skips // its own restore — without this, both sides would race to // write the same baseline, harmless but wasteful. @@ -701,31 +708,36 @@ export function FloorplanRegistryMoveOverlay() { } const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setMovingNodeOrigin('2d') - if (isFreshPlacement) { - emitter.emit('tool:cancel') - const temporal = useScene.temporal.getState() - const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false - if (wasTracking) temporal.pause() - useScene.getState().deleteNode(movingNode.id as AnyNodeId) - if (wasTracking) temporal.resume() - } - for (const relatedEntry of relatedEntries) { - relatedEntry.removeAttribute('transform') - } - useAlignmentGuides.getState().clear() - setMovingNode(null) + if (event.key !== 'Escape' && !isHistoryShortcut(event)) return + if (isHistoryShortcut(event)) { + // ⌘Z mid-move cancels like Escape — keep it from reaching the + // global undo arm (this handler is capture-phase, that one bubbles). + event.preventDefault() + event.stopImmediatePropagation() } + setMovingNodeOrigin('2d') + if (isFreshPlacement) { + emitter.emit('tool:cancel') + const temporal = useScene.temporal.getState() + const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false + if (wasTracking) temporal.pause() + useScene.getState().deleteNode(movingNode.id as AnyNodeId) + if (wasTracking) temporal.resume() + } + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } + useAlignmentGuides.getState().clear() + setMovingNode(null) } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onPointerUp) - window.addEventListener('keydown', onKey) + window.addEventListener('keydown', onKey, true) return () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) - window.removeEventListener('keydown', onKey) + window.removeEventListener('keydown', onKey, true) for (const relatedEntry of relatedEntries) { relatedEntry.removeAttribute('transform') } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 9a438a86..10dac6ad 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -2999,14 +2999,17 @@ const ROTATION_WEDGE_SEGMENTS = 48 * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads * horizontally regardless of the building's on-screen orientation. */ -function RotationAngleOverlay({ +export function RotationAngleOverlay({ overlay, palette, unitsPerPixel, sceneRotationDeg, }: { overlay: RotationOverlayState - palette: FloorplanPalette + palette: Pick< + FloorplanPalette, + 'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke' + > unitsPerPixel: number sceneRotationDeg: number }): React.ReactElement { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9ff3f66f..fede6c6e 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -86,6 +86,7 @@ import { worldToFloorplanLocalPoint, } from '../../lib/floorplan' import { guideEmitter } from '../../lib/guide-events' +import { measurementHint, parseMeasurement } from '../../lib/measurement-parser' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' import { sfxEmitter } from '../../lib/sfx-bus' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' @@ -126,7 +127,10 @@ import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-laye import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer' -import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer' +import { + FloorplanRegistryLayer, + RotationAngleOverlay, +} from '../editor-2d/renderers/floorplan-registry-layer' import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer' import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer' import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' @@ -1412,6 +1416,44 @@ function buildGuideRotationDraft( } } +/** Live rotation readout for a guide rotate drag — feeds the registry + * layer's wedge + degree chip so guides read the same as every other + * rotate affordance. Sweeps from the grabbed corner's bearing at grab to + * its current (snapped) bearing; suppressed below ~0.5° so a fresh grab + * doesn't flash a zero-width sliver. */ +function buildGuideRotationReadout( + interaction: GuideInteractionState | null, + draft: GuideTransformDraft | null, +) { + if ( + !( + interaction && + draft && + interaction.mode === 'rotate' && + draft.guideId === interaction.guideId + ) + ) { + return null + } + + const delta = normalizeAngle(getGuideSvgRotation(draft.rotation) - interaction.rotationSvg) + if (Math.abs(delta) < 0.0087) { + return null + } + + const width = getGuideWidth(interaction.scale) + const height = getGuideHeight(width, interaction.aspectRatio) + const startAngle = interaction.rotationSvg + interaction.cornerBaseAngle + + return { + pivot: [interaction.centerSvg.x, interaction.centerSvg.y] as const, + startAngle, + endAngle: startAngle + delta, + radius: Math.hypot(width, height) / 2, + sweep: Math.abs(delta), + } +} + function toSvgSelectionBounds(bounds: FloorplanSelectionBounds) { return { x: toSvgX(bounds.maxX), @@ -2695,6 +2737,39 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit) } } +const REFERENCE_SCALE_LINGO_UNIT: Record = { + meters: 'm', + centimeters: 'cm', + feet: 'ft', + inches: 'in', +} + +/** Lingo-parse the free-text real-length input in the dropdown's unit — a + * bare number means the dropdown unit, while `180cm`, `1m80` or `5'11"` + * override it. Returns `null` when the text isn't a readable length. */ +function parseReferenceScaleLength(raw: string, unit: ReferenceScaleUnit): number | null { + const unitId = REFERENCE_SCALE_LINGO_UNIT[unit] + return parseMeasurement( + raw, + { kind: 'length', unitId }, + { bareUnit: unitId, system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric' }, + ) +} + +function referenceScaleLengthHint(raw: string, unit: ReferenceScaleUnit): string | null { + const unitId = REFERENCE_SCALE_LINGO_UNIT[unit] + return measurementHint( + raw, + { kind: 'length', unitId }, + { + bareUnit: unitId, + system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric', + displayUnit: unitId, + precision: 2, + }, + ) +} + function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) { switch (unit) { case 'centimeters': @@ -3149,10 +3224,17 @@ function FloorplanGuideImage({ }} onPointerDown={(event) => { if (event.button === 0) { - event.stopPropagation() - if (isSelected && !isLocked) { - onGuideTranslateStart(guide, event) + // Only a selected, unlocked guide consumes the pointer-down (it + // starts a translate drag). Every other guide lets it bubble to + // the root so box select arms exactly as on empty canvas. + // A non-drag release still fires onClick (a committed box-select + // drag swallows the trailing click), so click-to-select → panel + // keeps working for locked and unselected guides alike. + if (isLocked || !isSelected) { + return } + event.stopPropagation() + onGuideTranslateStart(guide, event) } }} pointerEvents="all" @@ -5617,6 +5699,10 @@ export function FloorplanPanel({ const activeGuideInteractionMode = guideTransformDraft ? (guideInteractionRef.current?.mode ?? null) : null + const guideRotationReadout = buildGuideRotationReadout( + guideInteractionRef.current, + guideTransformDraft, + ) const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls]) const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls]) const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls]) @@ -7239,8 +7325,8 @@ export function FloorplanPanel({ return } - const displayLength = Number(referenceScaleValue) - if (!(displayLength > 0)) { + const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) + if (!(displayLength && displayLength > 0)) { return } @@ -10873,7 +10959,8 @@ export function FloorplanPanel({ const floorplanNavigationCursor = isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan - const pendingReferenceDisplayLength = Number(referenceScaleValue) + const pendingReferenceDisplayLength = + parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) ?? Number.NaN const pendingReferenceRealLengthMeters = pendingReferenceScale && pendingReferenceDisplayLength > 0 ? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit) @@ -10889,9 +10976,14 @@ export function FloorplanPanel({ const referenceScaleInputError = referenceScaleValue.trim() === '' ? 'Enter the real length of the line.' - : pendingReferenceDisplayLength > 0 - ? null - : 'Length must be greater than 0.' + : Number.isNaN(pendingReferenceDisplayLength) + ? `Enter a length like 3.5, 180cm or 5'11".` + : pendingReferenceDisplayLength > 0 + ? null + : 'Length must be greater than 0.' + const referenceScaleHint = referenceScaleInputError + ? null + : referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit) return (
{ - const value = Number(referenceScaleValue) - if (!(value > 0)) { - setReferenceScaleValue('0.0001') - } - }} onChange={(event) => setReferenceScaleValue(event.target.value)} - step="any" - type="number" + placeholder={`e.g. 3.5, 180cm or 5'11"`} + type="text" value={referenceScaleValue} />