Repository navigation
@meterup/pascal-editor@1.0.0-beta.6
Pre-releaseMinor Changes
-
d3aa350: Let a host bind the 3D camera's gestures.
The bindings were fixed: left drag selects unless you're in preview mode or
holding space, middle pans, right rotates, wheel zooms. A host whose scene is
read-only, or whose users are on laptop trackpads with no middle button, had no
say — panning meant the space bar.cameraInputonuseViewermerges over the defaults rather than replacing
them, so an unset entry keeps whatever the current mode asked for:useViewer.getState().setCameraInput({ mouseButtons: { left: "pan" }, touches: { one: "pan" }, });
Actions are named for what the user sees (
pan,rotate,zoom,dolly,
andzoomPan/dollyPanfor the combined two-finger gestures) rather than
mirroring the camera library's enum, so the binding API doesn't tie a host to
that dependency. The wheel and the one-finger slots take narrower sets than the
others, because a wheel has one axis and a pinch needs two fingers.Merging also covers the modifier path, which rebuilds the bindings on every key
change, so holding space no longer drops a host's overrides. -
068468e: Add
onHeadingChangeto the floor-plan compass slot.needleRefis the only way a host gets the per-frame heading, and it's typed
RefObject<SVGSVGElement>for the element that visually rotates. That works for
a host whose rotating element is an<svg>it owns, and not at all for one
composing an existing icon component, which has nowhere to put the ref and
falls back to re-rendering per commit.onHeadingChangecarries the same values as a subscription:floorplanCompassSlot={({ northRotationDeg, alignToNorth, onHeadingChange }) => { const heading = useMotionValue(northRotationDeg) useEffect(() => onHeadingChange((deg) => heading.set(deg)), [onHeadingChange]) return <motion.div style={{ rotate: heading }} onClick={alignToNorth}>{glyph}</motion.div> }}
It fires immediately with the current heading, because each source only writes
on its own updates and a still view sends nothing, so a subscriber that waited
would show north until the first movement.Deliberately a callback rather than an animation library's value type, so the
slot contract stays library-agnostic and a host can drive a plain style write
instead if it prefers.needleRefis unchanged. -
9a4878b: Add
floorplanDividerSlotfor host-owned split-view dividers.The divider's pill was a fixed neutral capsule, so a host whose own resize
handles look nothing like it had no way to reconcile the two, and nowhere to
hang a control that belongs on the boundary between the panes.The slot replaces the pill and receives the handler that starts the pane drag,
so a host can put it on whatever the user actually grabs:<Editor floorplanDividerSlot={({ startResize }) => ( <MyControlGroup> <MyToggle onClick={(event) => event.stopPropagation()} /> <MyGrabHandle onPointerDown={startResize} /> </MyControlGroup> )} />
The divider itself still starts a drag on pointer down, so controls that aren't
the grab handle need to stop propagation or clicking them resizes the panes.Only rendered in split view. Omitting the slot keeps the built-in pill, which
also now carriesdata-pascal-floorplan-divider-thumbfor hosts that only want
to restyle it. -
2f5c4e1: Rebase the fork onto upstream
pascalorg/editor@4c34032c(2026-08-24) — 478
commits, and a move off the fork's own0.9.xline onto upstream's1.0.0-beta
versioning. The fork now carries nothing but its release machinery and two
editor patches; everything else is upstream.Nothing we previously exported was removed. The public API grew substantially
(core 172 → 388 exports, viewer 106 → 193, editor 156 → 470) without breaking
underneath consumers.What matters most for consumers:
- Near-collinear wall junctions no longer explode.
calculateJunctionIntersectionsnow rejects a miter on its outcome, capped
atMITER_LIMIT(10) half-thicknesses, rather than only rejecting walls that
are exactly parallel. A junction where two walls run straight through each
other but miss collinearity by a fraction of a degree used to put a plan
vertexhalfThickness / sin(θ)away — kilometres out, collapsing the 2D
fit-to-bounds into one flat rectangle and firing beams across the 3D scene. It
now butts. Imported floor plans with off-axis wall runs render correctly. - Level assembly is dramatically faster.
findJunctionsbuckets walls into
a spatial grid by AABB instead of testing every wall against every junction
point. On a 1081-wall imported floor that is 584 ms → 11 ms per call, and
WallSystemrepeats the call every frame while progressively rebuilding. - Vertical building model. Scenes now carry explicit building and level
structure (buildingId,schema/nodes/building.ts), with load-time
migrations for saves that predate it — vertical, site-child, wall-slot,
elevator, retired-floorplan and construction-dimension. Anything synthesizing
aSceneGraphrather than loading a saved one needs to match the new shape;
this is the single change most likely to require work on the consumer side. - New packages:
capture-protocol,capture-viewer,cli. New core
systems alongsidewall/andstair/:fence/,slab/,elevator/. healSceneNodesrepairs already-saved scenes on load, dropping
degenerate nodes such as zero-length walls.
Three fork patches are dropped here because upstream made them redundant:
- The
floorplan-registry-layergeometry cache. Upstream landed its own
geometryCacheRef/levelDataCacheRefmemoisation plus thefindJunctions
prefilter above, which together address the same stall from further up. - The
ThreeElements/lineBasicNodeMaterialnarrowing inviewerthat let
tsgocheck the package. Upstream adopted the TypeScript 7 native compiler
and landed the identical narrowing. crossOriginon the 2D guide image. It forced the 2D<image>to fetch in
the same CORS mode as the 3D texture load so the two shared one browser cache
entry instead of downloading the image twice. Consumers that hand Pascal
same-origin blob URLs never hit the split in the first place, and the flag is
actively wrong for guide images served without CORS headers.
- Near-collinear wall junctions no longer explode.
-
068468e: Add
useFloorplanNavigationControlsfor host-driven 2D navigation.A host could already move the plan with
publishNavigationSyncPose, but only by
knowing rather a lot: that a pose is{ target, azimuth, viewWidth }, that
'host'is the one source that always reaches the plan, that azimuth is
radians, that screen deltas have to be rotated into the plan by its own azimuth
before they can move the target, and that animating means publishing a stream
rather than a single pose. The demo inapps/editordid all of that by hand.The hook does it instead:
const plan = useFloorplanNavigationControls() useGesture({ onDrag: ({ delta: [dx, dy] }) => plan.panByPixels(-dx, -dy), onPinch: ({ offset: [scale] }) => plan.zoomTo(scale), }) <button onClick={() => plan.rotateByDegrees(90, { durationMs: 260 })}>Rotate</button>
Rotation tweens take the short way round, so turning from 350° to 10° travels
20° forwards rather than 340° back, and an in-flight tween is cancelled by the
next command or by unmount.Whether a command moves the 3D camera too is the link's business, not the
hook's: these publish as'host', which always drives the plan, and the camera
follows only whilefloorplanNavigationLinkis on.The hook reads the shared pose at call time through
getPose()and
getMetersPerPixel()rather than subscribing to it.navigationSyncPose
changes every frame while anything is moving, so a subscription would re-render
the caller at that rate, which for a component that renders the editor is the
whole cost the imperative viewport exists to avoid. The returned object has a
stable identity, so it can go straight into an effect's dependencies.Supporting this, the floor-plan panel now publishes its measured size in CSS
pixels. A pose carries only its width in metres, so until now nobody but the
panel could convert one back to screen space, which is why the playground had
to stand inwindow.innerWidthand why it was wrong in split view. The panel
also republishesnavigationSyncPoseon every committed viewport change rather
than seeding it once: a single seed goes stale as soon as the plan moves any
other way, most obviously the initial fit, and a host reading that stale pose
and publishing it back dragged the plan to wherever the seed was taken. -
5fbeb64: Add
hideBuiltInOverlays, and let hosts style the split-view divider.Hosts that bring their own toolbar had one way to get rid of the editor's
floating overlays: hold capture mode on. That works, but capture mode is a
snapshot mode. It also unmountsSelectionManager, which owns the highlight
material and the outliner as well as click handling, so the canvas ends up
unselectable and nothing highlights even when something is selected from the
2D plan. Permanent capture mode is the wrong tool and this was the cost.hideBuiltInOverlayshides exactly the chrome and nothing else:<Editor hideBuiltInOverlays layoutVersion="v2" />
That drops the level selector, the action menu, the panel manager, the helper
manager, the built-in Plugins rail entry and the camera-controls hint, in both
layout versions. The Plugins entry matters becauseuseHostPanelsappends it
in theeditworkspace, so a host passingsidebarTabs={[]}still got a
non-empty tab bar and the v2 layout rendered a left column for a panel it never
asked for. The camera hint matters because it names the built-in gestures,
which a host overriding them withcameraInputhas just made wrong.Host panels and registered plugin panels are untouched, so a host can hide the
built-in rail entry and still show its own. Selection, hover highlighting and
the editing handles all keep working, so a host can replace the chrome and
still have an interactive canvas. Capture mode is unchanged and still
suppresses the lot for the duration of a shot.The split-view divider also picks up
data-pascal-floorplan-divideron its hit
area anddata-pascal-floorplan-divider-thumbon the pill, so a host can
restyle it to match its own resize handles:[data-pascal-floorplan-divider-thumb] { background: var(--my-handle-color); }
-
895cf8f: Add
useFloorplanNavigationanduseCameraNavigationso a host can own either
pane's gestures.The built-in bindings are fixed: middle-drag or space and left-drag to pan the
plan, right-drag to rotate, wheel and pinch to zoom, and the 3D equivalents on
the camera. They suit a full-page editor but read oddly embedded, and on a
trackpad there is no 2D pan gesture at all without the keyboard.Ownership is claimed by the hook that does the work, and released when it
unmounts:const plan = useFloorplanNavigation({ input: "host" }); const camera = useCameraNavigation({ input: "host", bindings });
So the claim and the binding are the same call, and declaring
'host'can't
leave a pane that silently has no gestures because nothing bound any. Omitting
inputfalls back to'builtin', which is the safe direction: a pane nobody
claimed keeps its own.For the plan,
'host'suppresses the pointer-down handler, the space-pan
modifier, and the wheel and pinch listeners. Node interaction, selection and
the compass are unaffected, and space key-up stays unguarded so flipping modes
mid-press can't leave the pan modifier stuck on.The two hooks are counterparts and live together at
lib/navigation/{floorplan,camera}.ts.FloorplanNavigationInputcomes from
the editor store.
Patch Changes
-
2251db5: Add an
alignmentGuideStrokeslot toFloorplanPaletteand draw the 2D snap
guides from it.FloorplanAlignmentGuideLayerhardcoded#ef4444, so the one piece of 2D
chrome most visible during a drag was the one piece a theme couldn't reach.
It already consumes the render context, so it now reads the slot and keeps the
red as its no-provider fallback. -
40fd549: Keep the compass live while the plan is being rotated.
A rotation with the panel open moves the view with a CSS transform on the SVG,
and the compass sits outside that SVG, so it wasn't carried along. The needle
held its last committed heading for the whole gesture and jumped when the
viewport settled.It was invisible with the built-in gestures, which commit often enough to hide
it, and obvious under host-driven rotation, where a tween streams poses for a
few hundred milliseconds before anything commits. A host consuming
onHeadingChangesaw nothing at all for the duration, since that signal is
published from the same place the needle is written.The presentation path now writes the heading as it goes, so both the built-in
needle andonHeadingChangetrack the gesture. -
496b7a7: Add an
appearanceprop controlling the editor's colour mode, replacing the
unconditionaldocument.body.classList.add('dark').Defaults to
'dark', so nothing changes for existing hosts.'light'clears
the class instead, and'inherit'leaves it alone for hosts that embed the
editor in a page whose colour mode they already own. The effect now records
the prior state and restores it on unmount, rather than assuming it set the
class itself.The layout roots and chrome overlays also pinned
darkon their own
className, which would have made'light'and'inherit'a lie. They now
inherit from the one class the prop manages. Every one of them sits inside
document.body(the mobile panel sheet portals there), so nothing needs its
own copy.Note this covers the editor's own Tailwind chrome. Scene and floor-plan
colours followsceneTheme'sappearanceand the newfloorplanPalette
override, which are independent. -
b66c82c: Ship type declarations and resolve them via a
typesexport condition.@meterup/pascal-editoris the only publishable package that exposes raw
TypeScript rather than build output —exports["."]points straight at
./src/index.tsx. Every TypeScript consumer therefore type-checks the editor's
entire source tree, all 406 non-test files of it, on top of its own. That is
slow enough to matter on its own, and it also drags whatever the source
transitively pulls in — notably the react-three-fiber JSX augmentation — into
the consumer's globalJSX.IntrinsicElementsunion, where it conflicts with
React 19's DOM element types and can push unrelated files past the
type-checker's complexity limits.Emit declaration-only output to
dist/via atsconfig.build.jsonand point
thetypesexport condition atdist/index.d.ts. The runtime conditions stay
onsrc/so embedders' bundlers are unaffected; only type resolution changes.
Consumers now see the public type surface instead of the implementation.One consequence worth knowing: the
typescondition applies to self-
referential@meterup/pascal-editorimports too, so a stale or missingdist/
makes the package's own type-check report phantom "has no exported member"
errors. Build before checking types. -
6fa479d: Respect the scene
readOnlyflag in the 2D floor plan. When the scene is
read-only (e.g. version-preview mode,isVersionPreview), the registry action
menu is hidden and the interactive edit handles (move / resize / vertex /
midpoint / edge / rotate) are stripped from the overlay pass, while selection
hit-lines, labels and dimensions still render. This mirrors the existing 3D
noEditinggating so a locked plan is fully view-only in both views.It composes with, rather than replaces, the multi-selection
stripHandleChrome
pass: that one also drops dimensions, dimension labels and equal-spacing badges,
because a multi-selection has no use for per-node measurements. A locked plan
does — it is still worth measuring — so the read-only strip removes only the
kinds that accept pointer input. -
97cf254: Add
floorplanBackgroundSlot, a host-owned SVG backdrop for the 2D floor plan.floorplanSceneSlotis the last child of the transformed scene group, so its
content paints over the plan. That makes it unusable for a backdrop, since SVG
has noz-index. The new slot renders as the first child of the same group,
under the grid and every geometry layer, and is a render prop so it can size
itself from the panel's own numbers:<Editor floorplanBackgroundSlot={({ bounds, unitsPerPixel, rotationDeg }) => ( <rect x={bounds.minX} y={bounds.minY} width={bounds.maxX - bounds.minX} height={bounds.maxY - bounds.minY} fill="url(#host-dot-pattern)" /> )} />
boundsis the same rotation-inflated extent the grid spans, so a backdrop
sized to it cannot expose a corner at any rotation.unitsPerPixelis there to
hold pattern detail at a fixed screen size across zoom.rotationDegis the
scene rotation, for content that needs to counter-rotate. Being inside the
scene group, the backdrop pans and rotates with the plan for free.FloorplanBackgroundContextis exported for typing the callback. -
b6315dc: Add
floorplanCompassSlot, replacing the built-in floor-plan compass.The built-in one is hardcoded down to its needle colours and its corner, so a
host matching its own design system had no way in short of hiding it with CSS.
The slot receives what the built-in control renders from, so a replacement can
match its behaviour rather than approximate it: the heading in degrees, an
align-to-north callback, and the needle ref the live camera stream writes to per
frame while the plan is hidden.<Editor floorplanCompassSlot={({ northRotationDeg, alignToNorth, needleRef }) => ( <MyCompass onClick={alignToNorth} ref={needleRef} rotation={northRotationDeg} /> )} />
Return
nullfor no compass at all. It applies to both compass surfaces, the
editor's 2D panel andFloorplanPreview, so Pascal's compass doesn't reappear
in preview mode.FloorplanCompassContextandFloorplanCompassSlotare
exported for typing the callback. -
e9ed009: Stop the 2D grid swapping its major and minor lines on every zoom step.
majorStepwas pinned atminorStep * 2, so it inherited every minor
doubling. Each time the fine lines coarsened, the coarse frame moved with them
and half the emphasised lines demoted to minor, in one jump, across the whole
grid. Zooming read as the grid flip-flopping rather than refining.The majors get their own screen-spacing threshold now, so the two ladders cross
at different zooms and the usual step refines the fine lines while the coarse
frame stays put. Swept over a zoom-out, the number of steps that move both went
from 5 to 0.Both ladders still double, so
majorStep / minorStepstays a power of two.
That's load-bearing: the minor path drops whatever lands on a major line via an
alignment test, and a ratio like 2.5 would leave the lattices incommensurate,
drawing fine lines under coarse ones and silently missing exclusions.The grid maths moved to
floorplan-grid.tsand is covered by
floorplan-grid.test.ts, matching how the other pure floor-plan helpers in this
directory are structured and tested. No behaviour change beyond the ladder. -
118b3a2: Re-rule the 2D grid during a pan or zoom instead of after it.
The grid's level of detail only caught up when the viewport committed, roughly
300ms after you stopped, so zooming out crowded the lines together and then
popped them to the right density once you let go.The cause is structural rather than a missing dependency. Pan and zoom write the
SVG'sviewBoximperatively precisely so they don'tsetStateon
FloorplanPanel, whose render costs ~120-220ms, so anything the panel derives
in React is a gesture behind by construction. Writing the path attribute behind
React's back doesn't work either: the next render restores the committed path,
and the grid alternates between the two.So the live view box is published to a small store and
FloorplanGridLayer
subscribes and rules itself. Same approach asuseFloorplanDraftPreviewand the
other hot-value stores here: the panel stays out of the render path and only the
two-path layer re-renders per frame.Two things keep that affordable, since
buildGridPathwalks the whole ruled
area:- the ruled bounds snap to
GRID_QUANTUM_STEPS, so a pan rebuilds the path once
per few steps of travel rather than per pointer move GRID_MARGIN_STEPSstays larger than the quantum, so the drift in between is
already covered
floorplanBackgroundSlot'sboundsis unchanged: it's still the
rotation-inflated view, without the grid's margin, which is an implementation
detail of how often the grid rebuilds. - the ruled bounds snap to
-
7b334e6: Rule the 2D grid past the edge of the view so a gesture doesn't outrun it.
Pan and zoom are applied imperatively (
applyFloorplanViewportImperatively
writes theviewBoxdirectly) and only re-render on a 300ms debounce, so a path
ruled exactly to the view leaves an unruled margin the moment either moves.The slack is counted in minor steps, not taken as a multiple of the view. The
cost inbuildGridPathis the number of subpaths, and a multiple of the view
scales that with zoom without bound: a large enoughdgets geometry dropped by
the renderer and lines stop crossing the scene. Counted in steps the extra is
fixed at2 * GRID_MARGIN_STEPSper axis at every zoom, while the distance
covered still scales, because the step itself scales with zoom. -
c99b617: Rule the 2D grid from the viewport that's on screen, not the last committed one.
gridStepsandgridBoundsderived fromviewBox, the committed viewport,
while the SVG itself renderspresentationViewBox, which tracks the imperative
updates a pan or zoom makes. The two agree outside a gesture. During one they
don't, so any render that happened for an unrelated reason (a selection, a
hover) re-ruled the grid for the pre-gesture view and undid the gesture's effect
on it until the 300ms viewport commit caught up.Both now read
presentationViewBox, which is what the rest of the panel
already presents from.Worth noting what this does not do: it doesn't make the grid live. Nothing
re-renders per frame during a gesture, by design. It makes the renders that do
happen correct, which together with the ruled margin is what keeps the grid
stable through a gesture. Writing the path attribute imperatively per frame
would be the alternative, and it fights React: the next render puts the
committed path back. -
8f15d2f: Stop the 2D grid shimmering as the view moves.
The grid drew with
shapeRendering="crispEdges", which turns off anti-aliasing
so a 1px line lands exactly on the pixel grid. The snap is per line, so on every
sub-pixel change to the view box each line jumps a whole pixel independently of
its neighbours. Diagonal lines re-stair-step, which reads as a shimmer;
axis-aligned lines jitter. Every frame of a pan or zoom re-rasterized the whole
grid differently, and it aliases at rest too: a 2x device pixel ratio doesn't
rescue it, because the hint disables anti-aliasing regardless of density.crispEdgesonly pays off for a grid that is both axis-aligned and still, and
this one is a moving reference, so the grid now always asks for
geometricPrecision.Measured on a rotated plan: across a zoom, mean luminance over the grid changed
345 times with 261 of those reversing direction. A level-of-detail ladder is
monotonic through a continuous zoom, so a 76% reversal rate is re-rasterization,
not lines being added and removed. -
9c0b396: Let a host set the 2D grid's line widths.
They were module constants, so the grid's weight was the one part of its
appearance a host couldn't reach, while its colours and opacities were already
overridable.minorGridWidthandmajorGridWidthjoinFloorplanPanelPalette, which means
they come through the existingfloorplanPaletteprop with no new API:<Editor floorplanPalette={{ minorGridWidth: 0.3, majorGridWidth: 0.5 }} />
Defaults are the previous constants, so nothing changes unless you set them.
Worth knowing before you do: the lines are drawn with
non-scaling-stroke, so
these are screen pixels and don't follow the zoom, and the defaults are
sub-pixel. That used to be propped up bycrispEdgessnapping thin lines up to a
solid pixel. Now that the grid always anti-aliases, these values are what decide
whether it reads at all. -
7b75cdf: Add
floorplanNavigationLinkfor letting the 2D plan and the 3D camera hold
independent viewpoints in split mode.Defaults to
true, the existing behaviour.falsestops 2D poses reaching the
camera (through the sync bridge'ssetActive) and stops camera poses moving the
plan's viewport, while leaving host-published poses applying to the plan either
way. Re-linking snaps the plan back onto the camera.The compass keeps working throughout, which took two fixes:
- The needle tracks the camera while the panel is hidden, and did so by writing
the ref holding the plan's own rotation. Unlinked, that drifts the plan's
recorded rotation to a heading it never had, and align-to-north then works
from the wrong baseline. It only follows the camera while linked now. - The needle describes whichever view is on screen, but each source only wrote
on its own updates, so a still camera left the previous view's heading in an
inline transform React had no reason to replace. It is handed over explicitly
on every switch.
Also seeds
navigationSyncPose, which was only ever written as a side effect
of navigating and so started null, leaving a host with nothing to read and no
way to take a first relative step. Seeding happens where it cannot move
anything by itself: when the host owns navigation input, or when the views are
unlinked. It publishes a'2d'pose, which while linked drives the camera, and
the plan's view width is its own fit rather than the camera's, so seeding
unconditionally would rezoom the 3D view on mount.That is what lets a host drive one gesture and leave the rest built-in. Poses
published as'host'apply to the plan whateverfloorplanNavigationInputis
set to, so an unlinked host can, say, step the plan in quarter turns while
built-in pan and zoom carry on and the 3D camera orbits freely.The pure converters
cameraPoseToFloorplanNavigationPoseand
floorplanNavigationPoseToCameraPoseare exported for relating the two views. - The needle tracks the camera while the panel is hidden, and did so by writing
-
edfb5a3: Add a
floorplanPaletteprop for overriding the 2D floor plan's colours.The palette was computed internally from the scene theme's appearance with no
way in, so hosts matching the plan to their own design system were reduced to
targeting Pascal's hex values with CSS. Overrides arePartial, merged over
the built-in light or dark palette, so omitted slots keep their defaults:<Editor floorplanPalette={{ surface: "transparent", minorGrid: "#e5e7eb" }} />
The panel palette is now exported as
FloorplanPanelPalettefor typing the
override. It is a superset ofFloorplanPalettein@meterup/pascal-core, and the
slots registry kinds consume are forwarded through<FloorplanRenderProvider>,
so one override reaches both the panel's own drawing and every kind's geometry.
Renaming it also ends the collision with the core type it mirrors. -
74921b7: Honour
readOnlyfor the 2D guide image and site boundary.Both were draggable in a locked scene. The drag even appeared to work: the
geometry followed the cursor, then snapped back on release when the store
refused the write.readOnlywas only ever consulted by the registry-driven path
(floorplan-registry-layer,floorplan-registry-action-menu), which strips its
own handles. The guide and site-boundary layers predate that mechanism and
render their affordances directly, so nothing was checking the flag for them.They now gate on it alongside their existing conditions, so a read-only scene
gets the guide and the boundary as static geometry with no drag targets,
vertex handles, edge handles or midpoint handles.The site polygon itself is untouched by this: the fitted-viewport calculation
still reads it to size the initial frame, so a locked scene frames the plan
exactly as an editable one does. -
d9613d6: Theme the container behind the 2D floor-plan SVG.
palette.surfacewas already host-settable and already painted the surface rect,
but the container the SVG sits in was a hardcodedbg-white. That isn't always
covered: the rect lives inside the SVG, and a rotation gesture CSS-rotates the
SVG, so the rect swings away from the corners and the container shows through.
On a dark palette it flashed white for the length of every rotation.It takes
palette.surfacenow, so there's one background colour for the plan
rather than a themed one in front of an unthemed one. -
c3a81a9: Mark the 2D viewport with
data-pascal-floorplan-viewport.A host binding its own navigation needs to know where the plan actually is on
screen: anchoring a zoom to the pointer means measuring the cursor against the
plan's own rect, and in split view that isn't the editor's rect. The 3D pane has
carrieddata-pascal-viewer-3dfor a while; this is its counterpart. -
93c1e2b: Export the rest of the fresh-placement helpers:
createFreshPlacementSubtree,
prepareFreshPlacementRootDuplicateandduplicatesAsFreshSubtree.Only
commitFreshPlacementSubtreewas exported, so a host driving its own
placement flow could finish a draft but not start one. It had to reimplement
the create half, including themetadata.isNewconvention, and then drift from
it.duplicatesAsFreshSubtreeis the predicate that picks between the subtree
and root-duplicate paths, so it ships alongside them. -
eeae5cb: Restore the always-on 3D ground grid and reconnect it to the viewer's
showGrid
preference.The grid had become a placement-only aid: the baseline alpha was pinned to
0
and mesh visibility keyed onisGridSnapActive(), replacing the previous
visible={showGrid}binding. A host that never arms a build tool (read-only
plan viewers, embedded scenes) was left with no grid at all and an inert Display
toggle, with no way to opt back in.Placement behaviour is unchanged. An armed draft/build tool, a node move, or a
reshape still gets the tight cursor patch with no baseline and a brightened
alpha. Otherwise the grid is the ground reference again: the wider
revealRadiusreveal over a constant baseline, unboosted, shown only while
showGridis on.const snapPatchVisible = isGridSnapActive(); revealRadiusUniform.value = snapPatchVisible ? PLACEMENT_REVEAL_RADIUS : revealRadius; baseAlphaUniform.value = snapPatchVisible ? 0 : IDLE_BASE_ALPHA; patchAlphaUniform.value = snapPatchVisible ? 1.5 : 1; gridRef.current.visible = snapPatchVisible || showGrid;
Grid pointer events are unaffected either way:
useGridEventsraycasts its own
math plane rather than the mesh, so visibility never changed tool behaviour. -
acbf89b: Stop the 2D grid re-ruling when the view box hasn't changed.
setLiveViewBoxpublished a fresh object on every imperative viewport write,
and the grid's memo chain is keyed on that object's identity. So the grid
rebuilt both of its paths every frame whether or not the view had moved, and
quantizeGridBounds— which exists so a pan rebuilds the path once per few
steps of travel rather than per frame — never got the chance to do its job.Rotation is where it showed. A rotation presentation moves the view with a CSS
transform on the SVG and writes the same view box every frame, so every one
of those rebuilds redrew geometry that hadn't moved. Host-driven rotation felt
chunky as a result, while the built-in gestures hid it behind the fact that
they also change the view box.The store now holds its reference when the values are equal, and the two path
memos key on the bounds' values rather than the bounds object, so quantisation
counts for something on pan and zoom too. -
1559fba: Request the 2D floor-plan guide image with CORS.
The 2D panel's
<image>loaded the guide withoutcrossOrigin, so the browser
cached the response with no CORS headers on it. 3D then loads the same URL as a
texture, which does need them, gets the cached header-less response back, and
fails. Whether 3D works came down to which view happened to load the image
first.crossOrigin="anonymous"on the 2D<image>makes both views ask for the same
thing, so one cache entry serves both. The attribute has no effect on 2D
rendering. -
7f4cad1: Add a
'host'navigation-sync source so an embedding app can drive the views.useEditor.publishNavigationSyncPosealready existed, butNavigationSyncSource
was'2d' | '3d'and each consumer tested for the other view's literal. A host
pose had to claim it came from a view it isn't, and claiming'2d'reached the
camera while claiming'3d'reached the floor plan, so neither worked for both.A view now applies any pose it didn't publish itself, expressed as
drivesFloorplanViewanddrivesCamerarather than repeated literals, so the
next source added reaches every consumer.NavigationSyncPose,
NavigationSyncPoseInputandNavigationSyncSourceare exported for typing
the call. -
d602fa1: Withhold both 2D box selections on a read-only plan.
The plan has two of them.
isMarqueeSelectionToolActivedraws in plan space and
needsfloorplanSelectionTool === 'marquee';isScreenSelectionToolActive
draws a screen-space rectangle intodocument.bodyand is what the default
'click'tool uses. Neither consultedreadOnly, and the screen-space one is
the default, so a plain drag across a locked plan box-selected whatever it
crossed.What follows is the real problem: once a set is selected, the group affordances
come with it, and a drag moves walls around a scene that then refuses the
write, so the geometry springs back.mode === 'select'isn't enough to gate on, because select is the one mode a
read-only scene is allowed to be in. These are precisely the affordances that
refusing editing modes can't reach — selection-shaped rather than tool-shaped. -
cfa5ace: Refuse editing modes on a read-only scene.
readOnlywas honoured by the registry-driven path and nowhere else, so each
affordance that predates it had to be found and guarded one at a time: the
guide image, the site boundary, the 2D marquee. The ones derived from tool
state are the large family, and they all share a cause — every
phase === … && mode === 'build' && tool === …asks what the user picked and
never whether the scene can be changed.setModenow refuses anything but'select'while the scene is read-only, and
setToolrefuses to arm one, though clearing is always allowed. Holding that
pair at their inert values leaves every derived flag already false, including
for tools added later, which is the part that doesn't need anyone to remember.Rehydration is covered too:
modeandtoolare persisted and don't go
through the setters, so a'build'stored during an editable visit would
otherwise arm itself on the next read-only one.This doesn't reach affordances that aren't derived from tool state — the
marquee is selection-shaped, not tool-shaped — so those still need a decision
each. It removes the family that was growing. -
7c9992f: Ease the 2D view onto the camera when navigation is re-linked.
Re-linking jumped. Two separate effects both fired on the toggle and both went
through the navigation sync scheduler, which exists to track a live stream and
so presents every pose the instant it arrives. A one-shot pose through it is a
jump by construction.The re-link now decomposes the camera pose itself and hands the result to the
view animator, so it picks up the same exponential decay the compass needle and
the camera buttons use. The panel-open sync is latched to the opening edge,
since it would otherwise land its snap first and leave the animation nothing to
travel.Width is applied unclamped, matching the live sync path: the camera is the
authority on how wide the view is, and clamping would ease towards a width the
stream would immediately contradict.The follow effect is latched too. It reads the building transform, so without
that, moving the building looked like a re-link and dragged the view off
whatever the user was looking at. -
e1d3a5a: Add
showSiteBoundaryto hide the site's boundary geometry.The boundary was always drawn, with no way to turn it off. It is a useful
reference while laying out a site and pure noise on a surface that only presents
a finished building.showSiteBoundaryjoinsuseViewernext toshowGrid/showZonesand
governs both views from one flag, because it is the same polygon in each:useViewer.getState().setShowSiteBoundary(false);
In 2D that drops the dashed polygon, its edge labels and its handles. In 3D it
drops the ground pad and the amber outline. Buildings, items and the horizon
ground are unaffected, so the scene loses its lot outline rather than its floor
— the horizon disc punches holes for slab footprints, not for the site polygon,
so it still covers the ground the pad was covering.Two things stay deliberately outside the flag. The fitted viewport still sizes
itself from the site polygon, so toggling this doesn't reframe the plan. And
sculpted terrain keeps rendering, since that is modelled geometry rather than
boundary chrome.Defaults to
trueand isn't persisted, so nothing changes until a host sets it. -
54e1b19: Let a scene theme set the ground grid's line colours and idle opacity.
appearancehardcoded both colours, and in dark mode<Grid>also ignored its
owncellColor/sectionColorprops, so neither a theme nor a host could
adjust how the grid read. That matters because the grid is drawn over the lit
groundfill: its contrast against that surface is a property of the pair, and
a light/dark flag can't settle it.groundandbackgroundSkywere already
carved out ofappearancefor the same reason.const theme = SCENE_THEMES.find((t) => t.id === "night"); theme.grid = { cell: "#2b2d3a", section: "#343747", idleOpacity: 0.18 };
All three keys are optional and fall back to the previous
appearance-derived
values, so existing themes render identically.idleOpacityapplies to the
always-on ground reference only; an active placement patch keeps its own
brightened treatment. -
2466d67: Stop gating the editor shell on 3D scene readiness when only the 2D pane is
showing.In a 2D-only view the 3D pane is
display: none, so its canvas measures 0x0,
R3F never renders the scene tree, no node renderer callsuseRegistry, and
hasCommittedSceneRoot()stays false.isViewerSceneReadyis therefore
unreachable rather than merely slow, and every load sat behind the full
SCENE_READY_FALLBACK_MSbefore the fallback fired and logged
viewer scene readiness timed out. Measured on a 351-node plan: a consistent
~8s loader over an already-rendered floor plan, withsceneRegistryempty
throughout.This is reachable without a host forcing anything.
viewModeis persisted in
pascal-editor-ui-preferencesand restored verbatim by
normalizePersistedEditorUiState, so any session that ended in 2D rehydrates
straight back into it on the next load.The loader now waits only on what a 2D stage actually needs, and the readiness
timeout is skipped entirely in that case:const sceneLoaded = !isLoading && !isSceneLoading && hasLoadedInitialScene; const twoDimensionalOnly = isPreviewMode ? previewStageMode === "2d" : viewMode === "2d"; const showLoader = !sceneLoaded || (!twoDimensionalOnly && !isViewerSceneReady);
This subsumes the previous
visibleLoadercarve-out, which applied the same
reasoning to the 2D preview stage only, so that variable is gone. Overlay
behaviour for preview is unchanged; 2D-only stages additionally pause the hidden
viewer once the scene has loaded, which costs nothing given it was never
rendering. -
ced65b4: Make version preview actually mark the scene read-only.
isVersionPreviewModewithheld the editing UI and nothing else. The scene
store kept reporting itself editable, so everyreadOnlyguard behind it —
the mutation guards in the scene store, and the affordance guards in the 2D
panel — sat inert under a prop that plainly means the scene can't be edited.It's a quiet failure: the guards read correctly, so they survive review and
then do nothing. The 2D marquee is the example, still painting a selection box
across a plan nobody can change, with the fix for it already written.Preview now sets
readOnlywhile it's on and restores the previous value when
it ends. One-way on purpose: preview implies read-only, but a scene can be
read-only for reasons of its own, so leaving preview must not declare it
editable.Hosts that set
readOnlythemselves are unaffected.