feat(editor): roof/stair/elevator snapping migration + no-angle footprint draft
Migrate roof/stair/elevator draft tools + the 2D floorplan move overlay off the legacy Shift/Alt=bypass model onto mode-driven snapping (isGridSnapActive / isMagneticSnapActive); Alt dropped (no validity gate). stair/elevator now use the live grid step. These three are placed as footprints, not directional draws, so the angle-lock mode was meaningless: add NodeDefinition.snapDraftDirectional (default true; false for roof/stair/elevator) so their draft resolves to the no-angle 'polygon' context (grid / lines / off). snapContextOf takes an injected draftDirectionalOf, like profileOf. Add toolHints to stair/elevator so they route through the contextual HUD and show the snapping chip. Fix one stale Alt-bypass comment in the item placement coordinator (#9: already force-only). +snapping-mode test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8e9a45a9d5
commit
bbf9291c2d
@@ -986,6 +986,17 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
*/
|
||||
snapProfile?: SnapProfile
|
||||
|
||||
/**
|
||||
* For `structural` kinds: does drafting this kind set a DIRECTION (so the
|
||||
* angle-lock snapping mode is meaningful)? Wall/fence/slab/ceiling drafting
|
||||
* draws directed edges → `true` (the default). Roof/stair/elevator are placed
|
||||
* as axis-aligned footprints, not directional draws → `false`, so their
|
||||
* drafting uses the no-angle `polygon` snap context (grid / lines / off)
|
||||
* instead of the angle-bearing `wall` context. Ignored for `item` kinds
|
||||
* (their context never carries an angle lock).
|
||||
*/
|
||||
snapDraftDirectional?: boolean
|
||||
|
||||
/**
|
||||
* Optional translucent preview of the node — used by the move tool to
|
||||
* show where the node will land, and by the placement tool's cursor.
|
||||
|
||||
@@ -23,7 +23,7 @@ import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib
|
||||
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
|
||||
import { useMovingNode } from '../../store/use-interaction-scope'
|
||||
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
||||
|
||||
@@ -509,10 +509,12 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
if (!m) return
|
||||
|
||||
// 1) Grid snap baseline. Fresh catalog placement is absolute under
|
||||
// the cursor; existing moves preserve the cursor's grab offset.
|
||||
// the cursor; existing moves preserve the cursor's grab offset. Grid
|
||||
// follows the active snapping mode (Shift cycles it); raw cursor in
|
||||
// any non-grid mode.
|
||||
const gridStep = useEditor.getState().gridSnapStep
|
||||
const snap = (value: number) =>
|
||||
event.shiftKey ? value : Math.round(value / gridStep) * gridStep
|
||||
isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
|
||||
const resolved = resolvePlanarCursorPosition({
|
||||
cursor: [m[0], m[1]],
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
@@ -525,12 +527,12 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
|
||||
// 2) Alignment snap layered on top. Treat the grid-snapped point
|
||||
// as the "proposed" position so alignment competes from a stable
|
||||
// base rather than the raw cursor jitter. Alt bypasses alignment
|
||||
// entirely; Shift bypasses both grid and alignment
|
||||
// hint chip.
|
||||
// base rather than the raw cursor jitter. Alignment ("lines") follows
|
||||
// the magnetic snapping mode — independent of grid; Alt is force-place,
|
||||
// not a snap bypass.
|
||||
let finalX = gridX
|
||||
let finalZ = gridZ
|
||||
if (!(event.altKey || event.shiftKey) && candidateAnchors.length > 0) {
|
||||
if (isMagneticSnapActive() && candidateAnchors.length > 0) {
|
||||
// Translate the cached local bbox to the proposed pos to get the
|
||||
// moving anchors at that location. The entry's untransformed
|
||||
// bbox is in world meters relative to the node's origin, so a
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import usePlacementPreview from '../../../store/use-placement-preview'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import {
|
||||
@@ -163,7 +164,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
// that happen to sit on a grid line, so off-grid items (furniture, angled
|
||||
// walls) would never surface a guide. The matched axis locks exactly to the
|
||||
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
|
||||
// candidate's coordinate; the other axis keeps its grid snap. Alignment runs
|
||||
// only when the magnetic (lines) snapping mode is active.
|
||||
const alignPoint = (
|
||||
gridX: number,
|
||||
gridZ: number,
|
||||
@@ -195,13 +197,19 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
// Grid snap follows the global mode (live step so the HUD chip is
|
||||
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[0] / step) * step
|
||||
: event.localPosition[0],
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[2] / step) * step
|
||||
: event.localPosition[2],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
const supportY = resolveElevatorSupportY({
|
||||
buildingId: currentBuildingId,
|
||||
@@ -221,7 +229,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
})
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
(isGridSnapActive() || isMagneticSnapActive()) &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
@@ -239,13 +247,17 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
})
|
||||
if (!latestBuildingId) return
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[0] / step) * step
|
||||
: event.localPosition[0],
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[2] / step) * step
|
||||
: event.localPosition[2],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
commitElevatorPlacement(
|
||||
latestBuildingId,
|
||||
|
||||
@@ -775,9 +775,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// item's edge, snap and publish a guide. The guide connects to the
|
||||
// nearest real corner of the candidate (resolver tie-break), so the dot
|
||||
// always sits on an actual point. The delta is applied to BOTH the grid
|
||||
// and cursor positions below. Alt (free place) bypasses all snap; the
|
||||
// active snapping mode governs whether alignment runs at all ('off' /
|
||||
// 'angles' disable magnetic alignment, matching the wall/fence flow).
|
||||
// and cursor positions below. Alt is force-place only (it does NOT bypass
|
||||
// snapping — 'off' mode is the no-snap bypass); the active snapping mode
|
||||
// governs whether alignment runs at all ('off' / 'angles' disable
|
||||
// magnetic alignment, 'lines' enables it, matching the wall/fence flow).
|
||||
const draft = draftNode.current
|
||||
let alignX = 0
|
||||
let alignZ = 0
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
resolveAlignmentForActiveBuilding,
|
||||
snapWorldXZForActiveBuilding,
|
||||
} from '../../../lib/world-grid-snap'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const DEFAULT_WALL_HEIGHT = 0.5
|
||||
@@ -187,7 +187,8 @@ export const RoofTool: React.FC = () => {
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
// that happen to sit on a grid line, so off-grid items (furniture, angled
|
||||
// walls) would never surface a guide. The matched axis locks exactly to the
|
||||
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
|
||||
// candidate's coordinate; the other axis keeps its grid snap. Alignment runs
|
||||
// only when the magnetic (lines) snapping mode is active.
|
||||
const alignPoint = (
|
||||
gridX: number,
|
||||
gridZ: number,
|
||||
@@ -241,21 +242,22 @@ export const RoofTool: React.FC = () => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid.
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const snapped: [number, number] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
// used to drag every roof corner off the visible grid. Snapping follows
|
||||
// the global mode (grid quantize / lines alignment); Off keeps the raw
|
||||
// cursor. Shift cycles the mode centrally — this tool never reads it.
|
||||
const snapped: [number, number] = isGridSnapActive()
|
||||
? snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
: [event.localPosition[0], event.localPosition[2]]
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
const y = event.localPosition[1]
|
||||
|
||||
@@ -265,7 +267,7 @@ export const RoofTool: React.FC = () => {
|
||||
cursorRef.current.position.set(gridX, gridY, gridZ)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
(isGridSnapActive() || isMagneticSnapActive()) &&
|
||||
corner1Ref.current &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
@@ -290,21 +292,21 @@ export const RoofTool: React.FC = () => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid.
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const snapped: [number, number] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
// used to drag every roof corner off the visible grid. Snapping follows
|
||||
// the global mode; Off keeps the raw cursor.
|
||||
const snapped: [number, number] = isGridSnapActive()
|
||||
? snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
: [event.localPosition[0], event.localPosition[2]]
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
const y = event.localPosition[1]
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveStairDestinationLevel,
|
||||
resolveStairPlacementLevelId,
|
||||
} from '../../../lib/stair-levels'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import {
|
||||
@@ -319,7 +320,8 @@ export const StairTool: React.FC = () => {
|
||||
// The probe is the RAW cursor, not the grid-snapped point: resolving
|
||||
// against the grid point would only catch anchors that happen to sit near
|
||||
// a grid line. Matched axes use the raw probe + snap delta; unmatched axes
|
||||
// keep the normal grid snap. Alt bypasses.
|
||||
// keep the normal grid snap. Alignment runs only when the magnetic (lines)
|
||||
// snapping mode is active.
|
||||
const alignPoint = (
|
||||
gridX: number,
|
||||
gridZ: number,
|
||||
@@ -348,20 +350,26 @@ export const StairTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
// Grid snap follows the global mode (live step so the HUD chip is
|
||||
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[0] / step) * step
|
||||
: event.localPosition[0],
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[2] / step) * step
|
||||
: event.localPosition[2],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
const position: [number, number, number] = [gridX, 0, gridZ]
|
||||
lastCanonicalPositionRef.current = position
|
||||
applyDraftPreview(position, rotationRef.current)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
(isGridSnapActive() || isMagneticSnapActive()) &&
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
@@ -372,13 +380,17 @@ export const StairTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
|
||||
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[0] / step) * step
|
||||
: event.localPosition[0],
|
||||
isGridSnapActive()
|
||||
? Math.round(event.localPosition[2] / step) * step
|
||||
: event.localPosition[2],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
event.nativeEvent?.altKey === true || bypassSnap,
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
return [gridX, 0, gridZ]
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export function HelperManager() {
|
||||
mode,
|
||||
tool,
|
||||
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
|
||||
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
|
||||
}),
|
||||
[scope, mode, tool],
|
||||
)
|
||||
|
||||
@@ -114,4 +114,25 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
||||
expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull()
|
||||
expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
|
||||
})
|
||||
|
||||
it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => {
|
||||
// Roof / stair / elevator are placed as footprints, not directional draws →
|
||||
// declared `snapDraftDirectional: false`, so their draft context drops the
|
||||
// angle-lock mode. Directional structural kinds (no flag) stay `wall`.
|
||||
const draftDirectionalOf = (t: string) => (t === 'roof' ? false : true)
|
||||
const draftCtx = (tool: string) =>
|
||||
snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf })
|
||||
expect(draftCtx('roof')).toBe('polygon')
|
||||
expect(draftCtx('wall')).toBe('wall')
|
||||
// Also via the explicit `drafting` scope path.
|
||||
expect(
|
||||
snapContextOf({
|
||||
scope: { kind: 'drafting', tool: 'roof' },
|
||||
mode: 'build',
|
||||
tool: 'roof',
|
||||
profileOf,
|
||||
draftDirectionalOf,
|
||||
}),
|
||||
).toBe('polygon')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,8 +137,12 @@ export function snapContextOf(args: {
|
||||
mode: string
|
||||
tool: string | null
|
||||
profileOf: (typeOrTool: string) => SnapProfile | undefined
|
||||
// Whether drafting a kind sets a direction (angle-lock meaningful). Injected
|
||||
// like `profileOf` so `snapping-mode` need not import the registry; defaults
|
||||
// to `true` (the structural draw default) when not supplied.
|
||||
draftDirectionalOf?: (typeOrTool: string) => boolean
|
||||
}): SnapContext | null {
|
||||
const { scope, mode, tool, profileOf } = args
|
||||
const { scope, mode, tool, profileOf, draftDirectionalOf } = args
|
||||
switch (scope.kind) {
|
||||
case 'placing':
|
||||
case 'moving':
|
||||
@@ -150,8 +154,12 @@ export function snapContextOf(args: {
|
||||
// — they use the no-angle 'polygon' set (grid / lines / off).
|
||||
return scope.reshape === 'endpoint' ? 'wall' : 'polygon'
|
||||
case 'drafting':
|
||||
return scope.tool ? contextForProfile(profileOf(scope.tool), true) : null
|
||||
return scope.tool
|
||||
? contextForProfile(profileOf(scope.tool), draftDirectionalOf?.(scope.tool) ?? true)
|
||||
: null
|
||||
default:
|
||||
return mode === 'build' && tool ? contextForProfile(profileOf(tool), true) : null
|
||||
return mode === 'build' && tool
|
||||
? contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true)
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,6 +1191,7 @@ export function getActiveSnapContext(): SnapContext | null {
|
||||
mode: editor.mode,
|
||||
tool: editor.tool,
|
||||
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
|
||||
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,16 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: ElevatorNode,
|
||||
category: 'structure',
|
||||
snapProfile: 'structural',
|
||||
// Placed as a footprint (R/T rotates), not a directional draw → no angle-lock
|
||||
// mode. The toolHints presence routes it through the contextual HUD so the
|
||||
// snapping chip shows during placement.
|
||||
snapDraftDirectional: false,
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place elevator' },
|
||||
{ key: 'R / T', label: 'Rotate' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
surfaceRole: 'joinery',
|
||||
|
||||
defaults: () => {
|
||||
|
||||
@@ -94,6 +94,9 @@ const roofHandles: HandleDescriptor<RoofNodeType>[] = [roofMoveHandle()]
|
||||
export const roofDefinition: NodeDefinition<typeof RoofNode> = {
|
||||
kind: 'roof',
|
||||
snapProfile: 'structural',
|
||||
// Drafted as a 2-corner footprint (axis-aligned bbox), not a directional
|
||||
// edge → no angle-lock mode (grid / lines / off only).
|
||||
snapDraftDirectional: false,
|
||||
schemaVersion: 1,
|
||||
schema: RoofNode,
|
||||
category: 'structure',
|
||||
|
||||
@@ -421,6 +421,16 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: StairNode,
|
||||
category: 'structure',
|
||||
snapProfile: 'structural',
|
||||
// Placed as a footprint (R/T rotates), not a directional draw → no angle-lock
|
||||
// mode. The toolHints presence routes it through the contextual HUD so the
|
||||
// snapping chip shows during placement.
|
||||
snapDraftDirectional: false,
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place stairs' },
|
||||
{ key: 'R / T', label: 'Rotate' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
surfaceRole: 'joinery',
|
||||
|
||||
defaults: () => {
|
||||
|
||||
Reference in New Issue
Block a user