floorplan/registry-layer: z-order buckets + overlay pass + click guard
Three layered improvements on the 2D registry layer:
- Z-order buckets so the SVG document order reflects intent: zones (0)
paint first, slabs/ceilings (1) next, every other kind (walls / items /
shelves / columns / stairs / …) on top. Stable sort preserves DFS order
within a bucket.
- Base / overlay split: each entry's `FloorplanGeometry` tree is walked
through `splitFloorplanOverlay`, partitioning into a base group
(polygons, paths, fills, hatches) and an overlay group (interactive
handles + labels — `text` / `endpoint-handle` / `midpoint-handle` /
`edge-handle` / `move-handle` / `dimension` / `dimension-label`). Base
renders rank-sorted; overlays paint after every base entry so polygon-
editor chrome on a selected slab and zone name labels stay legible
above the structural fills sitting on top of them.
- Click guard on the outer layer `<g>`. The base/overlay split means
pointer-down lands on base and pointer-up lands on overlay (selection
mounts the overlay on top mid-gesture). The browser then dispatches
`click` to the lowest common ancestor, ABOVE the entry's
`onClick={handleClickStop}`. Without a higher-level stop, the click
reached the SVG's `handleBackgroundClick` → `clear-elements` and the
selection set on pointer-down vanished a frame later. Scoped to
`onClick` only so pointer / hover / drag still propagate inside the
registry tree.
Also extends the `text` FloorplanGeometry with stroke / strokeWidth /
paintOrder / fontFamily so kinds can match the legacy "white fill +
colored outline" label look (used by the new zone name label).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
586cec8ad7
commit
7a92641baa
@@ -9,11 +9,13 @@ export type {
|
|||||||
EventSuffix,
|
EventSuffix,
|
||||||
FenceEvent,
|
FenceEvent,
|
||||||
GridEvent,
|
GridEvent,
|
||||||
|
GuideEvent,
|
||||||
ItemEvent,
|
ItemEvent,
|
||||||
LevelEvent,
|
LevelEvent,
|
||||||
NodeEvent,
|
NodeEvent,
|
||||||
RoofEvent,
|
RoofEvent,
|
||||||
RoofSegmentEvent,
|
RoofSegmentEvent,
|
||||||
|
ScanEvent,
|
||||||
ShelfEvent,
|
ShelfEvent,
|
||||||
SiteEvent,
|
SiteEvent,
|
||||||
SlabEvent,
|
SlabEvent,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
export {
|
export {
|
||||||
|
discoverPlugins,
|
||||||
getSelectableKinds,
|
getSelectableKinds,
|
||||||
isRegistrySelectable,
|
isRegistrySelectable,
|
||||||
loadPlugin,
|
loadPlugin,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
|
type PluginDiscovery,
|
||||||
registerNode,
|
registerNode,
|
||||||
|
setPluginDiscovery,
|
||||||
} from './registry'
|
} from './registry'
|
||||||
export {
|
export {
|
||||||
type CascadeContext,
|
type CascadeContext,
|
||||||
@@ -22,7 +25,14 @@ export type {
|
|||||||
CuttableConfig,
|
CuttableConfig,
|
||||||
DragAction,
|
DragAction,
|
||||||
EditorCtx,
|
EditorCtx,
|
||||||
|
FloorplanAffordance,
|
||||||
|
FloorplanAffordanceModifiers,
|
||||||
|
FloorplanAffordancePoint,
|
||||||
|
FloorplanAffordanceSession,
|
||||||
FloorplanGeometry,
|
FloorplanGeometry,
|
||||||
|
FloorplanMoveTarget,
|
||||||
|
FloorplanMoveTargetSession,
|
||||||
|
FloorplanPalette,
|
||||||
FloorplanPoint,
|
FloorplanPoint,
|
||||||
FloorplanStyle,
|
FloorplanStyle,
|
||||||
GeometryContext,
|
GeometryContext,
|
||||||
|
|||||||
@@ -95,3 +95,41 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
|
|||||||
registerNode(def)
|
registerNode(def)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-level plugin discovery hook. The bootstrap loads `builtinPlugin`
|
||||||
|
* unconditionally and then awaits this to pick up any extra plugins
|
||||||
|
* (third-party node packs, AI-authored bundles, user-installed kinds).
|
||||||
|
* Defaults to returning `[]` — apps that want external plugins call
|
||||||
|
* {@link setPluginDiscovery} before the bootstrap module runs.
|
||||||
|
*
|
||||||
|
* Kept async so a future loader can fetch over the network without
|
||||||
|
* changing the contract. See `wiki/editor-plugin-authoring.md` for the
|
||||||
|
* plugin author surface this enables.
|
||||||
|
*/
|
||||||
|
export type PluginDiscovery = () => Promise<Plugin[]>
|
||||||
|
|
||||||
|
let pluginDiscovery: PluginDiscovery = async () => []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the plugin discovery implementation. Call once at app startup
|
||||||
|
* before {@link discoverPlugins} is invoked (bootstrap order matters).
|
||||||
|
*
|
||||||
|
* The contract is intentionally minimal — just "return a list of
|
||||||
|
* plugins to load." The loader can be a static `import.meta.glob`, a
|
||||||
|
* `fetch` against a registry endpoint, a worker IPC, etc. Each returned
|
||||||
|
* plugin still goes through {@link loadPlugin} so the same API-version
|
||||||
|
* gate + duplicate-kind protection applies.
|
||||||
|
*/
|
||||||
|
export function setPluginDiscovery(fn: PluginDiscovery): void {
|
||||||
|
pluginDiscovery = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the active plugin discovery and return the discovered plugins.
|
||||||
|
* Bootstrap code is expected to call this after `loadPlugin(builtinPlugin)`
|
||||||
|
* and then `await loadPlugin(...)` each result in order.
|
||||||
|
*/
|
||||||
|
export function discoverPlugins(): Promise<Plugin[]> {
|
||||||
|
return pluginDiscovery()
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,90 @@ export type GeometryContext = {
|
|||||||
siblings: AnyNode[]
|
siblings: AnyNode[]
|
||||||
/** Resolved parent (null for root-level nodes). */
|
/** Resolved parent (null for root-level nodes). */
|
||||||
parent: AnyNode | null
|
parent: AnyNode | null
|
||||||
|
/**
|
||||||
|
* Pre-computed level-batch data, populated by the dispatcher when the
|
||||||
|
* kind declares `def.computeLevelData`. Shared across every
|
||||||
|
* `def.geometry(node, ctx)` call in the same level batch within a
|
||||||
|
* single frame, so kinds whose geometry depends on cross-sibling
|
||||||
|
* data (wall mitering, gradient sky uniforms across a zone, etc.)
|
||||||
|
* don't pay an O(N²) recomputation cost.
|
||||||
|
*
|
||||||
|
* Typed as `unknown` at the framework boundary — kinds cast to their
|
||||||
|
* own `LevelData` shape inside `def.geometry` (the same kind owns
|
||||||
|
* both the `computeLevelData` return shape and the `geometry`
|
||||||
|
* consumer, so the cast is internal). Only populated for `def.
|
||||||
|
* geometry` calls today; not used by `def.floorplan` (which already
|
||||||
|
* has cheap access to siblings through `ctx.siblings`).
|
||||||
|
*/
|
||||||
|
levelData?: unknown
|
||||||
|
/**
|
||||||
|
* Optional view state — only populated for `def.floorplan` builders. The
|
||||||
|
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
|
||||||
|
* their output (themed stroke when selected, endpoint dots when
|
||||||
|
* selected, hatch overlay, hover-side highlight). For `def.geometry`
|
||||||
|
* (3D) this is always undefined — the 3D selection outline is handled
|
||||||
|
* by the merged-outline post-process pass instead.
|
||||||
|
*/
|
||||||
|
viewState?: {
|
||||||
|
selected: boolean
|
||||||
|
/** Marquee or programmatic highlight — shows selected chrome without keyboard focus. */
|
||||||
|
highlighted: boolean
|
||||||
|
/** Pointer-hovered. */
|
||||||
|
hovered: boolean
|
||||||
|
/**
|
||||||
|
* True while this node is the target of an active 2D move (i.e.
|
||||||
|
* `useEditor.movingNode === node`). Used by kinds whose move
|
||||||
|
* preview includes extra chrome — e.g. door / window emit
|
||||||
|
* dimension lines showing the distance to adjacent openings or
|
||||||
|
* wall ends only during the move.
|
||||||
|
*/
|
||||||
|
moving: boolean
|
||||||
|
/**
|
||||||
|
* The kind's theme palette. Theme-aware colors (selection stroke,
|
||||||
|
* endpoint handle fill, hatch color) live here so kinds don't need
|
||||||
|
* to import `useViewer.theme` themselves.
|
||||||
|
*/
|
||||||
|
palette: FloorplanPalette
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FloorplanPalette ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Centralised set of themed colors that kinds pull from when building
|
||||||
|
// their floor-plan geometry. Mirrors the legacy `FloorplanPalette` in
|
||||||
|
// `floorplan-panel.tsx`. The 2D layer constructs this from
|
||||||
|
// `useViewer.theme` and passes it via `GeometryContext.viewState.palette`.
|
||||||
|
|
||||||
|
export type FloorplanPalette = {
|
||||||
|
selectedStroke: string
|
||||||
|
selectedFill: string
|
||||||
|
/** Hatch / cross-stroke color used for selected fills with patterns. */
|
||||||
|
selectedHatch: string
|
||||||
|
/**
|
||||||
|
* Stroke colour applied to a wall (and fence by analogy) when the
|
||||||
|
* pointer hovers it. Light blue in the legacy palette — distinct from
|
||||||
|
* the orange endpoint-handle hover so the body and its handles can
|
||||||
|
* both glow independently. Pass through `viewState.palette.wall
|
||||||
|
* HoverStroke` in `def.floorplan` when `viewState.hovered === true`
|
||||||
|
* and the node isn't selected.
|
||||||
|
*/
|
||||||
|
wallHoverStroke: string
|
||||||
|
endpointHandleFill: string
|
||||||
|
endpointHandleStroke: string
|
||||||
|
endpointHandleHoverStroke: string
|
||||||
|
endpointHandleActiveFill: string
|
||||||
|
endpointHandleActiveStroke: string
|
||||||
|
/**
|
||||||
|
* Curve sagitta handle slot — distinct teal colour-set the legacy
|
||||||
|
* `FloorplanWallCurveLayer` uses so users can tell endpoint dots
|
||||||
|
* (orange) and curve dots (teal) apart at a glance.
|
||||||
|
*/
|
||||||
|
curveHandleFill: string
|
||||||
|
curveHandleStroke: string
|
||||||
|
curveHandleHoverStroke: string
|
||||||
|
measurementStroke: string
|
||||||
|
measurementLabelBackground: string
|
||||||
|
measurementLabelText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── FloorplanGeometry ───────────────────────────────────────────────
|
// ─── FloorplanGeometry ───────────────────────────────────────────────
|
||||||
@@ -45,6 +129,22 @@ export type FloorplanStyle = {
|
|||||||
strokeWidth?: number
|
strokeWidth?: number
|
||||||
strokeDasharray?: string
|
strokeDasharray?: string
|
||||||
opacity?: number
|
opacity?: number
|
||||||
|
/**
|
||||||
|
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
|
||||||
|
* as a constant screen-pixel width regardless of viewport zoom. Maps
|
||||||
|
* straight to the SVG `vector-effect` attribute. Default (undefined)
|
||||||
|
* treats `strokeWidth` as plan-unit metres.
|
||||||
|
*
|
||||||
|
* Kinds that emit hand-drawn-looking strokes (fence body, wall hairlines,
|
||||||
|
* post markers) want non-scaling so the visual weight stays stable as
|
||||||
|
* the user zooms. Kinds whose stroke represents a real-world thickness
|
||||||
|
* (wall body in floor plan, slab outline) leave it undefined.
|
||||||
|
*/
|
||||||
|
vectorEffect?: 'non-scaling-stroke'
|
||||||
|
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||||
|
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
||||||
|
strokeOpacity?: number
|
||||||
|
fillOpacity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ToolHint ────────────────────────────────────────────────────────
|
// ─── ToolHint ────────────────────────────────────────────────────────
|
||||||
@@ -85,12 +185,319 @@ export type FloorplanGeometry =
|
|||||||
x2: number
|
x2: number
|
||||||
y2: number
|
y2: number
|
||||||
} & FloorplanStyle)
|
} & FloorplanStyle)
|
||||||
|
/**
|
||||||
|
* Plain SVG text in plan space. Used for short labels that need to
|
||||||
|
* sit at a specific plan coordinate — e.g. the elevator served-level
|
||||||
|
* chips' floor numbers. Rotates with the floor plan's transform
|
||||||
|
* (same as polygon coordinates) so it shares the building's
|
||||||
|
* orientation. For text that needs to stay screen-upright regardless
|
||||||
|
* of plan rotation, use `dimension-label` instead (it auto-flips
|
||||||
|
* upside-down labels).
|
||||||
|
*
|
||||||
|
* `fontSize` is in plan metres — typical values are 0.1–0.2m. The
|
||||||
|
* registry layer doesn't apply any text-rendering chrome (no plate,
|
||||||
|
* no rotation auto-flip) — it's just a styled `<text>` element.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'text'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
text: string
|
||||||
|
fontSize: number
|
||||||
|
fill?: string
|
||||||
|
fontWeight?: number | string
|
||||||
|
fontFamily?: string
|
||||||
|
textAnchor?: 'start' | 'middle' | 'end'
|
||||||
|
dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'alphabetic'
|
||||||
|
opacity?: number
|
||||||
|
/**
|
||||||
|
* Outlined-text styling — when `stroke` is set the renderer applies
|
||||||
|
* `stroke` / `strokeWidth` plus `paintOrder='stroke'` so the stroke
|
||||||
|
* is drawn under the fill. Used by zone name labels for the
|
||||||
|
* "white text inside a colored outline" look that stays legible
|
||||||
|
* against any fill color.
|
||||||
|
*/
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
|
paintOrder?: 'stroke' | 'fill' | 'normal'
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
|
||||||
|
* floor-plan symbol, scan slice, etc. `url` is passed through the
|
||||||
|
* editor's `loadAssetUrl` resolver (handles CDN / Supabase storage),
|
||||||
|
* so kinds emit the raw `asset.floorPlanUrl` and don't worry about
|
||||||
|
* fetching.
|
||||||
|
*
|
||||||
|
* `rotation` is in radians around `center`. The image is drawn at
|
||||||
|
* `center` with size `width × height` in plan-local metres;
|
||||||
|
* `preserveAspectRatio` controls letterboxing (default
|
||||||
|
* `'xMidYMid meet'`).
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'image'
|
||||||
|
url: string
|
||||||
|
center: FloorplanPoint
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
rotation?: number
|
||||||
|
preserveAspectRatio?: string
|
||||||
|
opacity?: number
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'group'
|
kind: 'group'
|
||||||
children: FloorplanGeometry[]
|
children: FloorplanGeometry[]
|
||||||
/** Optional transform applied to all children. Rotation in radians. */
|
/** Optional transform applied to all children. Rotation in radians. */
|
||||||
transform?: { translate?: FloorplanPoint; rotate?: number }
|
transform?: { translate?: FloorplanPoint; rotate?: number }
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Hatched fill overlay — same polygon shape as the kind's main fill but
|
||||||
|
* stroked with diagonal lines on top. Used for the selected-wall hatch
|
||||||
|
* effect from the legacy floor-plan panel. The 2D layer mounts a
|
||||||
|
* shared `<pattern>` in `<defs>` and references it via `fill=url(...)`.
|
||||||
|
*/
|
||||||
|
| { kind: 'hatch'; points: readonly FloorplanPoint[]; color: string; opacity?: number }
|
||||||
|
/**
|
||||||
|
* Transparent click-detection segment. Sits on top of the kind's main
|
||||||
|
* geometry with a wide stroke so the user doesn't need to pixel-hunt
|
||||||
|
* the polygon. `select` is the only affordance for now — clicking
|
||||||
|
* triggers selection of the owning node.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'hit-line'
|
||||||
|
x1: number
|
||||||
|
y1: number
|
||||||
|
x2: number
|
||||||
|
y2: number
|
||||||
|
/** Stroke width in screen pixels — converted to plan units by the dispatcher. */
|
||||||
|
strokeWidthPx: number
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Endpoint manipulation handle — the 5-circle stack from the legacy
|
||||||
|
* floor-plan: outer hover glow ring + hover ring + filled outer +
|
||||||
|
* inner dot + transparent hit. Rendered with theme-aware colors from
|
||||||
|
* `viewState.palette`. `affordance` keys into a kind-owned drag flow
|
||||||
|
* the dispatcher invokes; `payload` is opaque kind data the
|
||||||
|
* affordance handler unpacks.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'endpoint-handle'
|
||||||
|
point: FloorplanPoint
|
||||||
|
/** `active` = currently being dragged; `idle` = visible but inert. */
|
||||||
|
state: 'idle' | 'active'
|
||||||
|
/**
|
||||||
|
* Visual colour-set. `'endpoint'` (default) → orange — wall /
|
||||||
|
* fence endpoints, polygon vertices. `'curve'` → teal — the
|
||||||
|
* sagitta midpoint handle. Other values are reserved for future
|
||||||
|
* affordances (rotation, scale) without expanding the union.
|
||||||
|
*/
|
||||||
|
variant?: 'endpoint' | 'curve'
|
||||||
|
affordance: string
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Smaller "insert here" handle drawn between two polygon vertices.
|
||||||
|
* Visually a small white dot with a `+` icon; hover-expanded. Triggers
|
||||||
|
* an affordance that typically inserts a new vertex at the midpoint
|
||||||
|
* and then drags it (matches the legacy slab / ceiling boundary
|
||||||
|
* editor's edge-midpoint behaviour).
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'midpoint-handle'
|
||||||
|
point: FloorplanPoint
|
||||||
|
affordance: string
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Hit-target along an entire polygon edge. Renders as a transparent
|
||||||
|
* wide stroke for click detection; the dispatcher overlays a glow +
|
||||||
|
* solid stroke when hovered or actively being dragged. Used by the
|
||||||
|
* slab / ceiling boundary editor's "drag whole edge perpendicular"
|
||||||
|
* affordance — both endpoints translate together along the edge
|
||||||
|
* normal.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'edge-handle'
|
||||||
|
x1: number
|
||||||
|
y1: number
|
||||||
|
x2: number
|
||||||
|
y2: number
|
||||||
|
affordance: string
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* "Grab to move" handle drawn at a node's centroid — the orange dot
|
||||||
|
* users click-and-drag to move a door / window / item in the
|
||||||
|
* floorplan without going through the inspector's Move button.
|
||||||
|
*
|
||||||
|
* Pointer-down on the handle sets `useEditor.movingNode` to the
|
||||||
|
* owning node, which `FloorplanRegistryMoveOverlay` picks up and
|
||||||
|
* routes through the kind's `def.floorplanMoveTarget`. So both
|
||||||
|
* entry points (Move button + dot grab) share the same move
|
||||||
|
* pipeline — no parallel kind-side logic.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'move-handle'
|
||||||
|
point: FloorplanPoint
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Centered length / distance label. Renders as a small rounded
|
||||||
|
* background plate with text, oriented along `angle` (radians). The
|
||||||
|
* 2D layer flips the label upright when it would otherwise be upside
|
||||||
|
* down. Use this for simple "what length am I?" badges (fence, item
|
||||||
|
* width, draft preview).
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'dimension-label'
|
||||||
|
cx: number
|
||||||
|
cy: number
|
||||||
|
text: string
|
||||||
|
/** Rotation in radians. The renderer auto-flips to keep text upright. */
|
||||||
|
angle: number
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Architect's dimension overlay — extension lines from the edge
|
||||||
|
* endpoints out past the dimension line, two dimension line halves
|
||||||
|
* with the label sitting in the gap, end ticks perpendicular to the
|
||||||
|
* line. Used for the selected wall's full measurement; the rounded
|
||||||
|
* plate label is the wrong shape when you want plan-drawing chrome.
|
||||||
|
*
|
||||||
|
* The renderer computes the segment geometry from these inputs so the
|
||||||
|
* kind only needs to know "where is the edge and which way does the
|
||||||
|
* dimension line offset." `offsetNormal` is a unit vector
|
||||||
|
* perpendicular to the edge; pass the *outward* normal so the line
|
||||||
|
* sits on the side facing away from the wall interior.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'dimension'
|
||||||
|
start: FloorplanPoint
|
||||||
|
end: FloorplanPoint
|
||||||
|
/** Outward-pointing unit normal — the dimension line offsets along this. */
|
||||||
|
offsetNormal: FloorplanPoint
|
||||||
|
/** Distance (plan units) from the edge to the dimension line. */
|
||||||
|
offsetDistance: number
|
||||||
|
/** How far past the offset point the extension line continues. */
|
||||||
|
extensionOvershoot: number
|
||||||
|
text: string
|
||||||
|
/** Optional override for the line/text colour. Defaults to the palette accent. */
|
||||||
|
stroke?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FloorplanAffordance ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 2D drag session contract for floor-plan interactions. The registry
|
||||||
|
// layer (`FloorplanRegistryLayer`) drives the SVG event plumbing; each
|
||||||
|
// affordance handler owns the actual mutation logic for its kind.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// 1. Pointer-down on a handle whose `affordance` key matches.
|
||||||
|
// 2. Layer captures node snapshots for `affectedIds` and pauses
|
||||||
|
// history.
|
||||||
|
// 3. Layer calls `apply` on every pointer-move with the current plan
|
||||||
|
// point + modifier keys.
|
||||||
|
// 4. On pointer-up: layer reads the resulting scene state, reverts to
|
||||||
|
// the snapshot (still paused, untracked), resumes history, then
|
||||||
|
// re-applies the final state as a single tracked change (single-
|
||||||
|
// undo dance — same shape as Stage D 3D moves).
|
||||||
|
// 5. On pointer-cancel / unmount: revert + resume without committing.
|
||||||
|
//
|
||||||
|
// `apply` is expected to call `scene.updateNodes` directly to drive
|
||||||
|
// previews — the layer doesn't keep a separate draft state.
|
||||||
|
|
||||||
|
export type FloorplanAffordancePoint = readonly [x: number, y: number]
|
||||||
|
|
||||||
|
export type FloorplanAffordanceModifiers = {
|
||||||
|
shiftKey: boolean
|
||||||
|
altKey: boolean
|
||||||
|
ctrlKey: boolean
|
||||||
|
metaKey: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FloorplanAffordanceSession = {
|
||||||
|
/** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */
|
||||||
|
affectedIds: AnyNodeId[]
|
||||||
|
/**
|
||||||
|
* Run a single drag tick. Implementations call `scene.updateNodes` to
|
||||||
|
* preview the next position. Snap logic, linked-node cascade, and
|
||||||
|
* angle locking live here.
|
||||||
|
*/
|
||||||
|
apply(args: {
|
||||||
|
planPoint: FloorplanAffordancePoint
|
||||||
|
modifiers: FloorplanAffordanceModifiers
|
||||||
|
}): void
|
||||||
|
/**
|
||||||
|
* Called on pointer-up. Return `true` if the scene's current state
|
||||||
|
* should be committed; `false` reverts to the snapshot (e.g. wall too
|
||||||
|
* short, vertex collapsed onto neighbour).
|
||||||
|
*/
|
||||||
|
canCommit(): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FloorplanAffordance<N> = {
|
||||||
|
start(args: {
|
||||||
|
node: N
|
||||||
|
/** Opaque kind-specific payload from the handle primitive. */
|
||||||
|
payload: unknown
|
||||||
|
/** Current scene snapshot at drag start. */
|
||||||
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
|
/** Initial pointer position in plan coordinates. */
|
||||||
|
initialPlanPoint: FloorplanAffordancePoint
|
||||||
|
}): FloorplanAffordanceSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FloorplanMoveTarget ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Kind-specific 2D move-on-floorplan handler. Distinct from
|
||||||
|
// `FloorplanAffordance` because the lifecycle is different:
|
||||||
|
//
|
||||||
|
// - `FloorplanAffordance` is **handle-driven** — the user pointer-downs
|
||||||
|
// on a specific handle (endpoint dot, vertex, edge), drags, releases.
|
||||||
|
// Has an `initialPlanPoint`. One drag = one session.
|
||||||
|
// - `FloorplanMoveTarget` is **movingNode-driven** — the user clicks
|
||||||
|
// "Move" in the inspector / action menu, the floor-plan tracks the
|
||||||
|
// cursor from that moment until pointer-up or Esc. No initial
|
||||||
|
// pointer-down. The session starts when `useEditor.movingNode` is
|
||||||
|
// set to a node whose kind exposes `floorplanMoveTarget`.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// - door / window: pointer must hit a wall in plan space; commit
|
||||||
|
// re-anchors to the new wall (parentId + wallId + local position +
|
||||||
|
// side + rotation). Reuses `door-math` / `window-math` clamp +
|
||||||
|
// overlap helpers.
|
||||||
|
// - item with `attachTo: 'wall'` / `'wall-side'`: same as door /
|
||||||
|
// window but the local Y is free (item can move up/down the wall).
|
||||||
|
// - item with `attachTo: 'ceiling'`: hit-test ceiling polygons,
|
||||||
|
// reparent on transition.
|
||||||
|
// - item with `attachTo: 'floor'` (or no attachTo): point-in-slab
|
||||||
|
// check, snap to slab elevation.
|
||||||
|
//
|
||||||
|
// Falls back to `FloorplanRegistryMoveOverlay`'s generic free-floating
|
||||||
|
// translate when `floorplanMoveTarget` is unset on the kind.
|
||||||
|
|
||||||
|
export type FloorplanMoveTargetSession = {
|
||||||
|
/** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */
|
||||||
|
affectedIds: AnyNodeId[]
|
||||||
|
/**
|
||||||
|
* Single move-preview tick. Implementations call `scene.updateNodes`
|
||||||
|
* directly to drive the live preview (no separate draft state).
|
||||||
|
*/
|
||||||
|
apply(args: {
|
||||||
|
planPoint: FloorplanAffordancePoint
|
||||||
|
modifiers: FloorplanAffordanceModifiers
|
||||||
|
}): void
|
||||||
|
/**
|
||||||
|
* Called on pointer-up. Return `true` to commit the current scene
|
||||||
|
* state; `false` reverts to the snapshot (e.g. dropped in invalid
|
||||||
|
* area, overlap detected, ...).
|
||||||
|
*/
|
||||||
|
canCommit(): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FloorplanMoveTarget<N> = (args: {
|
||||||
|
node: N
|
||||||
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
|
}) => FloorplanMoveTargetSession
|
||||||
|
|
||||||
// ─── Plugin manifest ─────────────────────────────────────────────────
|
// ─── Plugin manifest ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -142,6 +549,23 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
* work (animations, named-mesh material poking).
|
* work (animations, named-mesh material poking).
|
||||||
*/
|
*/
|
||||||
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
|
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
|
||||||
|
/**
|
||||||
|
* Level-batch precompute hook. Called by `<GeometrySystem>` once per
|
||||||
|
* level per frame, **before** the per-node `def.geometry` calls in
|
||||||
|
* that batch. The result lands in `ctx.levelData` for every node in
|
||||||
|
* the same level.
|
||||||
|
*
|
||||||
|
* Used by kinds whose geometry depends on cross-sibling data that
|
||||||
|
* would be O(N²) to recompute per node:
|
||||||
|
* - wall: `calculateLevelMiters(walls)` — every wall's mesh
|
||||||
|
* reads its junctions from the level-wide miter graph.
|
||||||
|
* - zone (planned): shared TSL gradient uniforms.
|
||||||
|
*
|
||||||
|
* `siblings` is every node of this kind in the same level (including
|
||||||
|
* the dirty ones). The dispatcher de-duplicates per level so this
|
||||||
|
* runs once even when many walls are dirty in the same frame.
|
||||||
|
*/
|
||||||
|
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
|
||||||
/**
|
/**
|
||||||
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
|
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
|
||||||
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
|
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
|
||||||
@@ -157,6 +581,36 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
* the legacy `floorplan-panel.tsx` monolith.
|
* the legacy `floorplan-panel.tsx` monolith.
|
||||||
*/
|
*/
|
||||||
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
|
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
|
||||||
|
/**
|
||||||
|
* 2D drag affordances keyed by the string identifier emitted on
|
||||||
|
* `endpoint-handle` (and similar interactive floor-plan primitives) via
|
||||||
|
* the `affordance` field. The floor-plan registry layer calls
|
||||||
|
* `def.floorplanAffordances?.[affordance].start({...})` on pointer-down,
|
||||||
|
* receives a session, calls `apply(...)` on pointer-move and
|
||||||
|
* `commit()` / `cancel()` on pointer-up / pointer-cancel. The session
|
||||||
|
* mutates scene state directly during `apply`; the dispatcher handles
|
||||||
|
* the snapshot + single-undo dance around it.
|
||||||
|
*
|
||||||
|
* Mirrors the existing 3D `affordanceTools` map but for 2D SVG events,
|
||||||
|
* and operates on plain JS data instead of mounting React. Kinds with
|
||||||
|
* both 3D and 2D affordances expose both fields — they're independent.
|
||||||
|
*/
|
||||||
|
floorplanAffordances?: Record<string, FloorplanAffordance<z.infer<S>>>
|
||||||
|
/**
|
||||||
|
* Kind-specific 2D move handler for `useEditor.movingNode`-driven
|
||||||
|
* placement in the floor plan. When set, `FloorplanRegistryMove
|
||||||
|
* Overlay` invokes this once when `movingNode` becomes a node of
|
||||||
|
* this kind, and drives the session through pointer events until
|
||||||
|
* pointer-up / Esc. Falls back to the generic free-floating
|
||||||
|
* translate when unset.
|
||||||
|
*
|
||||||
|
* Use this for kinds whose move semantics are anchor-aware:
|
||||||
|
* doors / windows need wall hits + reparenting; items with
|
||||||
|
* `attachTo` need parent-surface hits. Kinds with simple
|
||||||
|
* translate-on-XZ semantics (shelf, spawn, fence) leave this
|
||||||
|
* unset and rely on the generic overlay path.
|
||||||
|
*/
|
||||||
|
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
|
||||||
system?: SystemContribution
|
system?: SystemContribution
|
||||||
tool?: LazyComponent
|
tool?: LazyComponent
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,11 +3,17 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
type FloorplanMoveTargetSession,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
|
pauseSceneHistory,
|
||||||
|
resumeSceneHistory,
|
||||||
snapPointToGrid,
|
snapPointToGrid,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
|
||||||
const GRID_STEP = 0.5
|
const GRID_STEP = 0.5
|
||||||
@@ -16,20 +22,21 @@ const GRID_STEP = 0.5
|
|||||||
* Cursor-driven placement for registered kinds in the floor plan.
|
* Cursor-driven placement for registered kinds in the floor plan.
|
||||||
*
|
*
|
||||||
* Activates when `useEditor.movingNode` is set to a node whose kind is
|
* Activates when `useEditor.movingNode` is set to a node whose kind is
|
||||||
* registered with `def.floorplan`. Tracks the pointer on the floor plan
|
* registered with `def.floorplan`. Two dispatch paths:
|
||||||
* SVG via the `[data-floorplan-scene]` `<g>` (set by floorplan-panel.tsx
|
|
||||||
* via a one-line attribute) and **imperatively translates the original
|
|
||||||
* rendered entry** so the user sees the actual shape follow the cursor —
|
|
||||||
* no ghost overlay, no double rendering.
|
|
||||||
*
|
*
|
||||||
* Coordinate conversion routes through the scene `<g>`'s `getScreenCTM`
|
* 1. **`def.floorplanMoveTarget` present** (door / window / item):
|
||||||
* so cursor → meters accounts for the floor plan's pan / zoom / building
|
* kind-specific 2D move handler with wall / ceiling / slab
|
||||||
* rotation. Position snaps to a 0.5m grid (matches the 3D placement
|
* anchor logic. Pointer events feed `session.apply` which writes
|
||||||
* tool's GRID_STEP). Pointerup commits via `updateNode`; Esc cancels.
|
* directly to `useScene`; pointer-up does the single-undo dance
|
||||||
|
* (revert→resume→re-apply) if `canCommit()` is true.
|
||||||
|
* 2. **Fallback — generic free-floating translate**: imperatively
|
||||||
|
* translates the rendered SVG entry on pointer-move, commits via
|
||||||
|
* `updateNode` on pointer-up. Used by shelf / spawn / fence /
|
||||||
|
* etc. whose move is "translate position on X/Z plane".
|
||||||
*
|
*
|
||||||
* Lives outside the floorplan-panel.tsx monolith. Mounts once globally
|
* Lives outside the `floorplan-panel.tsx` monolith. Coordinate
|
||||||
* at the panel root; renders nothing unless the active movingNode is a
|
* conversion routes through the scene `<g>`'s `getScreenCTM` so
|
||||||
* registered kind.
|
* cursor → meters accounts for pan / zoom / building rotation.
|
||||||
*/
|
*/
|
||||||
export function FloorplanRegistryMoveOverlay() {
|
export function FloorplanRegistryMoveOverlay() {
|
||||||
const movingNode = useEditor((s) => s.movingNode)
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
@@ -37,6 +44,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
|
|
||||||
const def = movingNode ? nodeRegistry.get(movingNode.type) : null
|
const def = movingNode ? nodeRegistry.get(movingNode.type) : null
|
||||||
const isActive = !!movingNode && !!def?.floorplan
|
const isActive = !!movingNode && !!def?.floorplan
|
||||||
|
const hasMoveTarget = !!def?.floorplanMoveTarget
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive || !movingNode) return
|
if (!isActive || !movingNode) return
|
||||||
@@ -44,21 +52,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
|
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
|
||||||
if (!scene) return
|
if (!scene) return
|
||||||
|
|
||||||
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
const toMeters = (clientX: number, clientY: number): [number, number] | null => {
|
||||||
if (!entry) return
|
|
||||||
|
|
||||||
// Capture the original position so the imperative translate is a
|
|
||||||
// pure delta — the inner FloorplanGeometry transform (the shelf
|
|
||||||
// builder's `translate(px pz) rotate(deg)`) stays untouched.
|
|
||||||
const originalPosition = ((
|
|
||||||
movingNode as unknown as {
|
|
||||||
position?: [number, number, number]
|
|
||||||
}
|
|
||||||
).position ?? [0, 0, 0]) as [number, number, number]
|
|
||||||
|
|
||||||
let lastSnapped: [number, number] | null = null
|
|
||||||
|
|
||||||
const toMeters = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
|
||||||
const svg = scene.ownerSVGElement
|
const svg = scene.ownerSVGElement
|
||||||
if (!svg) return null
|
if (!svg) return null
|
||||||
const ctm = scene.getScreenCTM()
|
const ctm = scene.getScreenCTM()
|
||||||
@@ -67,13 +61,286 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
pt.x = clientX
|
pt.x = clientX
|
||||||
pt.y = clientY
|
pt.y = clientY
|
||||||
const m = pt.matrixTransform(ctm.inverse())
|
const m = pt.matrixTransform(ctm.inverse())
|
||||||
return { x: m.x, y: m.y }
|
return [m.x, m.y]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
|
||||||
|
if (hasMoveTarget && def?.floorplanMoveTarget) {
|
||||||
|
const sceneNodes = useScene.getState().nodes
|
||||||
|
const session: FloorplanMoveTargetSession = (
|
||||||
|
def.floorplanMoveTarget as (a: {
|
||||||
|
node: AnyNode
|
||||||
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
|
}) => FloorplanMoveTargetSession
|
||||||
|
)({ node: movingNode, nodes: sceneNodes })
|
||||||
|
|
||||||
|
// Capture snapshots of every affected node BEFORE the first apply
|
||||||
|
// so the single-undo dance has a clean baseline to revert to.
|
||||||
|
const snapshots = session.affectedIds
|
||||||
|
.map((id) => sceneNodes[id])
|
||||||
|
.filter((n): n is AnyNode => !!n)
|
||||||
|
.map((n) => snapshotNode(n))
|
||||||
|
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
let historyPaused = true
|
||||||
|
|
||||||
|
// The registry action menu's Move button portals to `document.body`,
|
||||||
|
// so the trigger click's pointer-up happens OUTSIDE the floor-plan
|
||||||
|
// scene and never reaches `onPointerUp` here. That means: the very
|
||||||
|
// first window-pointer-up the overlay sees is the user's intended
|
||||||
|
// commit click. No "click-to-enter" gesture to detect — the older
|
||||||
|
// flow used an orange "Move" dot rendered inside the slab itself,
|
||||||
|
// where the trigger click DID hit the overlay's listener and had
|
||||||
|
// to be consumed. That legacy flow is gone in the registry layer;
|
||||||
|
// all entries use the action menu now.
|
||||||
|
let hasMovedSinceStart = false
|
||||||
|
|
||||||
|
const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
|
||||||
|
// We can't just check `target.closest('[data-floorplan-scene]')`
|
||||||
|
// because the scene's `<g>` only covers painted SVG elements —
|
||||||
|
// hovering empty grid background returns the parent SVG element
|
||||||
|
// as target (no ancestor with the marker), so the closest check
|
||||||
|
// fails. Compare the pointer position against the scene's
|
||||||
|
// bounding rect instead: any cursor inside the SVG viewport
|
||||||
|
// counts as "over the floor plan", regardless of whether the
|
||||||
|
// exact pixel paints a node or just blank surface.
|
||||||
|
const svg = scene.ownerSVGElement
|
||||||
|
if (!svg) return false
|
||||||
|
const rect = svg.getBoundingClientRect()
|
||||||
|
return (
|
||||||
|
clientX >= rect.left &&
|
||||||
|
clientX <= rect.right &&
|
||||||
|
clientY >= rect.top &&
|
||||||
|
clientY <= rect.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
// Skip 3D-canvas / other-UI cursor moves so the overlay only
|
||||||
|
// tracks pointer events that actually correspond to a floor-plan
|
||||||
|
// location. The bounding-rect check (vs the legacy
|
||||||
|
// `target.closest('[data-floorplan-scene]')`) also picks up
|
||||||
|
// hovers over empty grid background — without it, the cursor
|
||||||
|
// only updated the shelf when it happened to brush over an
|
||||||
|
// existing SVG entry, leaving the move feeling "stuck" elsewhere.
|
||||||
|
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
||||||
|
const planPoint = toMeters(event.clientX, event.clientY)
|
||||||
|
if (!planPoint) return
|
||||||
|
hasMovedSinceStart = true
|
||||||
|
session.apply({
|
||||||
|
planPoint,
|
||||||
|
modifiers: {
|
||||||
|
shiftKey: event.shiftKey,
|
||||||
|
altKey: event.altKey,
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitFinalStateOrRevert = () => {
|
||||||
|
const commitValid = session.canCommit()
|
||||||
|
const sceneState = useScene.getState().nodes
|
||||||
|
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
|
||||||
|
for (const snap of snapshots) {
|
||||||
|
const current = sceneState[snap.id]
|
||||||
|
if (!current) continue
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
let changed = false
|
||||||
|
for (const [key, before] of Object.entries(snap.data)) {
|
||||||
|
const after = (current as unknown as Record<string, unknown>)[key]
|
||||||
|
if (!deepEqual(before, after)) {
|
||||||
|
data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) finalUpdates.push({ id: snap.id, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commitValid && finalUpdates.length > 0) {
|
||||||
|
// Single-undo dance:
|
||||||
|
// 1. Revert to baseline while history is still paused.
|
||||||
|
// 2. Resume history.
|
||||||
|
// 3. Re-apply the final state — recorded as one tracked change.
|
||||||
|
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||||
|
if (historyPaused) {
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
historyPaused = false
|
||||||
|
}
|
||||||
|
useScene.getState().updateNodes(finalUpdates)
|
||||||
|
// Strip the isNew metadata once committed (matches the legacy
|
||||||
|
// 3D move-tool that demotes duplicated nodes from "new" status
|
||||||
|
// on first successful drop).
|
||||||
|
for (const snap of snapshots) {
|
||||||
|
const current = useScene.getState().nodes[snap.id]
|
||||||
|
const meta =
|
||||||
|
current && typeof (current as { metadata?: unknown }).metadata === 'object'
|
||||||
|
? ((current as { metadata?: Record<string, unknown> }).metadata ?? {})
|
||||||
|
: {}
|
||||||
|
if (meta.isNew) {
|
||||||
|
useScene.getState().updateNodes([
|
||||||
|
{
|
||||||
|
id: snap.id,
|
||||||
|
data: { metadata: { ...meta, isNew: false } } as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
// Re-select the moved node(s) — mirrors the legacy 3D move
|
||||||
|
// tool. The action menu cleared selection on Move click so
|
||||||
|
// selection-gated affordances (slab/ceiling boundary editor,
|
||||||
|
// etc.) would unmount during the drag; restoring it here
|
||||||
|
// brings them back at the new position.
|
||||||
|
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
||||||
|
} else {
|
||||||
|
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||||
|
if (historyPaused) {
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
historyPaused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = (event: PointerEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
// Bounding-rect check (see `isPointerOverFloorplanScene`) — same
|
||||||
|
// reason as `onMove`: commits should land for any pointer-up
|
||||||
|
// inside the SVG viewport, including empty grid background.
|
||||||
|
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
||||||
|
|
||||||
|
commitFinalStateOrRevert()
|
||||||
|
setMovingNode(null)
|
||||||
|
|
||||||
|
// Swallow the click event that follows this pointer-up — the
|
||||||
|
// floor-plan SVG's `handleBackgroundClick` would otherwise route
|
||||||
|
// it through `resolveFloorplanBackgroundSelection`, which clears
|
||||||
|
// the selection if the click resolved to empty space. We already
|
||||||
|
// set selection back to the moved node in `commitFinalStateOrRevert`;
|
||||||
|
// letting the background-click handler run would undo that for
|
||||||
|
// any commit click that doesn't happen to land directly on the
|
||||||
|
// node's hit-test geometry.
|
||||||
|
//
|
||||||
|
// The 3D mover doesn't need this because its grid-click fires
|
||||||
|
// via the emitter inside the R3F pointer event and can call
|
||||||
|
// `event.nativeEvent.stopPropagation()`; the 2D pointerup and
|
||||||
|
// the following click are separate DOM events, so we listen on
|
||||||
|
// window in the capture phase to intercept the click before any
|
||||||
|
// bubble-phase handler (the floor-plan SVG) sees it.
|
||||||
|
const swallowClick = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
window.removeEventListener('click', swallowClick, true)
|
||||||
|
}
|
||||||
|
window.addEventListener('click', swallowClick, true)
|
||||||
|
// Safety net: if no click fires (e.g. user dragged enough to
|
||||||
|
// suppress it), drop the listener on the next tick.
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener('click', swallowClick, true)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return
|
||||||
|
// Revert untracked, then resume — no history entry.
|
||||||
|
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||||
|
if (historyPaused) {
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
historyPaused = false
|
||||||
|
}
|
||||||
|
// Clear any live-transform previews the session wrote (slab /
|
||||||
|
// ceiling 2D move stages a translation delta in
|
||||||
|
// `useLiveTransforms`; without this clear, escape leaves the
|
||||||
|
// 2D layer rendering the polygon at the cancelled delta).
|
||||||
|
for (const id of session.affectedIds) {
|
||||||
|
useLiveTransforms.getState().clear(id)
|
||||||
|
}
|
||||||
|
// Restore selection cleared by the action menu's Move click.
|
||||||
|
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
||||||
|
setMovingNode(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onMove)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
// Unmount cleanup. Two scenarios when `historyPaused === true`:
|
||||||
|
//
|
||||||
|
// - User did at least one 2D apply (`hasMovedSinceStart`) but
|
||||||
|
// never committed — likely a mid-drag unmount. Revert the
|
||||||
|
// untracked writes so we don't leak partial state.
|
||||||
|
// - No 2D apply happened. The legacy `MoveItemContent` (3D
|
||||||
|
// mover) may have committed via `draftNode.commit` just
|
||||||
|
// before this unmount; clobbering that with a blind revert
|
||||||
|
// is the bug — both the rotation and position issues. Skip
|
||||||
|
// the revert and just resume history.
|
||||||
|
//
|
||||||
|
// Additionally, in split view the user may have brushed the
|
||||||
|
// cursor over the floor plan (setting `hasMovedSinceStart`)
|
||||||
|
// and then committed via a 3D mover. The 3D commit writes the
|
||||||
|
// new state to `scene` directly, so by the time this cleanup
|
||||||
|
// runs `snapshots` no longer matches scene state. Reverting
|
||||||
|
// here would stomp the 3D commit. Detect the case by
|
||||||
|
// comparing snapshot fields to current scene state — if they
|
||||||
|
// already differ, an external committer has finalised, leave
|
||||||
|
// it alone.
|
||||||
|
//
|
||||||
|
// Normal 2D commit / Escape paths set `historyPaused = false`
|
||||||
|
// inside `commitFinalStateOrRevert` / `onKey`, so this branch
|
||||||
|
// is skipped there.
|
||||||
|
if (historyPaused) {
|
||||||
|
if (hasMovedSinceStart) {
|
||||||
|
const currentNodes = useScene.getState().nodes
|
||||||
|
const externallyCommitted = snapshots.some((snap) => {
|
||||||
|
const current = currentNodes[snap.id]
|
||||||
|
if (!current) return false
|
||||||
|
for (const [key, before] of Object.entries(snap.data)) {
|
||||||
|
const after = (current as unknown as Record<string, unknown>)[key]
|
||||||
|
if (!deepEqual(before, after)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!externallyCommitted) {
|
||||||
|
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
}
|
||||||
|
// Belt-and-suspenders: clear any live-transform previews on
|
||||||
|
// abnormal unmount paths too. Slab / ceiling sessions write
|
||||||
|
// `useLiveTransforms` to drive the smooth drag visual; in pure
|
||||||
|
// 2D view the 3D `MoveSlabTool` cleanup isn't there to clear
|
||||||
|
// it for us.
|
||||||
|
for (const id of session.affectedIds) {
|
||||||
|
useLiveTransforms.getState().clear(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path 2 — generic free-floating translate ────────────────────
|
||||||
|
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
const originalPosition = ((
|
||||||
|
movingNode as unknown as {
|
||||||
|
position?: [number, number, number]
|
||||||
|
}
|
||||||
|
).position ?? [0, 0, 0]) as [number, number, number]
|
||||||
|
|
||||||
|
let lastSnapped: [number, number] | null = null
|
||||||
|
|
||||||
const onMove = (event: PointerEvent) => {
|
const onMove = (event: PointerEvent) => {
|
||||||
|
// Same target guard as Path 1 — pointer must be over the floor
|
||||||
|
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
||||||
|
// plan coords.
|
||||||
|
const target = event.target as Element | null
|
||||||
|
if (!target || !target.closest('[data-floorplan-scene]')) return
|
||||||
const m = toMeters(event.clientX, event.clientY)
|
const m = toMeters(event.clientX, event.clientY)
|
||||||
if (!m) return
|
if (!m) return
|
||||||
const [sx, sz] = snapPointToGrid([m.x, m.y], GRID_STEP)
|
const [sx, sz] = snapPointToGrid([m[0], m[1]], GRID_STEP)
|
||||||
const dx = sx - originalPosition[0]
|
const dx = sx - originalPosition[0]
|
||||||
const dz = sz - originalPosition[2]
|
const dz = sz - originalPosition[2]
|
||||||
entry.setAttribute('transform', `translate(${dx} ${dz})`)
|
entry.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||||
@@ -82,9 +349,6 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
|
|
||||||
const onPointerUp = (event: PointerEvent) => {
|
const onPointerUp = (event: PointerEvent) => {
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
// Commit only when the pointerup happened inside the floor plan
|
|
||||||
// SVG (so clicks on the inspector / palette / tabs don't accidentally
|
|
||||||
// commit a placement).
|
|
||||||
const target = event.target as Element | null
|
const target = event.target as Element | null
|
||||||
if (!target || !target.closest('[data-floorplan-scene]')) return
|
if (!target || !target.closest('[data-floorplan-scene]')) return
|
||||||
|
|
||||||
@@ -123,10 +387,52 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
window.removeEventListener('pointermove', onMove)
|
window.removeEventListener('pointermove', onMove)
|
||||||
window.removeEventListener('pointerup', onPointerUp)
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey)
|
||||||
// Defensive cleanup in case the component unmounts mid-drag.
|
|
||||||
entry.removeAttribute('transform')
|
entry.removeAttribute('transform')
|
||||||
}
|
}
|
||||||
}, [isActive, movingNode, setMovingNode])
|
}, [isActive, movingNode, setMovingNode, hasMoveTarget, def])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
|
||||||
|
//
|
||||||
|
// Kept inline here to avoid a circular dependency through a shared
|
||||||
|
// utility module. If a third call site shows up, extract.
|
||||||
|
|
||||||
|
type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
|
||||||
|
|
||||||
|
function snapshotNode(node: AnyNode): NodeSnapshot {
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
if (key === 'id' || key === 'type' || key === 'object') continue
|
||||||
|
data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
|
||||||
|
}
|
||||||
|
return { id: node.id, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
||||||
|
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (!deepEqual(a[i], b[i])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
|
||||||
|
const aKeys = Object.keys(a as Record<string, unknown>)
|
||||||
|
const bKeys = Object.keys(b as Record<string, unknown>)
|
||||||
|
if (aKeys.length !== bKeys.length) return false
|
||||||
|
for (const key of aKeys) {
|
||||||
|
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
+137
-59
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { FloorplanGeometry } from '@pascal-app/core'
|
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
|
||||||
import { memo } from 'react'
|
import { memo, useEffect, useState } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
|
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
|
||||||
@@ -29,92 +29,103 @@ export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer
|
|||||||
return renderNode(geometry, 0)
|
return renderNode(geometry, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function styleAttrs(g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> }) {
|
||||||
|
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
|
||||||
|
// primitive switch arms terse and ensures new style fields land
|
||||||
|
// everywhere at once. `as any` avoids re-asserting every variant
|
||||||
|
// includes the style fields — they all do, except `group` (which is
|
||||||
|
// filtered out by the caller's type bound).
|
||||||
|
const s = g as unknown as {
|
||||||
|
fill?: string
|
||||||
|
fillOpacity?: number
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
|
strokeDasharray?: string
|
||||||
|
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||||
|
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
||||||
|
strokeOpacity?: number
|
||||||
|
opacity?: number
|
||||||
|
vectorEffect?: 'non-scaling-stroke'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fill: s.fill ?? 'none',
|
||||||
|
fillOpacity: s.fillOpacity,
|
||||||
|
stroke: s.stroke,
|
||||||
|
strokeWidth: s.strokeWidth,
|
||||||
|
strokeDasharray: s.strokeDasharray,
|
||||||
|
strokeLinecap: s.strokeLinecap,
|
||||||
|
strokeLinejoin: s.strokeLinejoin,
|
||||||
|
strokeOpacity: s.strokeOpacity,
|
||||||
|
opacity: s.opacity,
|
||||||
|
vectorEffect: s.vectorEffect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | null {
|
function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | null {
|
||||||
switch (g.kind) {
|
switch (g.kind) {
|
||||||
case 'path':
|
case 'path':
|
||||||
return (
|
return <path d={g.d} key={keyHint} {...styleAttrs(g)} />
|
||||||
<path
|
|
||||||
d={g.d}
|
|
||||||
fill={g.fill ?? 'none'}
|
|
||||||
key={keyHint}
|
|
||||||
opacity={g.opacity}
|
|
||||||
stroke={g.stroke}
|
|
||||||
strokeDasharray={g.strokeDasharray}
|
|
||||||
strokeWidth={g.strokeWidth}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'polygon':
|
case 'polygon':
|
||||||
return (
|
return <polygon key={keyHint} points={pointsToAttr(g.points)} {...styleAttrs(g)} />
|
||||||
<polygon
|
|
||||||
fill={g.fill ?? 'none'}
|
|
||||||
key={keyHint}
|
|
||||||
opacity={g.opacity}
|
|
||||||
points={pointsToAttr(g.points)}
|
|
||||||
stroke={g.stroke}
|
|
||||||
strokeDasharray={g.strokeDasharray}
|
|
||||||
strokeWidth={g.strokeWidth}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'polyline':
|
case 'polyline':
|
||||||
return (
|
return <polyline key={keyHint} points={pointsToAttr(g.points)} {...styleAttrs(g)} />
|
||||||
<polyline
|
|
||||||
fill={g.fill ?? 'none'}
|
|
||||||
key={keyHint}
|
|
||||||
opacity={g.opacity}
|
|
||||||
points={pointsToAttr(g.points)}
|
|
||||||
stroke={g.stroke}
|
|
||||||
strokeDasharray={g.strokeDasharray}
|
|
||||||
strokeWidth={g.strokeWidth}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'rect':
|
case 'rect':
|
||||||
return (
|
return (
|
||||||
<rect
|
<rect
|
||||||
fill={g.fill ?? 'none'}
|
|
||||||
height={g.height}
|
height={g.height}
|
||||||
key={keyHint}
|
key={keyHint}
|
||||||
opacity={g.opacity}
|
|
||||||
rx={g.rx}
|
rx={g.rx}
|
||||||
ry={g.ry}
|
ry={g.ry}
|
||||||
stroke={g.stroke}
|
|
||||||
strokeDasharray={g.strokeDasharray}
|
|
||||||
strokeWidth={g.strokeWidth}
|
|
||||||
width={g.width}
|
width={g.width}
|
||||||
x={g.x}
|
x={g.x}
|
||||||
y={g.y}
|
y={g.y}
|
||||||
|
{...styleAttrs(g)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'circle':
|
case 'circle':
|
||||||
return (
|
return <circle cx={g.cx} cy={g.cy} key={keyHint} r={g.r} {...styleAttrs(g)} />
|
||||||
<circle
|
|
||||||
cx={g.cx}
|
|
||||||
cy={g.cy}
|
|
||||||
fill={g.fill ?? 'none'}
|
|
||||||
key={keyHint}
|
|
||||||
opacity={g.opacity}
|
|
||||||
r={g.r}
|
|
||||||
stroke={g.stroke}
|
|
||||||
strokeDasharray={g.strokeDasharray}
|
|
||||||
strokeWidth={g.strokeWidth}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'line':
|
case 'line':
|
||||||
|
return <line key={keyHint} x1={g.x1} x2={g.x2} y1={g.y1} y2={g.y2} {...styleAttrs(g)} />
|
||||||
|
|
||||||
|
case 'text':
|
||||||
return (
|
return (
|
||||||
<line
|
<text
|
||||||
|
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
||||||
|
fill={g.fill ?? '#171717'}
|
||||||
|
fontFamily={g.fontFamily}
|
||||||
|
fontSize={g.fontSize}
|
||||||
|
fontWeight={g.fontWeight}
|
||||||
key={keyHint}
|
key={keyHint}
|
||||||
opacity={g.opacity}
|
opacity={g.opacity}
|
||||||
|
paintOrder={g.paintOrder}
|
||||||
stroke={g.stroke}
|
stroke={g.stroke}
|
||||||
strokeDasharray={g.strokeDasharray}
|
strokeLinecap={g.stroke ? 'round' : undefined}
|
||||||
|
strokeLinejoin={g.stroke ? 'round' : undefined}
|
||||||
strokeWidth={g.strokeWidth}
|
strokeWidth={g.strokeWidth}
|
||||||
x1={g.x1}
|
textAnchor={g.textAnchor ?? 'start'}
|
||||||
x2={g.x2}
|
x={g.x}
|
||||||
y1={g.y1}
|
y={g.y}
|
||||||
y2={g.y2}
|
>
|
||||||
|
{g.text}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<FloorplanImage
|
||||||
|
center={g.center}
|
||||||
|
height={g.height}
|
||||||
|
key={keyHint}
|
||||||
|
opacity={g.opacity}
|
||||||
|
preserveAspectRatio={g.preserveAspectRatio ?? 'xMidYMid meet'}
|
||||||
|
rotation={g.rotation ?? 0}
|
||||||
|
url={g.url}
|
||||||
|
width={g.width}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,6 +137,15 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement |
|
|||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The interactive primitives (hatch / hit-line / endpoint-handle /
|
||||||
|
// dimension-label) need the SVG context + theme palette + units-per-
|
||||||
|
// pixel that only the registry layer has access to. They're rendered
|
||||||
|
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If
|
||||||
|
// a caller routes one of these through this pure renderer it
|
||||||
|
// silently drops — the static renderer is for static output.
|
||||||
|
default:
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,3 +163,61 @@ function formatTransform(t?: {
|
|||||||
if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
|
if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
|
||||||
return parts.length > 0 ? parts.join(' ') : undefined
|
return parts.length > 0 ? parts.join(' ') : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `image` primitive renderer. Resolves the URL asynchronously via
|
||||||
|
* `loadAssetUrl` (handles CDN / Supabase storage) and renders an SVG
|
||||||
|
* `<image>` centered at `center`, rotated around it, sized in plan-local
|
||||||
|
* metres. While the resolution is in flight, renders nothing.
|
||||||
|
*/
|
||||||
|
function FloorplanImage({
|
||||||
|
url,
|
||||||
|
center,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rotation,
|
||||||
|
preserveAspectRatio,
|
||||||
|
opacity,
|
||||||
|
}: {
|
||||||
|
url: string
|
||||||
|
center: readonly [number, number]
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
rotation: number
|
||||||
|
preserveAspectRatio: string
|
||||||
|
opacity?: number
|
||||||
|
}) {
|
||||||
|
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) {
|
||||||
|
setResolvedUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setResolvedUrl(null)
|
||||||
|
loadAssetUrl(url).then((next) => {
|
||||||
|
if (!cancelled) setResolvedUrl(next)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [url])
|
||||||
|
if (!resolvedUrl) return null
|
||||||
|
const rotationDeg = (rotation * 180) / Math.PI
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
pointerEvents="none"
|
||||||
|
transform={`translate(${center[0]} ${center[1]}) rotate(${rotationDeg})`}
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
height={height}
|
||||||
|
href={resolvedUrl}
|
||||||
|
opacity={opacity}
|
||||||
|
preserveAspectRatio={preserveAspectRatio}
|
||||||
|
width={width}
|
||||||
|
x={-width / 2}
|
||||||
|
y={-height / 2}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user