Refine editor layer boundaries and selection handling
This commit is contained in:
@@ -18,6 +18,25 @@ export const DoorSegment = z.object({
|
||||
|
||||
export type DoorSegment = z.infer<typeof DoorSegment>
|
||||
|
||||
export const DoorCategory = z.enum(['interior', 'garage'])
|
||||
export const DoorType = z.enum([
|
||||
'hinged',
|
||||
'double',
|
||||
'french',
|
||||
'folding',
|
||||
'pocket',
|
||||
'barn',
|
||||
'sliding',
|
||||
'garage-sectional',
|
||||
'garage-rollup',
|
||||
'garage-tiltup',
|
||||
])
|
||||
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
|
||||
|
||||
export type DoorCategory = z.infer<typeof DoorCategory>
|
||||
export type DoorType = z.infer<typeof DoorType>
|
||||
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
|
||||
|
||||
export const DoorNode = BaseNode.extend({
|
||||
id: objectId('door'),
|
||||
type: nodeType('door'),
|
||||
@@ -32,6 +51,15 @@ export const DoorNode = BaseNode.extend({
|
||||
width: z.number().default(0.9),
|
||||
height: z.number().default(2.1),
|
||||
|
||||
// Door family
|
||||
doorCategory: DoorCategory.default('interior'),
|
||||
doorType: DoorType.default('hinged'),
|
||||
leafCount: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(1),
|
||||
operationState: z.number().min(0).max(1).default(0),
|
||||
slideDirection: z.enum(['left', 'right']).default('left'),
|
||||
trackStyle: DoorTrackStyle.default('none'),
|
||||
garagePanelCount: z.number().int().min(1).max(12).default(4),
|
||||
|
||||
// Opening mode
|
||||
openingKind: z.enum(['door', 'opening']).default('door'),
|
||||
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
|
||||
@@ -90,6 +118,7 @@ export const DoorNode = BaseNode.extend({
|
||||
panicBarHeight: z.number().default(1.0),
|
||||
}).describe(dedent`Door node - a parametric door placed on a wall
|
||||
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
||||
- doorCategory/doorType: explicit operation family, defaulting old doors to interior hinged
|
||||
- openingKind/openingShape: hinged door or frameless wall opening shape
|
||||
- segments: rows stacked top to bottom, each defining its own columnRatios
|
||||
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
|
||||
|
||||
@@ -56,6 +56,17 @@ const resolvePlacedSpawnNode = (
|
||||
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
|
||||
}
|
||||
|
||||
function updateDoorOpenState(
|
||||
doorId: AnyNodeId,
|
||||
data: { operationState?: number; swingAngle?: number },
|
||||
) {
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[doorId]
|
||||
scene.updateNode(doorId, data)
|
||||
scene.dirtyNodes.add(doorId)
|
||||
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
export const FirstPersonControls = () => {
|
||||
const { camera, gl } = useThree()
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
@@ -151,10 +162,22 @@ export const FirstPersonControls = () => {
|
||||
const node = useScene.getState().nodes[doorId]
|
||||
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||
|
||||
if (
|
||||
node.doorType === 'folding' ||
|
||||
node.doorType === 'pocket' ||
|
||||
node.doorType === 'barn' ||
|
||||
node.doorType === 'sliding'
|
||||
) {
|
||||
const currentOpenAmount = node.operationState ?? 0
|
||||
updateDoorOpenState(doorId, {
|
||||
operationState: currentOpenAmount >= 0.5 ? 0 : 1,
|
||||
})
|
||||
} else {
|
||||
const currentSwingAngle = node.swingAngle ?? 0
|
||||
useScene.getState().updateNode(doorId, {
|
||||
updateDoorOpenState(doorId, {
|
||||
swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||
})
|
||||
}
|
||||
|
||||
requestAnimationFrame(rebuildColliderWorld)
|
||||
}, [rebuildColliderWorld, resolveInteractableDoorId])
|
||||
@@ -260,7 +283,7 @@ export const FirstPersonControls = () => {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
useEditor.getState().setFirstPersonMode(false)
|
||||
} else if (event.code === 'KeyE') {
|
||||
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleInteractableDoor()
|
||||
|
||||
@@ -4679,6 +4679,275 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
x: (svgP2.x + svgP3.x) / 2,
|
||||
y: (svgP2.y + svgP3.y) / 2,
|
||||
}
|
||||
const isFoldingDoor = opening.doorType === 'folding'
|
||||
const foldingPanelCount = opening.leafCount === 2 ? 2 : 4
|
||||
const foldingAmount = Math.max(0, Math.min(1, opening.operationState ?? 0))
|
||||
const foldingSpan = Math.max(1e-6, Math.hypot(svgP2.x - svgP1.x, svgP2.y - svgP1.y))
|
||||
const foldingPanelLength = foldingSpan / foldingPanelCount
|
||||
const foldingAngle = Math.PI * 0.44 * foldingAmount
|
||||
const foldingPoints = isFoldingDoor
|
||||
? Array.from({ length: foldingPanelCount + 1 }).reduce<Point2D[]>(
|
||||
(points, _, index) => {
|
||||
if (index === 0) return [{ x: svgP1.x, y: svgP1.y }]
|
||||
|
||||
const previous = points[index - 1]!
|
||||
const direction = (index - 1) % 2 === 0 ? -1 : 1
|
||||
const angle = direction * foldingAngle
|
||||
const along = Math.cos(angle) * foldingPanelLength
|
||||
const out = Math.sin(angle) * foldingPanelLength * swingSign
|
||||
points.push({
|
||||
x: previous.x + nx * along + px * out,
|
||||
y: previous.y + ny * along + py * out,
|
||||
})
|
||||
return points
|
||||
},
|
||||
[],
|
||||
)
|
||||
: []
|
||||
const foldingPath =
|
||||
foldingPoints.length > 0
|
||||
? foldingPoints
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||
.join(' ')
|
||||
: null
|
||||
const isPocketDoor = opening.doorType === 'pocket'
|
||||
const pocketAmount = Math.max(0, Math.min(1, opening.operationState ?? 0))
|
||||
const pocketSign = opening.slideDirection === 'right' ? 1 : -1
|
||||
const pocketShift = pocketSign * foldingSpan * pocketAmount
|
||||
const pocketTrackStart =
|
||||
pocketSign > 0
|
||||
? svgP1
|
||||
: { x: svgP1.x - nx * foldingSpan, y: svgP1.y - ny * foldingSpan }
|
||||
const pocketTrackEnd =
|
||||
pocketSign > 0
|
||||
? { x: svgP2.x + nx * foldingSpan, y: svgP2.y + ny * foldingSpan }
|
||||
: svgP2
|
||||
const pocketLeafStart = {
|
||||
x: svgP1.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5,
|
||||
y: svgP1.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5,
|
||||
}
|
||||
const pocketLeafEnd = {
|
||||
x: svgP2.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5,
|
||||
y: svgP2.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5,
|
||||
}
|
||||
const pocketLeafPoints = [
|
||||
{
|
||||
x: pocketLeafStart.x - px * leafHalfThickness,
|
||||
y: pocketLeafStart.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: pocketLeafEnd.x - px * leafHalfThickness,
|
||||
y: pocketLeafEnd.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: pocketLeafEnd.x + px * leafHalfThickness,
|
||||
y: pocketLeafEnd.y + py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: pocketLeafStart.x + px * leafHalfThickness,
|
||||
y: pocketLeafStart.y + py * leafHalfThickness,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
const isBarnDoor = opening.doorType === 'barn'
|
||||
const barnLeafStart = {
|
||||
x: pocketLeafStart.x + px * swingSign * doorCubeSize * 0.75,
|
||||
y: pocketLeafStart.y + py * swingSign * doorCubeSize * 0.75,
|
||||
}
|
||||
const barnLeafEnd = {
|
||||
x: pocketLeafEnd.x + px * swingSign * doorCubeSize * 0.75,
|
||||
y: pocketLeafEnd.y + py * swingSign * doorCubeSize * 0.75,
|
||||
}
|
||||
const barnLeafPoints = [
|
||||
{
|
||||
x: barnLeafStart.x - px * leafHalfThickness,
|
||||
y: barnLeafStart.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: barnLeafEnd.x - px * leafHalfThickness,
|
||||
y: barnLeafEnd.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: barnLeafEnd.x + px * leafHalfThickness,
|
||||
y: barnLeafEnd.y + py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: barnLeafStart.x + px * leafHalfThickness,
|
||||
y: barnLeafStart.y + py * leafHalfThickness,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
const isSlidingDoor = opening.doorType === 'sliding'
|
||||
const slidingPanelSpan = foldingSpan * 0.54
|
||||
const slidingActiveOnRight = opening.slideDirection !== 'right'
|
||||
const slidingFixedSign = slidingActiveOnRight ? -1 : 1
|
||||
const slidingActiveSign = slidingActiveOnRight ? 1 : -1
|
||||
const slidingFixedCenter = slidingFixedSign * foldingSpan * 0.23
|
||||
const slidingActiveCenter =
|
||||
slidingActiveSign * foldingSpan * 0.23 -
|
||||
slidingActiveSign * foldingSpan * 0.44 * pocketAmount
|
||||
const slidingPanelPoints = (centerOffset: number, faceOffset: number) => {
|
||||
const start = {
|
||||
x:
|
||||
svgP1.x +
|
||||
nx * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) +
|
||||
px * swingSign * faceOffset,
|
||||
y:
|
||||
svgP1.y +
|
||||
ny * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) +
|
||||
py * swingSign * faceOffset,
|
||||
}
|
||||
const end = {
|
||||
x:
|
||||
svgP1.x +
|
||||
nx * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) +
|
||||
px * swingSign * faceOffset,
|
||||
y:
|
||||
svgP1.y +
|
||||
ny * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) +
|
||||
py * swingSign * faceOffset,
|
||||
}
|
||||
return [
|
||||
{ x: start.x - px * leafHalfThickness, y: start.y - py * leafHalfThickness },
|
||||
{ x: end.x - px * leafHalfThickness, y: end.y - py * leafHalfThickness },
|
||||
{ x: end.x + px * leafHalfThickness, y: end.y + py * leafHalfThickness },
|
||||
{ x: start.x + px * leafHalfThickness, y: start.y + py * leafHalfThickness },
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
}
|
||||
const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34)
|
||||
const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68)
|
||||
const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french'
|
||||
const doubleLeafPlans = isDoubleSwingDoor
|
||||
? (
|
||||
[
|
||||
{
|
||||
key: 'left',
|
||||
hingePoint: { x: cx - nx * (width / 2), y: cy - ny * (width / 2) },
|
||||
strikePoint: { x: cx, y: cy },
|
||||
},
|
||||
{
|
||||
key: 'right',
|
||||
hingePoint: { x: cx + nx * (width / 2), y: cy + ny * (width / 2) },
|
||||
strikePoint: { x: cx, y: cy },
|
||||
},
|
||||
] as const
|
||||
).map(({ key, hingePoint, strikePoint }) => {
|
||||
const tangentSign = key === 'left' ? 1 : -1
|
||||
const planHingeCubeCenter = {
|
||||
x: hingePoint.x + nx * tangentSign * doorCubeInset,
|
||||
y: hingePoint.y + ny * tangentSign * doorCubeInset,
|
||||
}
|
||||
const planStrikeCubeCenter = {
|
||||
x: strikePoint.x - nx * tangentSign * doorCubeInset,
|
||||
y: strikePoint.y - ny * tangentSign * doorCubeInset,
|
||||
}
|
||||
const planLeafStart = {
|
||||
x:
|
||||
planHingeCubeCenter.x +
|
||||
px * swingSign * (doorCubeSize / 2) +
|
||||
nx * tangentSign * (doorCubeSize / 2 + leafHalfThickness),
|
||||
y:
|
||||
planHingeCubeCenter.y +
|
||||
py * swingSign * (doorCubeSize / 2) +
|
||||
ny * tangentSign * (doorCubeSize / 2 + leafHalfThickness),
|
||||
}
|
||||
const planArcEnd = {
|
||||
x:
|
||||
planStrikeCubeCenter.x +
|
||||
px * swingSign * (doorCubeSize / 2) -
|
||||
nx * tangentSign * (doorCubeSize / 2),
|
||||
y:
|
||||
planStrikeCubeCenter.y +
|
||||
py * swingSign * (doorCubeSize / 2) -
|
||||
ny * tangentSign * (doorCubeSize / 2),
|
||||
}
|
||||
const planSwingRadius = Math.hypot(
|
||||
planArcEnd.x - planLeafStart.x,
|
||||
planArcEnd.y - planLeafStart.y,
|
||||
)
|
||||
const planClosedLeafVector = {
|
||||
x: planArcEnd.x - planLeafStart.x,
|
||||
y: planArcEnd.y - planLeafStart.y,
|
||||
}
|
||||
const planOpenAngle = swingAngle * swingSign * tangentSign
|
||||
const planOpenCos = Math.cos(planOpenAngle)
|
||||
const planOpenSin = Math.sin(planOpenAngle)
|
||||
const planLeafEnd = {
|
||||
x:
|
||||
planLeafStart.x +
|
||||
planClosedLeafVector.x * planOpenCos -
|
||||
planClosedLeafVector.y * planOpenSin,
|
||||
y:
|
||||
planLeafStart.y +
|
||||
planClosedLeafVector.x * planOpenSin +
|
||||
planClosedLeafVector.y * planOpenCos,
|
||||
}
|
||||
const planSweepFlag =
|
||||
key === 'left'
|
||||
? swingDirection === 'inward'
|
||||
? 0
|
||||
: 1
|
||||
: swingDirection === 'inward'
|
||||
? 1
|
||||
: 0
|
||||
|
||||
return {
|
||||
key,
|
||||
hingeCubeCenter: planHingeCubeCenter,
|
||||
strikeCubeCenter: planStrikeCubeCenter,
|
||||
hingeMarkerX: planHingeCubeCenter.x,
|
||||
hingeMarkerY: planHingeCubeCenter.y,
|
||||
swingRadius: planSwingRadius,
|
||||
sweepFlag: planSweepFlag,
|
||||
arcEnd: planArcEnd,
|
||||
leafEnd: planLeafEnd,
|
||||
leafPolygonPoints: [
|
||||
{
|
||||
x: planLeafStart.x - nx * leafHalfThickness,
|
||||
y: planLeafStart.y - ny * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: planLeafEnd.x - nx * leafHalfThickness,
|
||||
y: planLeafEnd.y - ny * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: planLeafEnd.x + nx * leafHalfThickness,
|
||||
y: planLeafEnd.y + ny * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: planLeafStart.x + nx * leafHalfThickness,
|
||||
y: planLeafStart.y + ny * leafHalfThickness,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' '),
|
||||
closedLeafHintPoints: [
|
||||
{
|
||||
x: planLeafStart.x - nx * leafHalfThickness * 0.7,
|
||||
y: planLeafStart.y - ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
{
|
||||
x: planArcEnd.x - nx * leafHalfThickness * 0.7,
|
||||
y: planArcEnd.y - ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
{
|
||||
x: planArcEnd.x + nx * leafHalfThickness * 0.7,
|
||||
y: planArcEnd.y + ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
{
|
||||
x: planLeafStart.x + nx * leafHalfThickness * 0.7,
|
||||
y: planLeafStart.y + ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' '),
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
return (
|
||||
<g
|
||||
@@ -4779,6 +5048,239 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
points={doorBackgroundPoints}
|
||||
stroke="none"
|
||||
/>
|
||||
{isFoldingDoor ? (
|
||||
<>
|
||||
<path
|
||||
d={`M ${svgP1.x} ${svgP1.y} L ${svgP2.x} ${svgP2.y}`}
|
||||
fill="none"
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{foldingPath && (
|
||||
<path
|
||||
d={foldingPath}
|
||||
fill="none"
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{foldingPoints.map((point, index) => (
|
||||
<circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
fill={
|
||||
index === 0 || index === foldingPoints.length - 1
|
||||
? doorStroke
|
||||
: doorLeafFill
|
||||
}
|
||||
key={`${opening.id}:folding-node:${index}`}
|
||||
r={
|
||||
index === 0 || index === foldingPoints.length - 1
|
||||
? hingeMarkerRadius
|
||||
: hingeMarkerRadius * 0.72
|
||||
}
|
||||
stroke={doorStroke}
|
||||
strokeWidth="0.8"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : isPocketDoor ? (
|
||||
<>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeDasharray="0.09 0.06"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={pocketTrackStart.x}
|
||||
x2={pocketTrackEnd.x}
|
||||
y1={pocketTrackStart.y}
|
||||
y2={pocketTrackEnd.y}
|
||||
/>
|
||||
<line
|
||||
stroke={doorStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y}
|
||||
y2={svgP2.y}
|
||||
/>
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
points={pocketLeafPoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={pocketLeafEnd.x}
|
||||
cy={pocketLeafEnd.y}
|
||||
fill={doorStroke}
|
||||
r={hingeMarkerRadius * 0.72}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
) : isBarnDoor ? (
|
||||
<>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={pocketTrackStart.x}
|
||||
x2={pocketTrackEnd.x}
|
||||
y1={pocketTrackStart.y + py * swingSign * doorCubeSize * 1.15}
|
||||
y2={pocketTrackEnd.y + py * swingSign * doorCubeSize * 1.15}
|
||||
/>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.9"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y}
|
||||
y2={svgP2.y}
|
||||
/>
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
points={barnLeafPoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{[0.28, 0.72].map((ratio) => {
|
||||
const wheel = {
|
||||
x: barnLeafStart.x + (barnLeafEnd.x - barnLeafStart.x) * ratio,
|
||||
y: barnLeafStart.y + (barnLeafEnd.y - barnLeafStart.y) * ratio,
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
cx={wheel.x}
|
||||
cy={wheel.y}
|
||||
fill={doorStroke}
|
||||
key={`${opening.id}:barn-wheel:${ratio}`}
|
||||
r={hingeMarkerRadius * 0.62}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : isSlidingDoor ? (
|
||||
<>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y + py * swingSign * doorCubeSize * 0.34}
|
||||
y2={svgP2.y + py * swingSign * doorCubeSize * 0.34}
|
||||
/>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y + py * swingSign * doorCubeSize * 0.68}
|
||||
y2={svgP2.y + py * swingSign * doorCubeSize * 0.68}
|
||||
/>
|
||||
<polygon
|
||||
fill="rgba(224, 242, 254, 0.7)"
|
||||
points={slidingFixedPoints}
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
points={slidingActivePoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
) : isDoubleSwingDoor ? (
|
||||
<>
|
||||
{doubleLeafPlans.map((leaf) =>
|
||||
leaf.swingRadius > 1e-6 ? (
|
||||
<path
|
||||
d={`M ${leaf.arcEnd.x} ${leaf.arcEnd.y} A ${leaf.swingRadius} ${leaf.swingRadius} 0 0 ${leaf.sweepFlag} ${leaf.leafEnd.x} ${leaf.leafEnd.y}`}
|
||||
fill="none"
|
||||
key={`${opening.id}:double-sweep:${leaf.key}`}
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.9"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
{swingAngle > 0.03 &&
|
||||
doubleLeafPlans.map((leaf) => (
|
||||
<polygon
|
||||
fill="none"
|
||||
key={`${opening.id}:double-hint:${leaf.key}`}
|
||||
points={leaf.closedLeafHintPoints}
|
||||
stroke={doorSoftStroke}
|
||||
strokeDasharray="0.08 0.06"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.8"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{doubleLeafPlans.map((leaf) => (
|
||||
<rect
|
||||
fill={doorLeafFill}
|
||||
height={doorCubeSize}
|
||||
key={`${opening.id}:double-hinge:${leaf.key}`}
|
||||
rx={doorCubeSize * 0.12}
|
||||
stroke={doorStroke}
|
||||
strokeWidth="1.35"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={doorCubeSize}
|
||||
x={leaf.hingeCubeCenter.x - doorCubeSize / 2}
|
||||
y={leaf.hingeCubeCenter.y - doorCubeSize / 2}
|
||||
/>
|
||||
))}
|
||||
{doubleLeafPlans.map((leaf) => (
|
||||
<circle
|
||||
cx={leaf.hingeMarkerX}
|
||||
cy={leaf.hingeMarkerY}
|
||||
fill={doorStroke}
|
||||
key={`${opening.id}:double-hinge-marker:${leaf.key}`}
|
||||
r={hingeMarkerRadius}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{doubleLeafPlans.map((leaf) => (
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
key={`${opening.id}:double-leaf:${leaf.key}`}
|
||||
points={leaf.leafPolygonPoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{swingSweepPath && (
|
||||
<path
|
||||
d={swingSweepPath}
|
||||
@@ -4847,6 +5349,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSelected ? (
|
||||
<>
|
||||
<circle
|
||||
|
||||
@@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => {
|
||||
parentId: event.node.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
doorCategory: draft.doorCategory,
|
||||
doorType: draft.doorType,
|
||||
leafCount: draft.leafCount,
|
||||
operationState: draft.operationState,
|
||||
slideDirection: draft.slideDirection,
|
||||
trackStyle: draft.trackStyle,
|
||||
garagePanelCount: draft.garagePanelCount,
|
||||
frameThickness: draft.frameThickness,
|
||||
frameDepth: draft.frameDepth,
|
||||
threshold: draft.threshold,
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
@@ -22,6 +23,63 @@ import { ToggleControl } from '../controls/toggle-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PresetsPopover } from './presets/presets-popover'
|
||||
|
||||
const doorTypeOptions = [
|
||||
{ label: 'Hinged', value: 'hinged', available: true },
|
||||
{ label: 'Double', value: 'double', available: true },
|
||||
{ label: 'French', value: 'french', available: true },
|
||||
{ label: 'Folding', value: 'folding', available: true },
|
||||
{ label: 'Pocket', value: 'pocket', available: true },
|
||||
{ label: 'Barn', value: 'barn', available: true },
|
||||
{ label: 'Sliding', value: 'sliding', available: true },
|
||||
] satisfies {
|
||||
label: string
|
||||
value: DoorNode['doorType']
|
||||
available: boolean
|
||||
}[]
|
||||
|
||||
const frenchDoorSegments: DoorNode['segments'] = [
|
||||
{
|
||||
type: 'glass',
|
||||
heightRatio: 0.76,
|
||||
columnRatios: [1, 1],
|
||||
dividerThickness: 0.025,
|
||||
panelDepth: 0.01,
|
||||
panelInset: 0.04,
|
||||
},
|
||||
{
|
||||
type: 'panel',
|
||||
heightRatio: 0.24,
|
||||
columnRatios: [1],
|
||||
dividerThickness: 0.03,
|
||||
panelDepth: 0.012,
|
||||
panelInset: 0.035,
|
||||
},
|
||||
]
|
||||
|
||||
const foldingDoorSegments: DoorNode['segments'] = [
|
||||
{
|
||||
type: 'panel',
|
||||
heightRatio: 1,
|
||||
columnRatios: [1],
|
||||
dividerThickness: 0.02,
|
||||
panelDepth: 0.008,
|
||||
panelInset: 0.025,
|
||||
},
|
||||
]
|
||||
|
||||
const defaultDoorDimensions: Record<DoorNode['doorType'], { width: number; height: number }> = {
|
||||
hinged: { width: 0.9, height: 2.1 },
|
||||
double: { width: 1.5, height: 2.1 },
|
||||
french: { width: 1.5, height: 2.1 },
|
||||
folding: { width: 1.8, height: 2.1 },
|
||||
pocket: { width: 0.9, height: 2.1 },
|
||||
barn: { width: 1, height: 2.1 },
|
||||
sliding: { width: 1.5, height: 2.1 },
|
||||
'garage-sectional': { width: 2.7, height: 2.4 },
|
||||
'garage-rollup': { width: 2.7, height: 2.4 },
|
||||
'garage-tiltup': { width: 2.7, height: 2.4 },
|
||||
}
|
||||
|
||||
function isSameDoorValue(current: unknown, next: unknown): boolean {
|
||||
if (typeof current === 'number' && typeof next === 'number') {
|
||||
return Math.abs(current - next) < 1e-6
|
||||
@@ -195,6 +253,13 @@ export function DoorPanel() {
|
||||
const getDoorPresetData = useCallback(() => {
|
||||
if (!node) return null
|
||||
return {
|
||||
doorCategory: node.doorCategory,
|
||||
doorType: node.doorType,
|
||||
leafCount: node.leafCount,
|
||||
operationState: node.operationState,
|
||||
slideDirection: node.slideDirection,
|
||||
trackStyle: node.trackStyle,
|
||||
garagePanelCount: node.garagePanelCount,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
frameThickness: node.frameThickness,
|
||||
@@ -260,6 +325,10 @@ export function DoorPanel() {
|
||||
const archHeight = node.archHeight ?? 0.45
|
||||
const openingRevealRadius = node.openingRevealRadius ?? 0.025
|
||||
const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height))
|
||||
const doorType = node.doorType ?? 'hinged'
|
||||
const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french'
|
||||
const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding'
|
||||
const supportsHandleSide = isSwingDoor
|
||||
|
||||
const setOpeningTopRadius = (index: number, value: number, commit = false) => {
|
||||
const next = [...openingTopRadii] as [number, number]
|
||||
@@ -271,6 +340,101 @@ export function DoorPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial<DoorNode> => {
|
||||
const dimensions = defaultDoorDimensions[nextDoorType]
|
||||
const dimensionUpdates = {
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
position: [node.position[0], dimensions.height / 2, node.position[2]] as DoorNode['position'],
|
||||
}
|
||||
|
||||
if (nextDoorType === 'double' || nextDoorType === 'french') {
|
||||
return {
|
||||
doorCategory: 'interior',
|
||||
doorType: nextDoorType,
|
||||
leafCount: 2,
|
||||
...dimensionUpdates,
|
||||
handleSide: 'right',
|
||||
...(nextDoorType === 'french'
|
||||
? {
|
||||
contentPadding: [0.045, 0.055],
|
||||
segments: frenchDoorSegments,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (nextDoorType === 'folding') {
|
||||
return {
|
||||
doorCategory: 'interior',
|
||||
doorType: nextDoorType,
|
||||
leafCount: 4,
|
||||
...dimensionUpdates,
|
||||
handle: true,
|
||||
handleSide: 'right',
|
||||
trackStyle: 'visible',
|
||||
operationState: Math.max(node.operationState ?? 0, 0.65),
|
||||
contentPadding: [0.03, 0.04],
|
||||
segments: foldingDoorSegments,
|
||||
}
|
||||
}
|
||||
|
||||
if (nextDoorType === 'pocket') {
|
||||
return {
|
||||
doorCategory: 'interior',
|
||||
doorType: nextDoorType,
|
||||
leafCount: 1,
|
||||
...dimensionUpdates,
|
||||
handle: true,
|
||||
handleSide: 'right',
|
||||
trackStyle: 'pocket',
|
||||
slideDirection: node.slideDirection ?? 'left',
|
||||
operationState: node.operationState ?? 0,
|
||||
contentPadding: [0.035, 0.045],
|
||||
segments: foldingDoorSegments,
|
||||
}
|
||||
}
|
||||
|
||||
if (nextDoorType === 'barn') {
|
||||
return {
|
||||
doorCategory: 'interior',
|
||||
doorType: nextDoorType,
|
||||
leafCount: 1,
|
||||
...dimensionUpdates,
|
||||
handle: true,
|
||||
handleSide: 'right',
|
||||
trackStyle: 'visible',
|
||||
slideDirection: node.slideDirection ?? 'left',
|
||||
operationState: node.operationState ?? 0,
|
||||
contentPadding: [0.035, 0.045],
|
||||
segments: foldingDoorSegments,
|
||||
}
|
||||
}
|
||||
|
||||
if (nextDoorType === 'sliding') {
|
||||
return {
|
||||
doorCategory: 'interior',
|
||||
doorType: nextDoorType,
|
||||
leafCount: 2,
|
||||
...dimensionUpdates,
|
||||
handle: true,
|
||||
handleSide: 'right',
|
||||
trackStyle: 'visible',
|
||||
slideDirection: node.slideDirection ?? 'left',
|
||||
operationState: node.operationState ?? 0,
|
||||
contentPadding: [0.03, 0.04],
|
||||
segments: frenchDoorSegments,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
doorCategory: 'interior',
|
||||
doorType: nextDoorType,
|
||||
leafCount: 1,
|
||||
...dimensionUpdates,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/door.png"
|
||||
@@ -324,6 +488,31 @@ export function DoorPanel() {
|
||||
value={node.openingKind}
|
||||
/>
|
||||
</div>
|
||||
{!isOpening && (
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
{doorTypeOptions.map((option) => {
|
||||
const isSelected = doorType === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center gap-2 rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
!option.available && 'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
|
||||
)}
|
||||
disabled={!option.available}
|
||||
key={option.value}
|
||||
onClick={() => handleUpdate(getDoorTypeUpdates(option.value))}
|
||||
type="button"
|
||||
>
|
||||
<DoorOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
@@ -353,6 +542,68 @@ export function DoorPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
{doorType === 'folding' && !isOpening && (
|
||||
<PanelSection title="Fold">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Panels
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ leafCount: v === '2' ? 2 : 4 })}
|
||||
options={[
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '4', value: '4' },
|
||||
]}
|
||||
value={node.leafCount === 2 ? '2' : '4'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Open"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ operationState: v / 100 })}
|
||||
precision={0}
|
||||
restoreOnCommit={false}
|
||||
step={5}
|
||||
unit="%"
|
||||
value={Math.round((node.operationState ?? 0) * 100)}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSlidingDoor && !isOpening && (
|
||||
<PanelSection title="Slide">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
{doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ slideDirection: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
value={node.slideDirection ?? 'left'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Open"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ operationState: v / 100 })}
|
||||
precision={0}
|
||||
restoreOnCommit={false}
|
||||
step={5}
|
||||
unit="%"
|
||||
value={Math.round((node.operationState ?? 0) * 100)}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
@@ -524,6 +775,7 @@ export function DoorPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Swing">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<div className="space-y-1">
|
||||
@@ -554,7 +806,9 @@ export function DoorPanel() {
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Threshold">
|
||||
<ToggleControl
|
||||
checked={node.threshold}
|
||||
@@ -576,14 +830,17 @@ export function DoorPanel() {
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Handle">
|
||||
{isSwingDoor && (
|
||||
<ToggleControl
|
||||
checked={node.handle}
|
||||
label="Enable Handle"
|
||||
onChange={(checked) => handleUpdate({ handle: checked })}
|
||||
/>
|
||||
{node.handle && (
|
||||
)}
|
||||
{(node.handle || !isSwingDoor) && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
@@ -595,6 +852,7 @@ export function DoorPanel() {
|
||||
unit="m"
|
||||
value={Math.round(node.handleHeight * 100) / 100}
|
||||
/>
|
||||
{supportsHandleSide && (
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Handle Side
|
||||
@@ -608,10 +866,12 @@ export function DoorPanel() {
|
||||
value={node.handleSide}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Hardware">
|
||||
<ToggleControl
|
||||
checked={node.doorCloser}
|
||||
@@ -638,6 +898,7 @@ export function DoorPanel() {
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Segments">
|
||||
{node.segments.map((seg, i) => {
|
||||
|
||||
+2
-2
@@ -88,8 +88,8 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
title: 'Item Placement',
|
||||
shortcuts: [
|
||||
{ keys: ['R'], action: 'Rotate item clockwise by 90 degrees' },
|
||||
{ keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' },
|
||||
{ keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
|
||||
{ keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
|
||||
{
|
||||
keys: ['Shift'],
|
||||
action: 'Temporarily bypass placement validation constraints',
|
||||
|
||||
@@ -7,6 +7,17 @@ import useEditor from '../store/use-editor'
|
||||
|
||||
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
||||
|
||||
function updateDoorOpenState(
|
||||
doorId: AnyNodeId,
|
||||
data: { operationState?: number; swingAngle?: number },
|
||||
) {
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[doorId]
|
||||
scene.updateNode(doorId, data)
|
||||
scene.dirtyNodes.add(doorId)
|
||||
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
||||
// so that the global Escape handler knows not to also switch to select mode.
|
||||
let _toolCancelConsumed = false
|
||||
@@ -154,11 +165,23 @@ export const useKeyboard = ({
|
||||
if (node?.type === 'door') {
|
||||
e.preventDefault()
|
||||
if (node.openingKind !== 'opening') {
|
||||
if (
|
||||
node.doorType === 'folding' ||
|
||||
node.doorType === 'pocket' ||
|
||||
node.doorType === 'barn' ||
|
||||
node.doorType === 'sliding'
|
||||
) {
|
||||
const currentOpenAmount = node.operationState ?? 0
|
||||
updateDoorOpenState(node.id, {
|
||||
operationState: currentOpenAmount >= 0.5 ? 0 : 1,
|
||||
})
|
||||
} else {
|
||||
const currentSwingAngle = node.swingAngle ?? 0
|
||||
useScene.getState().updateNode(node.id, {
|
||||
updateDoorOpenState(node.id, {
|
||||
swingAngle:
|
||||
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||
})
|
||||
}
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
} else if (node && 'rotation' in node) {
|
||||
@@ -184,7 +207,15 @@ export const useKeyboard = ({
|
||||
if (node?.type === 'door') {
|
||||
e.preventDefault()
|
||||
if (node.openingKind !== 'opening') {
|
||||
useScene.getState().updateNode(node.id, { swingAngle: 0 })
|
||||
updateDoorOpenState(
|
||||
node.id,
|
||||
node.doorType === 'folding' ||
|
||||
node.doorType === 'pocket' ||
|
||||
node.doorType === 'barn' ||
|
||||
node.doorType === 'sliding'
|
||||
? { operationState: 0 }
|
||||
: { swingAngle: 0 },
|
||||
)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
} else if (node && 'rotation' in node) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user