Adjustments pass: three 0.185, undo/cancel semantics, guide & panel fixes (#496)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-14 13:26:35 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 3625d17bfc
commit a590747748
36 changed files with 547 additions and 136 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"three": "^0.184.0", "three": "^0.185.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -29,7 +29,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"three": "^0.184.0", "three": "^0.185.0",
"web-ifc": "^0.0.77", "web-ifc": "^0.0.77",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
+10 -10
View File
@@ -48,7 +48,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"three": "^0.184.0", "three": "^0.185.0",
"zod": "^4.3.5", "zod": "^4.3.5",
}, },
"devDependencies": { "devDependencies": {
@@ -83,7 +83,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"three": "^0.184.0", "three": "^0.185.0",
"web-ifc": "^0.0.77", "web-ifc": "^0.0.77",
"zod": "^4.3.5", "zod": "^4.3.5",
}, },
@@ -119,7 +119,7 @@
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184", "three": "^0.185",
}, },
}, },
"packages/editor": { "packages/editor": {
@@ -179,7 +179,7 @@
"next": ">=15", "next": ">=15",
"react": "^18 || ^19", "react": "^18 || ^19",
"react-dom": "^18 || ^19", "react-dom": "^18 || ^19",
"three": "^0.184", "three": "^0.185",
}, },
}, },
"packages/eslint-config": { "packages/eslint-config": {
@@ -256,7 +256,7 @@
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"lucide-react": "^1", "lucide-react": "^1",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184", "three": "^0.185",
"zustand": "^5", "zustand": "^5",
}, },
}, },
@@ -276,7 +276,7 @@
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"react": "^19", "react": "^19",
"three": "^0.184", "three": "^0.185",
"typescript": "6.0.3", "typescript": "6.0.3",
"zod": "^4", "zod": "^4",
"zustand": "^5", "zustand": "^5",
@@ -287,7 +287,7 @@
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184", "three": "^0.185",
"zod": "^4", "zod": "^4",
"zustand": "^5", "zustand": "^5",
}, },
@@ -333,7 +333,7 @@
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184", "three": "^0.185",
}, },
}, },
"tooling/typescript": { "tooling/typescript": {
@@ -345,7 +345,7 @@
"@types/react": "19.2.17", "@types/react": "19.2.17",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/three": "0.184.1", "@types/three": "0.184.1",
"three": "0.184.0", "three": "0.185.1",
}, },
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@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=="], "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=="], "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=="],
+1 -1
View File
@@ -38,7 +38,7 @@
"@types/react": "19.2.17", "@types/react": "19.2.17",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/three": "0.184.1", "@types/three": "0.184.1",
"three": "0.184.0" "three": "0.185.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0",
+1 -1
View File
@@ -67,7 +67,7 @@
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184" "three": "^0.185"
}, },
"dependencies": { "dependencies": {
"dedent": "^1.7.1", "dedent": "^1.7.1",
+10 -8
View File
@@ -1066,14 +1066,6 @@ const useScene: UseSceneStore = create<SceneState>()(
} }
} }
set({
nodes: cleanedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: extra?.collections ?? {},
materials,
})
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds) const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
if (normalizedRootNodeIds.length > 0) { if (normalizedRootNodeIds.length > 0) {
@@ -1084,6 +1076,10 @@ const useScene: UseSceneStore = create<SceneState>()(
} }
} }
// 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({ set({
nodes: cleanedNodes, nodes: cleanedNodes,
rootNodeIds: normalizedRootNodeIds, rootNodeIds: normalizedRootNodeIds,
@@ -1296,6 +1292,12 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() { export function clearSceneHistory() {
resetSceneHistoryPauseDepth() 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() useScene.temporal.getState().clear()
prevPastLength = 0 prevPastLength = 0
prevFutureLength = 0 prevFutureLength = 0
+1 -1
View File
@@ -18,7 +18,7 @@
"next": ">=15", "next": ">=15",
"react": "^18 || ^19", "react": "^18 || ^19",
"react-dom": "^18 || ^19", "react-dom": "^18 || ^19",
"three": "^0.184" "three": "^0.185"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -21,6 +21,7 @@ import { create } from 'zustand'
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment' import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
import { clientToPlan } from '../../lib/floorplan/plan-coords' import { clientToPlan } from '../../lib/floorplan/plan-coords'
import { isHistoryShortcut } from '../../lib/history'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides' import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { import useEditor, {
@@ -363,7 +364,8 @@ export function startFloorplanGroupMove(
cancel() cancel()
return 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.preventDefault()
e.stopPropagation() e.stopPropagation()
swallowNextClick() swallowNextClick()
@@ -536,7 +538,8 @@ export function startFloorplanGroupRotate(event: {
cancel() cancel()
return 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.preventDefault()
e.stopPropagation() e.stopPropagation()
swallowNextClick() swallowNextClick()
@@ -18,6 +18,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
import { isHistoryShortcut } from '../../lib/history'
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { movementSfxStepKey } from '../../lib/sfx/movement-tick' import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
@@ -358,7 +359,13 @@ export function FloorplanRegistryMoveOverlay() {
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
return 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 // Claim teardown ownership so the 3D move tool's cleanup skips
// its own restore — without this, both sides would race to // its own restore — without this, both sides would race to
// write the same baseline, harmless but wasteful. // write the same baseline, harmless but wasteful.
@@ -701,7 +708,13 @@ export function FloorplanRegistryMoveOverlay() {
} }
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') { 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') setMovingNodeOrigin('2d')
if (isFreshPlacement) { if (isFreshPlacement) {
emitter.emit('tool:cancel') emitter.emit('tool:cancel')
@@ -717,15 +730,14 @@ export function FloorplanRegistryMoveOverlay() {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
setMovingNode(null) setMovingNode(null)
} }
}
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp) window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey, true)
return () => { return () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey) window.removeEventListener('keydown', onKey, true)
for (const relatedEntry of relatedEntries) { for (const relatedEntry of relatedEntries) {
relatedEntry.removeAttribute('transform') relatedEntry.removeAttribute('transform')
} }
@@ -2999,14 +2999,17 @@ const ROTATION_WEDGE_SEGMENTS = 48
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
* horizontally regardless of the building's on-screen orientation. * horizontally regardless of the building's on-screen orientation.
*/ */
function RotationAngleOverlay({ export function RotationAngleOverlay({
overlay, overlay,
palette, palette,
unitsPerPixel, unitsPerPixel,
sceneRotationDeg, sceneRotationDeg,
}: { }: {
overlay: RotationOverlayState overlay: RotationOverlayState
palette: FloorplanPalette palette: Pick<
FloorplanPalette,
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
>
unitsPerPixel: number unitsPerPixel: number
sceneRotationDeg: number sceneRotationDeg: number
}): React.ReactElement { }): React.ReactElement {
@@ -86,6 +86,7 @@ import {
worldToFloorplanLocalPoint, worldToFloorplanLocalPoint,
} from '../../lib/floorplan' } from '../../lib/floorplan'
import { guideEmitter } from '../../lib/guide-events' import { guideEmitter } from '../../lib/guide-events'
import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' 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 { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-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 { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer' import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' 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) { function toSvgSelectionBounds(bounds: FloorplanSelectionBounds) {
return { return {
x: toSvgX(bounds.maxX), x: toSvgX(bounds.maxX),
@@ -2695,6 +2737,39 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
} }
} }
const REFERENCE_SCALE_LINGO_UNIT: Record<ReferenceScaleUnit, 'm' | 'cm' | 'ft' | 'in'> = {
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) { function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) {
switch (unit) { switch (unit) {
case 'centimeters': case 'centimeters':
@@ -3149,10 +3224,17 @@ function FloorplanGuideImage({
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.button === 0) { if (event.button === 0) {
event.stopPropagation() // Only a selected, unlocked guide consumes the pointer-down (it
if (isSelected && !isLocked) { // starts a translate drag). Every other guide lets it bubble to
onGuideTranslateStart(guide, event) // the <svg> 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" pointerEvents="all"
@@ -5617,6 +5699,10 @@ export function FloorplanPanel({
const activeGuideInteractionMode = guideTransformDraft const activeGuideInteractionMode = guideTransformDraft
? (guideInteractionRef.current?.mode ?? null) ? (guideInteractionRef.current?.mode ?? null)
: null : null
const guideRotationReadout = buildGuideRotationReadout(
guideInteractionRef.current,
guideTransformDraft,
)
const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls]) const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])
const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls]) const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls]) const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])
@@ -7239,8 +7325,8 @@ export function FloorplanPanel({
return return
} }
const displayLength = Number(referenceScaleValue) const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit)
if (!(displayLength > 0)) { if (!(displayLength && displayLength > 0)) {
return return
} }
@@ -10873,7 +10959,8 @@ export function FloorplanPanel({
const floorplanNavigationCursor = const floorplanNavigationCursor =
isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null
const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan
const pendingReferenceDisplayLength = Number(referenceScaleValue) const pendingReferenceDisplayLength =
parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) ?? Number.NaN
const pendingReferenceRealLengthMeters = const pendingReferenceRealLengthMeters =
pendingReferenceScale && pendingReferenceDisplayLength > 0 pendingReferenceScale && pendingReferenceDisplayLength > 0
? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit) ? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit)
@@ -10889,9 +10976,14 @@ export function FloorplanPanel({
const referenceScaleInputError = const referenceScaleInputError =
referenceScaleValue.trim() === '' referenceScaleValue.trim() === ''
? 'Enter the real length of the line.' ? 'Enter the real length of the line.'
: Number.isNaN(pendingReferenceDisplayLength)
? `Enter a length like 3.5, 180cm or 5'11".`
: pendingReferenceDisplayLength > 0 : pendingReferenceDisplayLength > 0
? null ? null
: 'Length must be greater than 0.' : 'Length must be greater than 0.'
const referenceScaleHint = referenceScaleInputError
? null
: referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit)
return ( return (
<div <div
className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95" className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
@@ -11005,16 +11097,9 @@ export function FloorplanPanel({
'h-9 rounded-lg border bg-background px-3 text-sm outline-none transition focus:border-foreground/40', 'h-9 rounded-lg border bg-background px-3 text-sm outline-none transition focus:border-foreground/40',
referenceScaleInputError ? 'border-destructive/60' : 'border-border', referenceScaleInputError ? 'border-destructive/60' : 'border-border',
)} )}
inputMode="decimal"
onBlur={() => {
const value = Number(referenceScaleValue)
if (!(value > 0)) {
setReferenceScaleValue('0.0001')
}
}}
onChange={(event) => setReferenceScaleValue(event.target.value)} onChange={(event) => setReferenceScaleValue(event.target.value)}
step="any" placeholder={`e.g. 3.5, 180cm or 5'11"`}
type="number" type="text"
value={referenceScaleValue} value={referenceScaleValue}
/> />
<select <select
@@ -11037,6 +11122,7 @@ export function FloorplanPanel({
)} )}
> >
{referenceScaleInputError ?? {referenceScaleInputError ??
referenceScaleHint ??
'Any decimal works. Use the known real length, not the drawn value.'} 'Any decimal works. Use the known real length, not the drawn value.'}
</span> </span>
</label> </label>
@@ -11386,6 +11472,19 @@ export function FloorplanPanel({
/> />
)} )}
{guideRotationReadout && (
<RotationAngleOverlay
overlay={guideRotationReadout}
palette={{
measurementLabelBackground: isDark ? '#0f172a' : '#ffffff',
measurementLabelText: isDark ? '#e2e8f0' : '#171717',
measurementStroke: palette.measurementStroke,
}}
sceneRotationDeg={floorplanSceneRotationDeg}
unitsPerPixel={floorplanUnitsPerPixel}
/>
)}
<FloorplanDraftCursorLayer <FloorplanDraftCursorLayer
activePolygonDraftPoints={activePolygonDraftPoints} activePolygonDraftPoints={activePolygonDraftPoints}
cursorColor={floorplanCursorColor} cursorColor={floorplanCursorColor}
@@ -15,6 +15,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three' import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three'
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help' import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
import { isHistoryShortcut } from '../../lib/history'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides' import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { import useEditor, {
@@ -380,7 +381,8 @@ export function armGroupMove3d(args: {
cancel() cancel()
return return
} }
if (e.key !== 'Escape') return // ⌘Z mid-move cancels like Escape — never a history jump under a live pointer.
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
swallowNextClick() swallowNextClick()
@@ -15,6 +15,7 @@ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
import { isHistoryShortcut } from '../../lib/history'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope, { import useInteractionScope, {
@@ -159,6 +160,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
} }
const activate = (event: ThreeEvent<PointerEvent>) => { const activate = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
@@ -300,6 +302,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel) window.removeEventListener('pointercancel', onCancel)
window.removeEventListener('keydown', onKeyDown, true)
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useViewer.getState().setInputDragging(false) useViewer.getState().setInputDragging(false)
@@ -346,6 +349,16 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
cleanup() cleanup()
} }
// Escape / ⌘Z abort the rotate — capture phase so they win over the global
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
e.preventDefault()
e.stopPropagation()
swallowNextClick()
onCancel()
}
dragCleanupRef.current = () => { dragCleanupRef.current = () => {
clearLivePreviews() clearLivePreviews()
cleanup() cleanup()
@@ -356,6 +369,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel) window.addEventListener('pointercancel', onCancel)
window.addEventListener('keydown', onKeyDown, true)
} }
return createPortal( return createPortal(
@@ -12,6 +12,7 @@ import { useViewer } from '@pascal-app/viewer'
import { type ThreeEvent, useThree } from '@react-three/fiber' import { type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three' import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
import { isHistoryShortcut } from '../../../lib/history'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
@@ -110,6 +111,9 @@ export function useHandleDrag(args: UseHandleDragArgs) {
useEffect(() => () => dragCleanupRef.current?.(), []) useEffect(() => () => dragCleanupRef.current?.(), [])
return (event: ThreeEvent<PointerEvent>) => { return (event: ThreeEvent<PointerEvent>) => {
// Only the primary button starts a handle gesture — right/middle-drag
// belongs to the camera, so let it propagate untouched.
if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
@@ -185,6 +189,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel) window.removeEventListener('pointercancel', onCancel)
window.removeEventListener('keydown', onKeyDown, true)
if (document.body.style.cursor === cursor) { if (document.body.style.cursor === cursor) {
document.body.style.cursor = '' document.body.style.cursor = ''
} }
@@ -222,9 +227,20 @@ export function useHandleDrag(args: UseHandleDragArgs) {
cleanup() cleanup()
} }
// Escape / ⌘Z abort the drag — capture phase so they win over the global
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
e.preventDefault()
e.stopPropagation()
swallowNextClick()
onCancel()
}
dragCleanupRef.current = cleanup dragCleanupRef.current = cleanup
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel) window.addEventListener('pointercancel', onCancel)
window.addEventListener('keydown', onKeyDown, true)
} }
} }
@@ -7,8 +7,10 @@ import {
GRID_LAYER, GRID_LAYER,
getSceneTheme, getSceneTheme,
horizonHazeColor, horizonHazeColor,
packNormalToRGB,
SSGI_PARAMS, SSGI_PARAMS,
snapLevelsToTruePositions, snapLevelsToTruePositions,
unpackRGBToNormal,
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import type { CameraControls } from '@react-three/drei' import type { CameraControls } from '@react-three/drei'
@@ -20,10 +22,8 @@ import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js' import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
import { import {
colorToDirection,
convertToTexture, convertToTexture,
diffuseColor, diffuseColor,
directionToColor,
float, float,
mix, mix,
mrt, mrt,
@@ -105,7 +105,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
mrt({ mrt({
output, output,
diffuseColor, diffuseColor,
normal: directionToColor(normalView), normal: packNormalToRGB(normalView),
}), }),
) )
@@ -116,7 +116,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
scenePass.getTexture('diffuseColor').type = UnsignedByteType scenePass.getTexture('diffuseColor').type = UnsignedByteType
scenePass.getTexture('normal').type = UnsignedByteType scenePass.getTexture('normal').type = UnsignedByteType
const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv))) const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)))
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
giPass.sliceCount.value = SSGI_PARAMS.sliceCount giPass.sliceCount.value = SSGI_PARAMS.sliceCount
@@ -131,8 +131,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
const giTexture = (giPass as any).getTextureNode() // r185: SSGI's AO lives in its own single-channel texture (getAONode)
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1)) // rather than the alpha of one packed rgba texture.
const aoTexture = (giPass as any).getAONode()
const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam) const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
denoisePass.index.value = 0 denoisePass.index.value = 0
denoisePass.radius.value = 4 denoisePass.radius.value = 4
@@ -32,6 +32,7 @@ import {
} from 'three' } from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { isHistoryShortcut } from '../../lib/history'
import { endpointReshapeScope } from '../../lib/interaction/scope' import { endpointReshapeScope } from '../../lib/interaction/scope'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
@@ -331,6 +332,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
}, []) }, [])
const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => { const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
@@ -435,6 +437,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
const handleY = wallHeight + HEIGHT_HANDLE_OFFSET const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
const activateHeightResize = (event: ThreeEvent<PointerEvent>) => { const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
@@ -496,6 +499,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel) window.removeEventListener('pointercancel', onCancel)
window.removeEventListener('keydown', onKeyDown, true)
if (document.body.style.cursor === 'ns-resize') { if (document.body.style.cursor === 'ns-resize') {
document.body.style.cursor = '' document.body.style.cursor = ''
} }
@@ -525,10 +529,21 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
cleanup() cleanup()
} }
// Escape / ⌘Z abort the drag — capture phase so they win over the global
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
e.preventDefault()
e.stopPropagation()
swallowNextClick()
onCancel()
}
dragCleanupRef.current = cleanup dragCleanupRef.current = cleanup
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel) window.addEventListener('pointercancel', onCancel)
window.addEventListener('keydown', onKeyDown, true)
} }
return ( return (
@@ -607,6 +622,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const activateWallMove = (event: ThreeEvent<PointerEvent>) => { const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
@@ -696,6 +712,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => { const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
@@ -31,6 +31,7 @@ import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { isHistoryShortcut } from '../../../lib/history'
import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy' import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy'
import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope' import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope'
import { swallowNextClick } from '../../editor/handles/use-handle-drag' import { swallowNextClick } from '../../editor/handles/use-handle-drag'
@@ -1250,6 +1251,7 @@ function RoofTrimHandles() {
} }
const startDrag = (side: RoofTrimSide, event: ThreeEvent<PointerEvent>) => { const startDrag = (side: RoofTrimSide, event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
const source = sceneRegistry.nodes.get(segment.id) const source = sceneRegistry.nodes.get(segment.id)
if (!source) return if (!source) return
@@ -1340,6 +1342,7 @@ function RoofTrimHandles() {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel) window.removeEventListener('pointercancel', onCancel)
window.removeEventListener('keydown', onKeyDown, true)
if ( if (
document.body.style.cursor === 'ew-resize' || document.body.style.cursor === 'ew-resize' ||
document.body.style.cursor === 'ns-resize' || document.body.style.cursor === 'ns-resize' ||
@@ -1378,10 +1381,21 @@ function RoofTrimHandles() {
cleanup() cleanup()
} }
// Escape / ⌘Z abort the trim drag — capture phase so they win over the
// global use-keyboard arms (⌘Z must never history-jump mid-gesture).
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
e.preventDefault()
e.stopPropagation()
swallowNextClick()
onCancel()
}
dragCleanupRef.current = cleanup dragCleanupRef.current = cleanup
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel) window.addEventListener('pointercancel', onCancel)
window.addEventListener('keydown', onKeyDown, true)
} }
const renderTrimPlane = ( const renderTrimPlane = (
@@ -29,6 +29,7 @@ import useEditor from '../../../store/use-editor'
import { MobilePanelSheet } from './mobile-panel-sheet' import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar' import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display' import { getNodeDisplay } from './node-display'
import { resetDesktopInspectorCollapsed } from './panel-wrapper'
import { ParametricInspector } from './parametric-inspector' import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
@@ -186,6 +187,27 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
return id ? (s.nodes[id as AnyNodeId] ?? null) : null return id ? (s.nodes[id as AnyNodeId] ?? null) : null
}) })
// Node and reference selection are mutually exclusive: selecting a guide
// clears the node selection (handleGuideSelect), but node selection never
// cleared a lingering reference — so clicking a wall with a floorplan
// selected kept showing the reference panel. Clear the stale reference the
// moment a scene selection appears.
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
useEffect(() => {
if (selectedIds.length > 0 || selectedZoneId) {
setSelectedReferenceId(null)
}
}, [selectedIds, selectedZoneId, setSelectedReferenceId])
// The inspector's expanded state is shared across panel swaps, but a fresh
// selection after everything was deselected should open collapsed again.
const hasAnySelection = selectedIds.length > 0 || Boolean(selectedZoneId) || Boolean(selectedReferenceId)
useEffect(() => {
if (!hasAnySelection) {
resetDesktopInspectorCollapsed()
}
}, [hasAnySelection])
if (isMobile) { if (isMobile) {
if (selectedReferenceId) { if (selectedReferenceId) {
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} /> return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
@@ -19,6 +19,13 @@ const DRAG_MARGIN = 8
const CLICK_SLOP = 4 const CLICK_SLOP = 4
let desktopInspectorCollapsed = true let desktopInspectorCollapsed = true
/** Forget the shared expanded state. Called when the last selection clears so
* a fresh selection opens the inspector collapsed — the sharing is only meant
* to survive swaps between panels (roof ↔ segment), not a close/reopen. */
export function resetDesktopInspectorCollapsed() {
desktopInspectorCollapsed = true
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), Math.max(min, max)) return Math.min(Math.max(value, min), Math.max(min, max))
} }
@@ -1,4 +1,4 @@
import { emitter, useScene, validateBuildJson } from '@pascal-app/core' import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { TreeView, VisualJson } from '@visual-json/react' import { TreeView, VisualJson } from '@visual-json/react'
import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
@@ -267,6 +267,9 @@ export function SettingsPanel({
parsed.nodes as Parameters<typeof setScene>[0], parsed.nodes as Parameters<typeof setScene>[0],
parsed.rootNodeIds as Parameters<typeof setScene>[1], parsed.rootNodeIds as Parameters<typeof setScene>[1],
) )
// An import is a scene load: it becomes the undo floor. Without this,
// undo could step back into the pre-import scene state.
clearSceneHistory()
resetSelection() resetSelection()
setPhase('site') setPhase('site')
setPendingImport(null) setPendingImport(null)
@@ -274,6 +277,9 @@ export function SettingsPanel({
const handleResetToDefault = () => { const handleResetToDefault = () => {
clearScene() clearScene()
// Same floor rule as import — undo after a reset must not resurrect the
// old scene (or land on the empty intermediate `unloadScene` state).
clearSceneHistory()
resetSelection() resetSelection()
setPhase('structure') setPhase('structure')
selectDefaultBuildingAndLevel() selectDefaultBuildingAndLevel()
+62 -21
View File
@@ -23,6 +23,7 @@ import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
import { toggleDoorOpenState } from '../lib/door-interaction' import { toggleDoorOpenState } from '../lib/door-interaction'
import { guideEmitter } from '../lib/guide-events' import { guideEmitter } from '../lib/guide-events'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { isActive } from '../lib/interaction/scope'
import { import {
copySelectedNodesToEditorClipboard, copySelectedNodesToEditorClipboard,
pasteEditorClipboardToLevel, pasteEditorClipboardToLevel,
@@ -98,6 +99,64 @@ export const markToolCancelConsumed = () => {
_toolCancelConsumed = true _toolCancelConsumed = true
} }
// Escape's fall-through when no tool consumed the cancel: drop back to the
// select tool (keeping building/level context) and close panels. Tools like
// preset/item placement rely on this — they pass no coordinator onCancel, and
// it is the mode switch unmounting them that destroys the draft.
const exitToSelectAfterUnconsumedCancel = () => {
const currentPhase = useEditor.getState().phase
const currentStructureLayer = useEditor.getState().structureLayer
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
// From zone mode, return to structure select
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('select')
} else {
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setMode('select')
}
useEditor.getState().setFloorplanSelectionTool('click')
// Clear selections to close UI panels, but KEEP the active building and level context.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
}
// ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…)
// reads as "abort this action", not history undo — behave exactly like Escape
// and report whether anything was in flight so the undo/redo arms know to
// skip the history jump. Pointer drags that only listen for their own
// capture-phase keydown never reach here — they stopPropagation first (see
// isHistoryShortcut call sites).
const cancelInteractionForHistoryShortcut = () => {
if (useEditor.getState().referenceScaleActiveGuideId) {
guideEmitter.emit('guide:cancel-reference-scale')
return true
}
_toolCancelConsumed = false
emitter.emit('tool:cancel')
if (_toolCancelConsumed) return true
if (
isActive(useInteractionScope.getState().scope) ||
useViewer.getState().inputDragging ||
// Paused history means a gesture session is live (draft placement, adopted
// move, …) even when no scope/drag flag is set — the preset/item draft
// cycle keeps temporal paused for the whole session, and a history jump
// against a paused store would land on a stale baseline anyway.
!useScene.temporal.getState().isTracking
) {
// A gesture is live but nothing consumed the cancel: finish it the way
// Escape does — the mode switch is what actually cancels tools that hook
// their teardown to unmount (preset/item placement).
exitToSelectAfterUnconsumedCancel()
return true
}
return false
}
export const useKeyboard = ({ export const useKeyboard = ({
isVersionPreviewMode = false, isVersionPreviewMode = false,
disabled = false, disabled = false,
@@ -233,27 +292,7 @@ export const useKeyboard = ({
// Only switch to select mode if no tool had an active mid-action to cancel. // Only switch to select mode if no tool had an active mid-action to cancel.
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool) // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
if (!_toolCancelConsumed) { if (!_toolCancelConsumed) {
const currentPhase = useEditor.getState().phase exitToSelectAfterUnconsumedCancel()
const currentStructureLayer = useEditor.getState().structureLayer
useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
// From zone mode, return to structure select
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('select')
} else {
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setMode('select')
}
useEditor.getState().setFloorplanSelectionTool('click')
// Clear selections to close UI panels, but KEEP the active building and level context.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
} }
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
@@ -323,10 +362,12 @@ export const useKeyboard = ({
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
if (cancelInteractionForHistoryShortcut()) return
runUndo() runUndo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
if (cancelInteractionForHistoryShortcut()) return
runRedo() runRedo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault() e.preventDefault()
+9
View File
@@ -19,3 +19,12 @@ export function runRedo() {
useScene.temporal.getState().redo() useScene.temporal.getState().redo()
refreshSceneAfterHistoryJump() refreshSceneAfterHistoryJump()
} }
/**
* ⌘Z / ⌘⇧Z (undo/redo). Pointer-drag sessions intercept these in the capture
* phase and cancel the gesture instead — mid-drag, "undo" means "abort what my
* mouse is doing", never a history jump under a live pointer.
*/
export function isHistoryShortcut(e: KeyboardEvent) {
return (e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')
}
+13 -1
View File
@@ -1,6 +1,12 @@
'use client' 'use client'
import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core' import {
clearSceneHistory,
nodeRegistry,
resolveLevelId,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import useEditor, { import useEditor, {
hasCustomPersistedEditorUiState, hasCustomPersistedEditorUiState,
@@ -385,6 +391,12 @@ export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
useScene.getState().clearScene() useScene.getState().clearScene()
} }
// The loaded scene is the undo floor. Loading records history entries of
// its own (`unloadScene` + `setScene`/`clearScene` are tracked writes), so
// without this reset a few Ctrl+Z presses could step past the load into the
// pre-load — often empty — state and wipe the whole project.
clearSceneHistory()
syncEditorSelectionFromCurrentScene() syncEditorSelectionFromCurrentScene()
} }
+1 -1
View File
@@ -30,7 +30,7 @@
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"lucide-react": "^1", "lucide-react": "^1",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184", "three": "^0.185",
"zustand": "^5" "zustand": "^5"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -27,7 +27,7 @@
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184", "three": "^0.185",
"zod": "^4", "zod": "^4",
"zustand": "^5" "zustand": "^5"
}, },
@@ -41,7 +41,7 @@
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"react": "^19", "react": "^19",
"three": "^0.184", "three": "^0.185",
"typescript": "6.0.3", "typescript": "6.0.3",
"zod": "^4", "zod": "^4",
"zustand": "^5" "zustand": "^5"
+1 -1
View File
@@ -26,7 +26,7 @@
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
"three": "^0.184" "three": "^0.185"
}, },
"dependencies": { "dependencies": {
"three-bvh-csg": "^0.0.18", "three-bvh-csg": "^0.0.18",
@@ -446,6 +446,14 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
const transparentBackground = useViewer((state) => state.transparentBackground) const transparentBackground = useViewer((state) => state.transparentBackground)
// The shadows toggle drives `renderer.shadowMap.enabled` (via the Canvas
// `shadows` prop) rather than the lights' `castShadow`: toggling castShadow
// off disposes the shadow map's GPU texture but three r184's WebGPU node
// cache keeps the shadows-on builder state that references it, so toggling
// back on reuses destroyed resources and every frame submit fails with a
// GPUValidationError. Disabling at the renderer level rebuilds materials
// without disposing anything, so the round-trip is safe.
const shadowsEnabled = useViewer((state) => state.shadows)
useLayoutEffect(() => { useLayoutEffect(() => {
if (transparent === undefined) return if (transparent === undefined) return
@@ -549,7 +557,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
}} }}
shadows={{ shadows={{
type: THREE.PCFShadowMap, type: THREE.PCFShadowMap,
enabled: true, enabled: shadowsEnabled,
}} }}
> >
<FrameLimiter fps={50} /> <FrameLimiter fps={50} />
@@ -244,8 +244,16 @@ export function Lights() {
return ( return (
<> <>
{theme.lights.map((light, index) => ( {theme.lights.map((light, index) => (
// The user-facing shadows toggle must NOT flip `castShadow` at runtime:
// three r184's WebGPU node cache keys builder state by castShadow, but
// evicts with the post-toggle key, so flipping off disposes the shadow
// map's GPU texture while the shadows-on cache entry (still referencing
// it) survives. Re-enabling then reuses that stale state and every
// submit fails ("Invalid CommandBuffer ... renderContext_N"). The
// toggle is applied via `renderer.shadowMap.enabled` (Canvas `shadows`
// prop in viewer/index.tsx), which round-trips without disposing.
<directionalLight <directionalLight
castShadow={Boolean(light.castShadow) && !SHADOWS_DISABLED && shadows} castShadow={Boolean(light.castShadow) && !SHADOWS_DISABLED}
key={`${index}-${light.position.join(',')}`} key={`${index}-${light.position.join(',')}`}
position={light.position} position={light.position}
ref={(ref) => { ref={(ref) => {
@@ -256,7 +264,7 @@ export function Lights() {
shadow-normalBias={0.3} shadow-normalBias={0.3}
shadow-radius={2} shadow-radius={2}
> >
{light.castShadow && !SHADOWS_DISABLED && shadows ? ( {light.castShadow && !SHADOWS_DISABLED ? (
<orthographicCamera <orthographicCamera
attach="shadow-camera" attach="shadow-camera"
bottom={-shadowCameraSize} bottom={-shadowCameraSize}
@@ -5,9 +5,7 @@ import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import { import {
add, add,
colorToDirection,
diffuseColor, diffuseColor,
directionToColor,
float, float,
mix, mix,
mrt, mrt,
@@ -34,6 +32,7 @@ import { inkedEdges } from '../../lib/ink-edges'
import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers' import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { mergedOutline } from '../../lib/merged-outline-node' import { mergedOutline } from '../../lib/merged-outline-node'
import { getSceneTheme } from '../../lib/scene-themes' import { getSceneTheme } from '../../lib/scene-themes'
import { packNormalToRGB, unpackRGBToNormal } from '../../lib/tsl-compat'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
// Scene-referred grade applied before the output tone mapping (AgX). AgX rolls // Scene-referred grade applied before the output tone mapping (AgX). AgX rolls
@@ -423,7 +422,7 @@ const PostProcessingPasses = ({
mrt({ mrt({
output, output,
diffuseColor, diffuseColor,
normal: directionToColor(normalView), normal: packNormalToRGB(normalView),
}), }),
) )
scenePassDepth = scenePass.getTextureNode('depth') scenePassDepth = scenePass.getTextureNode('depth')
@@ -431,7 +430,7 @@ const PostProcessingPasses = ({
const normalTexture = scenePass.getTexture('normal') const normalTexture = scenePass.getTexture('normal')
normalTexture.type = UnsignedByteType normalTexture.type = UnsignedByteType
// Extract normal from color-encoded texture (SSGI consumes the node form) // Extract normal from color-encoded texture (SSGI consumes the node form)
sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv))) sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)))
} }
if (ssgiEnabled) { if (ssgiEnabled) {
@@ -452,14 +451,16 @@ const PostProcessingPasses = ({
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
const giTexture = (giPass as any).getTextureNode() // r185: SSGI renders AO and GI into two separate textures (R8 + RG11B10)
// exposed via getAONode()/getGINode() instead of one rgba texture.
const aoTexture = (giPass as any).getAONode()
const gi = giPass.rgb const gi = (giPass as any).getGINode().rgb
let ao: any let ao: any
if (denoiseEnabled) { if (denoiseEnabled) {
// DenoiseNode only denoises RGB — alpha is passed through unchanged. // DenoiseNode only denoises RGB — alpha is passed through unchanged.
// SSGI packs AO into alpha, so we remap it into RGB before denoising. // SSGI's AO is a single red channel, so we remap it into RGB before denoising.
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1)) const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera) const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
denoisePass.index.value = 0 denoisePass.index.value = 0
denoisePass.radius.value = 4 denoisePass.radius.value = 4
@@ -467,7 +468,7 @@ const PostProcessingPasses = ({
} else { } else {
// Diagnostic path: feed raw noisy SSGI AO straight through. Will // Diagnostic path: feed raw noisy SSGI AO straight through. Will
// look grainy — that's the point, it isolates denoise cost. // look grainy — that's the point, it isolates denoise cost.
ao = giTexture.a ao = aoTexture.r
} }
// AO is a near/mid-field cue like the ink: fade it out with raw depth // AO is a near/mid-field cue like the ink: fade it out with raw depth
+3 -1
View File
@@ -69,6 +69,7 @@ export {
collectIsolationSubtree, collectIsolationSubtree,
isIsolationActive, isIsolationActive,
} from './lib/isolation' } from './lib/isolation'
export { ensureKtx2Support } from './lib/ktx2-loader'
export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
export { export {
applyMaterialPresetToMaterials, applyMaterialPresetToMaterials,
@@ -107,8 +108,9 @@ export {
SCENE_THEMES, SCENE_THEMES,
type SceneTheme, type SceneTheme,
} from './lib/scene-themes' } from './lib/scene-themes'
export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat'
export { useItemLightPool } from './store/use-item-light-pool' export { useItemLightPool } from './store/use-item-light-pool'
export { default as useViewer } from './store/use-viewer' export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer'
export { CeilingSystem } from './systems/ceiling/ceiling-system' export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { export {
createColumnBoxGeometry, createColumnBoxGeometry,
+8 -17
View File
@@ -1,15 +1,6 @@
import { import { abs, float, max, min, mix, screenSize, screenUV, smoothstep, vec2 } from 'three/tsl'
abs,
colorToDirection, import { unpackRGBToNormal } from './tsl-compat'
float,
max,
min,
mix,
screenSize,
screenUV,
smoothstep,
vec2,
} from 'three/tsl'
// Screen-space ink outline (SketchUp / Moebius look). Reads the scene-pass // Screen-space ink outline (SketchUp / Moebius look). Reads the scene-pass
// depth + normal MRT and inks two signals: // depth + normal MRT and inks two signals:
@@ -62,11 +53,11 @@ export function inkedEdges({
// ≈ metres of step / near (near≈0.1): ~5cm starts a line, ~25cm solid. // ≈ metres of step / near (near≈0.1): ~5cm starts a line, ~25cm solid.
const depthEdge = smoothstep(float(0.5), float(2.5), depthMetric).mul(noiseGate) const depthEdge = smoothstep(float(0.5), float(2.5), depthMetric).mul(noiseGate)
const nC = colorToDirection(normalTex.sample(uvN)).normalize() const nC = unpackRGBToNormal(normalTex.sample(uvN)).normalize()
const nR = colorToDirection(normalTex.sample(uvN.add(vec2(px.x, 0)))).normalize() const nR = unpackRGBToNormal(normalTex.sample(uvN.add(vec2(px.x, 0)))).normalize()
const nL = colorToDirection(normalTex.sample(uvN.sub(vec2(px.x, 0)))).normalize() const nL = unpackRGBToNormal(normalTex.sample(uvN.sub(vec2(px.x, 0)))).normalize()
const nU = colorToDirection(normalTex.sample(uvN.add(vec2(0, px.y)))).normalize() const nU = unpackRGBToNormal(normalTex.sample(uvN.add(vec2(0, px.y)))).normalize()
const nD = colorToDirection(normalTex.sample(uvN.sub(vec2(0, px.y)))).normalize() const nD = unpackRGBToNormal(normalTex.sample(uvN.sub(vec2(0, px.y)))).normalize()
// Ink is a near/mid-field affordance: fade it out with raw depth so the // Ink is a near/mid-field affordance: fade it out with raw depth so the
// horizon (the infinite ground disc vanishing against the backdrop) and // horizon (the infinite ground disc vanishing against the backdrop) and
// other far-field depth cliffs never draw a line across the sky junction. // other far-field depth cliffs never draw a line across the sky junction.
+17
View File
@@ -122,6 +122,22 @@ ktx2Loader.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@mas
const configuredRenderers = new WeakSet<object>() const configuredRenderers = new WeakSet<object>()
const warnedRenderers = new WeakSet<object>() const warnedRenderers = new WeakSet<object>()
let resolveKtx2Ready: () => void
const ktx2ReadyPromise = new Promise<void>((resolve) => {
resolveKtx2Ready = resolve
})
/**
* Resolves once `detectSupport` has succeeded for any renderer. `.ktx2` loads
* issued before that point would throw inside KTX2Loader ("Missing
* initialization with `.detectSupport( renderer )`"), so texture loaders await
* this instead of failing — covers materials created while the renderer is
* still initializing (e.g. a standalone capture canvas).
*/
export function whenKtx2Ready(): Promise<void> {
return ktx2ReadyPromise
}
/** Returns true once support has been detected for this renderer (KTX2 safe to load). */ /** Returns true once support has been detected for this renderer (KTX2 safe to load). */
export function ensureKtx2Support(renderer: unknown): boolean { export function ensureKtx2Support(renderer: unknown): boolean {
const key = renderer as object | null const key = renderer as object | null
@@ -130,6 +146,7 @@ export function ensureKtx2Support(renderer: unknown): boolean {
try { try {
;(ktx2Loader as unknown as { detectSupport: (r: unknown) => void }).detectSupport(renderer) ;(ktx2Loader as unknown as { detectSupport: (r: unknown) => void }).detectSupport(renderer)
configuredRenderers.add(key) configuredRenderers.add(key)
resolveKtx2Ready()
return true return true
} catch (error) { } catch (error) {
// Some WebGPU flows can transiently call this before backend init; don't // Some WebGPU flows can transiently call this before backend init; don't
+12 -3
View File
@@ -15,7 +15,7 @@ import { float, mix, positionViewDirection, transformedNormalView } from 'three/
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
import { resolveCdnUrl } from './asset-url' import { resolveCdnUrl } from './asset-url'
import { isKtx2Url, ktx2Loader } from './ktx2-loader' import { isKtx2Url, ktx2Loader, whenKtx2Ready } from './ktx2-loader'
import { getSceneTheme } from './scene-themes' import { getSceneTheme } from './scene-themes'
export type RenderShading = 'solid' | 'rendered' export type RenderShading = 'solid' | 'rendered'
@@ -308,8 +308,17 @@ async function loadPresetTexture(
const existingPromise = textureLoadPromises.get(cacheKey) const existingPromise = textureLoadPromises.get(cacheKey)
if (existingPromise) return existingPromise if (existingPromise) return existingPromise
const promise = pickTextureLoader(resolvedPath) // `.ktx2` loads wait for `detectSupport` (KTX2Loader.load throws before it) —
.loadAsync(resolvedPath) // materials can be created while a capture canvas's renderer is still
// initializing, and failing here would cache the material permanently
// texture-less (white).
const load = isKtx2Url(resolvedPath)
? whenKtx2Ready().then(() =>
(ktx2Loader as unknown as THREE.TextureLoader).loadAsync(resolvedPath),
)
: textureLoader.loadAsync(resolvedPath)
const promise = load
.then((texture) => { .then((texture) => {
applyTextureProperties(texture, props, slot) applyTextureProperties(texture, props, slot)
setTextureCacheKey(texture, cacheKey) setTextureCacheKey(texture, cacheKey)
+16
View File
@@ -0,0 +1,16 @@
import * as TSL from 'three/tsl'
/**
* three runs at 0.185 but @types/three is pinned at 0.184: the 0.185 typings
* make tsgo's type inference allocate unboundedly (microsoft/typescript-go
* #2125 class) and OOM the machine. r185 renamed directionToColor /
* colorToDirection to packNormalToRGB / unpackRGBToNormal — re-export the new
* runtime names under the old names' signatures. Delete this file (and the
* @types/three pin) once tsgo handles the 0.185 types; the `typeof` references
* to the removed old names will fail the build as a reminder.
*/
export const packNormalToRGB = (TSL as unknown as { packNormalToRGB: typeof TSL.directionToColor })
.packNormalToRGB
export const unpackRGBToNormal = (
TSL as unknown as { unpackRGBToNormal: typeof TSL.colorToDirection }
).unpackRGBToNormal
+82 -4
View File
@@ -79,6 +79,10 @@ type ViewerState = {
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
setUnit: (unit: 'metric' | 'imperial') => void setUnit: (unit: 'metric' | 'imperial') => void
/** True once the user explicitly picked a unit. Until then `unit` is a
* locale-derived default and is not persisted, so the default can keep
* tracking the browser locale across sessions. */
unitExplicit: boolean
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
@@ -165,6 +169,7 @@ type PersistedViewerState = Partial<
| 'edges' | 'edges'
| 'shadows' | 'shadows'
| 'unit' | 'unit'
| 'unitExplicit'
| 'levelMode' | 'levelMode'
| 'wallMode' | 'wallMode'
| 'projectPreferences' | 'projectPreferences'
@@ -179,6 +184,63 @@ const UNITS = ['metric', 'imperial'] as const
const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const
const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const
// Countries still on imperial/US customary units: United States, Liberia, Myanmar.
const IMPERIAL_REGIONS = ['US', 'LR', 'MM']
// IANA zones for those countries. The timezone tracks the OS clock (actual
// location), unlike navigator.language where en-US is a common default for
// users far outside the US.
const IMPERIAL_TIMEZONES = new Set([
'America/New_York',
'America/Detroit',
'America/Kentucky/Louisville',
'America/Kentucky/Monticello',
'America/Indiana/Indianapolis',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Vevay',
'America/Indiana/Tell_City',
'America/Indiana/Knox',
'America/Chicago',
'America/Menominee',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/North_Dakota/Beulah',
'America/Denver',
'America/Boise',
'America/Phoenix',
'America/Los_Angeles',
'America/Anchorage',
'America/Juneau',
'America/Sitka',
'America/Metlakatla',
'America/Yakutat',
'America/Nome',
'America/Adak',
'Pacific/Honolulu',
'America/Puerto_Rico',
'Pacific/Guam',
'Africa/Monrovia', // Liberia
'Asia/Yangon', // Myanmar
'Asia/Rangoon', // Myanmar (legacy alias)
])
function detectDefaultUnit(): ViewerState['unit'] {
if (typeof navigator === 'undefined') return 'metric'
try {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (timeZone) return IMPERIAL_TIMEZONES.has(timeZone) ? 'imperial' : 'metric'
// No timezone available: fall back to an explicit locale region subtag
// only (never maximize() — it turns a bare "en" into region US).
const region = new Intl.Locale(navigator.language).region
return region && IMPERIAL_REGIONS.includes(region) ? 'imperial' : 'metric'
} catch {
return 'metric'
}
}
function pickString<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T { function pickString<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback
} }
@@ -226,7 +288,9 @@ function normalizePersistedViewerState(value: unknown): PersistedViewerState {
colorPreset: pickString<ColorPreset>(state.colorPreset, COLOR_PRESETS, 'clay'), colorPreset: pickString<ColorPreset>(state.colorPreset, COLOR_PRESETS, 'clay'),
edges: pickString<EdgeMode>(state.edges, EDGE_MODES, 'soft'), edges: pickString<EdgeMode>(state.edges, EDGE_MODES, 'soft'),
shadows: typeof state.shadows === 'boolean' ? state.shadows : true, shadows: typeof state.shadows === 'boolean' ? state.shadows : true,
unit: pickString<ViewerState['unit']>(state.unit, UNITS, 'metric'), unit: pickString<ViewerState['unit']>(state.unit, UNITS, detectDefaultUnit()),
unitExplicit:
typeof state.unit === 'string' && UNITS.includes(state.unit as ViewerState['unit']),
levelMode: pickString<ViewerState['levelMode']>(state.levelMode, LEVEL_MODES, 'stacked'), levelMode: pickString<ViewerState['levelMode']>(state.levelMode, LEVEL_MODES, 'stacked'),
wallMode: pickString<ViewerState['wallMode']>(state.wallMode, WALL_MODES, 'up'), wallMode: pickString<ViewerState['wallMode']>(state.wallMode, WALL_MODES, 'up'),
projectPreferences: normalizeProjectPreferences(state.projectPreferences), projectPreferences: normalizeProjectPreferences(state.projectPreferences),
@@ -295,8 +359,9 @@ const useViewer = create<ViewerState>()(
shadows: true, shadows: true,
setShadows: (shadows) => set({ shadows }), setShadows: (shadows) => set({ shadows }),
unit: 'metric', unit: detectDefaultUnit(),
setUnit: (unit) => set({ unit }), unitExplicit: false,
setUnit: (unit) => set({ unit, unitExplicit: true }),
levelMode: 'stacked', levelMode: 'stacked',
setLevelMode: (mode) => set({ levelMode: mode }), setLevelMode: (mode) => set({ levelMode: mode }),
@@ -431,7 +496,7 @@ const useViewer = create<ViewerState>()(
colorPreset: state.colorPreset, colorPreset: state.colorPreset,
edges: state.edges, edges: state.edges,
shadows: state.shadows, shadows: state.shadows,
unit: state.unit, ...(state.unitExplicit ? { unit: state.unit } : {}),
levelMode: state.levelMode, levelMode: state.levelMode,
wallMode: state.wallMode, wallMode: state.wallMode,
projectPreferences: state.projectPreferences, projectPreferences: state.projectPreferences,
@@ -440,4 +505,17 @@ const useViewer = create<ViewerState>()(
), ),
) )
/** Apply an authoritative country code (e.g. IP-derived by the host app) as
* the unit default. Stronger signal than the timezone heuristic used at store
* creation, but still a default: it never overrides an explicit user choice
* and is not persisted (the unit only sticks once the user touches the
* toggle). */
export function applyCountryUnitDefault(country: string | null | undefined) {
if (!country) return
const state = useViewer.getState()
if (state.unitExplicit) return
const unit = IMPERIAL_REGIONS.includes(country.toUpperCase()) ? 'imperial' : 'metric'
if (state.unit !== unit) useViewer.setState({ unit })
}
export default useViewer export default useViewer