From a59074774898116c0e7116b424b371e5e3420ea4 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 14 Jul 2026 13:26:35 -0400 Subject: [PATCH] Adjustments pass: three 0.185, undo/cancel semantics, guide & panel fixes (#496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): only start handle drags on primary button Right-click over a rotation/move/resize handle started the gesture and stopPropagation()'d, fighting the camera orbit. Guard every gesture starter (shared useHandleDrag, group rotate gizmo, wall endpoint/height/ move, fence move, roof trim) with event.button !== 0 before it swallows the event. Co-Authored-By: Claude Fable 5 * feat(viewer): default units from timezone/locale until user picks Derive the metric/imperial default from the IANA timezone (US, Liberia, Myanmar zones -> imperial; anything else -> metric), falling back to an explicit locale region subtag only when no timezone resolves. Timezone tracks actual location, unlike navigator.language where en-US is a common default far outside the US. The unit is only persisted once the user explicitly sets it, so an untouched preference keeps tracking location; existing persisted values are treated as explicit and left alone. Co-Authored-By: Claude Fable 5 * fix(viewer): toggle shadows via renderer.shadowMap.enabled, not castShadow Flipping a light's castShadow at runtime crashes three r184's WebGPU renderer: toggling off disposes the shadow map's GPU texture, but the node builder cache evicts with the post-toggle key, so the shadows-on entry survives still referencing the destroyed texture. Re-enabling reuses that stale state and every frame submit fails with GPUValidationError ("Invalid CommandBuffer from CommandEncoder"). Keep castShadow static and drive the user-facing toggle through the Canvas shadows prop (renderer.shadowMap.enabled), which rebuilds materials without disposing shadow resources. Co-Authored-By: Claude Fable 5 * fix(core,editor): floor undo history at scene load Undo could step back past the scene load into the pre-load (empty) state, wiping the whole project — which autosave would then persist. Two defects: clearSceneHistory() had zero call sites, so every load left the empty pre-load state in zundo's pastStates; and setScene wrote the store twice, recording a half-normalized intermediate as a second undo target. - applySceneGraphToEditor, JSON import, and reset-to-default now clear history so the loaded scene is the undo floor - setScene collapses to a single tracked write (final state identical) - clearSceneHistory also resumes tracking so a load landing inside a pause window can't strand undo recording off Co-Authored-By: Claude Fable 5 * build(three): upgrade runtime to 0.185.1, pin @types/three at 0.184 r185 renames directionToColor/colorToDirection to packNormalToRGB/ unpackRGBToNormal and splits SSGI's packed rgba output into separate AO (getAONode, single channel) and GI (getGINode) textures; wind-node's positionLocal reads become positionGeometry. @types/three stays at 0.184.1: the 0.185 typings send tsgo's inference into unbounded allocation (microsoft/typescript-go#2125 class — it ate ~70GB/90s and OOM-killed the machine). viewer/lib/tsl-compat.ts bridges the two renamed TSL exports with 0.184-typed signatures; drop it and the pin together once tsgo copes. Co-Authored-By: Claude Fable 5 * fix(viewer): gate .ktx2 preset-texture loads on detectSupport KTX2Loader.load throws before detectSupport has run, and materials created while a standalone capture canvas's renderer was still initializing cached themselves permanently texture-less — fabric slots rendered white in item thumbnails. .ktx2 loads now await whenKtx2Ready() (resolved by the first successful ensureKtx2Support), which is exported so hosts with standalone canvases can arm it. Co-Authored-By: Claude Fable 5 * feat(viewer): accept host-supplied country for the unit default applyCountryUnitDefault lets the host app feed an authoritative IP-derived country (e.g. Vercel's x-vercel-ip-country) into the unit default. Stronger signal than the timezone heuristic applied at store creation, still never overrides an explicit user choice. Co-Authored-By: Claude Fable 5 * fix(editor): box select starts over locked guide images A locked guide's hit-rect swallowed pointer-down via stopPropagation, so marquee selection couldn't start on top of it. Locked guides now let the event bubble to the svg root; click-to-select and the unlock affordance still work because a non-drag release fires onClick as before. Co-Authored-By: Claude Fable 5 * feat(editor): live rotation readout while rotating a guide image Rotating a guide with the 2D handles gave no angle feedback. Reuse the registry layer's RotationAngleOverlay (wedge + degree chip) for guide rotate drags: sweeps from the grabbed corner's bearing at grab to its current snapped bearing, suppressed under ~0.5deg so a fresh grab doesn't flash a sliver. Co-Authored-By: Claude Fable 5 * feat(editor): lingo free-text input in the guide set-scale dialog The real-length field accepts natural measurement text via @pascal-app/lingo — 5'11", 180cm, 1m80, 12ft — parsed in the dropdown's unit (a bare number still means that unit, a typed unit wins). A faint '= 1.80 m' hint previews non-trivial input, unparseable text gets a clear error, and the odd onBlur force-reset to 0.0001 is gone. Co-Authored-By: Claude Fable 5 * feat(editor): cmd+z mid-interaction cancels the gesture instead of undoing Undo pressed while the mouse is mid-action (moving, drawing, dragging a handle) used to history-jump under the live pointer — stale carry, half gestures committing against a rewound scene. Now it reads as 'abort this action', exactly like Escape: - the global undo/redo arms first route through the tool:cancel path (covers build drafts, placement ghosts, move tools) and skip the history jump when anything was in flight (consumed, scope-active, or inputDragging); - pointer drags that only knew pointercancel (generic handle drags, group rotate, wall side/height handles, roof trim) gain the same capture-phase Escape/cmd+z keydown the group-move drags already had — fixing Escape for them too; - the existing capture-phase handlers (3D/2D group move, 2D registry move overlay) additionally accept cmd+z as cancel. Co-Authored-By: Claude Fable 5 * fix(editor): box select arms over any guide image that won't drag Follow-up to cd830279, which only let LOCKED guides bubble pointer-down. An unlocked, unselected guide also swallowed the event for nothing (no translate drag starts), so marquee selection could never start on top of it. Now only the one case that uses the event consumes it — selected + unlocked → translate drag — and everything else bubbles to the svg root. Click-to-select still works: a non-drag release never crosses the box-select threshold, so the trailing click fires the guide's onClick. Co-Authored-By: Claude Fable 5 * fix(editor): cmd+z during draft placement cancels instead of undoing The preset/item placement flow (useDraftNode + placement coordinator) registers no interaction scope and holds no pointer, so the cmd+z cancel guard from c699d74e saw it as idle and history-jumped mid-placement. Paused scene history is the universal tell — the draft cycle (and every adopted-move session) keeps temporal paused for the whole gesture, and an undo against a paused store lands on a stale baseline anyway. Treat !isTracking as in-flight. Co-Authored-By: Claude Fable 5 * fix(editor): cmd+z mid-placement completes the cancel, not just skips undo 4ffda723 stopped the history jump during preset/item placement but left the draft alive: the item tool passes no coordinator onCancel — it is Escape's fall-through (switch to select, unmount the tool) that actually destroys the draft. Extract that fall-through and run it from the cmd+z path too whenever a gesture is live and nothing consumed tool:cancel, so cmd+z now behaves exactly like Escape end to end. Co-Authored-By: Claude Fable 5 * fix(editor): node selection clears a lingering reference selection Selecting a guide clears the node selection (handleGuideSelect), but the reverse was never wired: clicking a wall with a floorplan reference selected left selectedReferenceId set, and the panel manager's reference-first priority kept showing the floorplan panel until it was closed by hand. PanelManager now drops the stale reference the moment a scene selection (nodes or zone) appears. Also: the inspector's expanded state is shared across panel swaps by design, but it survived close/reopen too — deselecting everything now resets it, so a fresh selection opens the panel collapsed again. Co-Authored-By: Claude Fable 5 * fix(plugin-trees): wind displacement reads positionLocal, not positionGeometry The three-0.185 migration renamed positionLocal to positionGeometry in the wind nodes, but positionLocal was never removed in r185 — and the two are not interchangeable here. NodeMaterial.setupPosition applies the instance transform by mutating positionLocal, then overwrites it with positionNode's output; reading raw positionGeometry therefore discarded every instance matrix — leaf cards rendered unscaled at tree-local coordinates (a giant canopy filling the sky) and grass/flower instances collapsed invisibly. Reading positionLocal (instance transform included) restores r184 behavior exactly. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/editor/package.json | 2 +- apps/ifc-converter/next-env.d.ts | 2 +- apps/ifc-converter/package.json | 2 +- bun.lock | 20 +-- package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/store/use-scene.ts | 18 ++- packages/editor/package.json | 2 +- .../editor-2d/floorplan-group-move.tsx | 7 +- .../floorplan-registry-move-overlay.tsx | 48 +++--- .../renderers/floorplan-registry-layer.tsx | 7 +- .../src/components/editor/floorplan-panel.tsx | 137 +++++++++++++++--- .../src/components/editor/group-move-3d.ts | 4 +- .../components/editor/group-rotate-handle.tsx | 14 ++ .../editor/handles/use-handle-drag.ts | 16 ++ .../components/editor/thumbnail-generator.tsx | 14 +- .../editor/wall-move-side-handles.tsx | 17 +++ .../systems/roof/roof-edit-system.tsx | 14 ++ .../components/ui/panels/panel-manager.tsx | 22 +++ .../components/ui/panels/panel-wrapper.tsx | 7 + .../sidebar/panels/settings-panel/index.tsx | 8 +- packages/editor/src/hooks/use-keyboard.ts | 83 ++++++++--- packages/editor/src/lib/history.ts | 9 ++ packages/editor/src/lib/scene.ts | 14 +- packages/nodes/package.json | 2 +- packages/plugin-trees/package.json | 4 +- packages/viewer/package.json | 2 +- .../viewer/src/components/viewer/index.tsx | 10 +- .../viewer/src/components/viewer/lights.tsx | 12 +- .../src/components/viewer/post-processing.tsx | 19 +-- packages/viewer/src/index.ts | 4 +- packages/viewer/src/lib/ink-edges.ts | 25 +--- packages/viewer/src/lib/ktx2-loader.ts | 17 +++ packages/viewer/src/lib/materials.ts | 15 +- packages/viewer/src/lib/tsl-compat.ts | 16 ++ packages/viewer/src/store/use-viewer.ts | 86 ++++++++++- 36 files changed, 547 insertions(+), 136 deletions(-) create mode 100644 packages/viewer/src/lib/tsl-compat.ts 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} />