Add garage door types and animated door controls
This commit is contained in:
@@ -166,7 +166,10 @@ export const FirstPersonControls = () => {
|
|||||||
node.doorType === 'folding' ||
|
node.doorType === 'folding' ||
|
||||||
node.doorType === 'pocket' ||
|
node.doorType === 'pocket' ||
|
||||||
node.doorType === 'barn' ||
|
node.doorType === 'barn' ||
|
||||||
node.doorType === 'sliding'
|
node.doorType === 'sliding' ||
|
||||||
|
node.doorType === 'garage-sectional' ||
|
||||||
|
node.doorType === 'garage-rollup' ||
|
||||||
|
node.doorType === 'garage-tiltup'
|
||||||
) {
|
) {
|
||||||
const currentOpenAmount = node.operationState ?? 0
|
const currentOpenAmount = node.operationState ?? 0
|
||||||
updateDoorOpenState(doorId, {
|
updateDoorOpenState(doorId, {
|
||||||
|
|||||||
@@ -4820,6 +4820,23 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
}
|
}
|
||||||
const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34)
|
const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34)
|
||||||
const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68)
|
const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68)
|
||||||
|
const isGarageSectionalDoor = opening.doorType === 'garage-sectional'
|
||||||
|
const isGarageRollupDoor = opening.doorType === 'garage-rollup'
|
||||||
|
const isGarageTiltupDoor = opening.doorType === 'garage-tiltup'
|
||||||
|
const garagePanelCount = Math.max(3, Math.min(12, opening.garagePanelCount ?? 4))
|
||||||
|
const garagePanelLines = Array.from({ length: garagePanelCount - 1 }, (_, index) => {
|
||||||
|
const t = (index + 1) / garagePanelCount
|
||||||
|
return {
|
||||||
|
start: {
|
||||||
|
x: svgP1.x + (svgP2.x - svgP1.x) * t,
|
||||||
|
y: svgP1.y + (svgP2.y - svgP1.y) * t,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
x: svgP1.x + (svgP2.x - svgP1.x) * t + px * swingSign * doorCubeSize * 0.78,
|
||||||
|
y: svgP1.y + (svgP2.y - svgP1.y) * t + py * swingSign * doorCubeSize * 0.78,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french'
|
const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french'
|
||||||
const doubleLeafPlans = isDoubleSwingDoor
|
const doubleLeafPlans = isDoubleSwingDoor
|
||||||
? (
|
? (
|
||||||
@@ -5215,6 +5232,66 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : isGarageSectionalDoor || isGarageRollupDoor || isGarageTiltupDoor ? (
|
||||||
|
<>
|
||||||
|
<line
|
||||||
|
stroke={doorStroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
x1={svgP1.x}
|
||||||
|
x2={svgP2.x}
|
||||||
|
y1={svgP1.y}
|
||||||
|
y2={svgP2.y}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
stroke={doorSoftStroke}
|
||||||
|
strokeDasharray="0.12 0.08"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
x1={svgP1.x + px * swingSign * doorCubeSize * 0.78}
|
||||||
|
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
|
||||||
|
y1={svgP1.y + py * swingSign * doorCubeSize * 0.78}
|
||||||
|
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
|
||||||
|
/>
|
||||||
|
{isGarageRollupDoor ? (
|
||||||
|
<circle
|
||||||
|
cx={(svgP1.x + svgP2.x) / 2 + px * swingSign * doorCubeSize * 0.78}
|
||||||
|
cy={(svgP1.y + svgP2.y) / 2 + py * swingSign * doorCubeSize * 0.78}
|
||||||
|
fill="none"
|
||||||
|
r={doorCubeSize * 0.22}
|
||||||
|
stroke={doorSoftStroke}
|
||||||
|
strokeWidth="0.9"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
) : isGarageTiltupDoor ? (
|
||||||
|
<line
|
||||||
|
stroke={doorSoftStroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
x1={svgP1.x + px * swingSign * doorCubeSize * 0.18}
|
||||||
|
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
|
||||||
|
y1={svgP1.y + py * swingSign * doorCubeSize * 0.18}
|
||||||
|
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
garagePanelLines.map((line, index) => (
|
||||||
|
<line
|
||||||
|
key={`${opening.id}:garage-section:${index}`}
|
||||||
|
stroke={doorSoftStroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="0.8"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
x1={line.start.x}
|
||||||
|
x2={line.end.x}
|
||||||
|
y1={line.start.y}
|
||||||
|
y2={line.end.y}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : isDoubleSwingDoor ? (
|
) : isDoubleSwingDoor ? (
|
||||||
<>
|
<>
|
||||||
{doubleLeafPlans.map((leaf) =>
|
{doubleLeafPlans.map((leaf) =>
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ const doorTypeOptions = [
|
|||||||
available: boolean
|
available: boolean
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
|
const garageDoorTypeOptions = [
|
||||||
|
{ label: 'Sectional', value: 'garage-sectional', available: true },
|
||||||
|
{ label: 'Roll-up', value: 'garage-rollup', available: true },
|
||||||
|
{ label: 'Tilt-up', value: 'garage-tiltup', available: true },
|
||||||
|
] satisfies {
|
||||||
|
label: string
|
||||||
|
value: DoorNode['doorType']
|
||||||
|
available: boolean
|
||||||
|
}[]
|
||||||
|
|
||||||
const frenchDoorSegments: DoorNode['segments'] = [
|
const frenchDoorSegments: DoorNode['segments'] = [
|
||||||
{
|
{
|
||||||
type: 'glass',
|
type: 'glass',
|
||||||
@@ -328,7 +338,13 @@ export function DoorPanel() {
|
|||||||
const doorType = node.doorType ?? 'hinged'
|
const doorType = node.doorType ?? 'hinged'
|
||||||
const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french'
|
const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french'
|
||||||
const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding'
|
const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding'
|
||||||
|
const isGarageDoor = node.doorCategory === 'garage' || doorType.startsWith('garage-')
|
||||||
|
const isSectionalGarageDoor = doorType === 'garage-sectional'
|
||||||
|
const isRollupGarageDoor = doorType === 'garage-rollup'
|
||||||
|
const isTiltupGarageDoor = doorType === 'garage-tiltup'
|
||||||
|
const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door'
|
||||||
const supportsHandleSide = isSwingDoor
|
const supportsHandleSide = isSwingDoor
|
||||||
|
const maxDoorWidth = isGarageDoor ? 6 : 3
|
||||||
|
|
||||||
const setOpeningTopRadius = (index: number, value: number, commit = false) => {
|
const setOpeningTopRadius = (index: number, value: number, commit = false) => {
|
||||||
const next = [...openingTopRadii] as [number, number]
|
const next = [...openingTopRadii] as [number, number]
|
||||||
@@ -427,11 +443,60 @@ export function DoorPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextDoorType === 'garage-sectional') {
|
||||||
|
return {
|
||||||
|
doorCategory: 'garage',
|
||||||
|
doorType: nextDoorType,
|
||||||
|
leafCount: 1,
|
||||||
|
...dimensionUpdates,
|
||||||
|
handle: false,
|
||||||
|
threshold: false,
|
||||||
|
trackStyle: 'overhead',
|
||||||
|
operationState: 0,
|
||||||
|
garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)),
|
||||||
|
contentPadding: [0.04, 0.04],
|
||||||
|
segments: foldingDoorSegments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextDoorType === 'garage-rollup') {
|
||||||
|
return {
|
||||||
|
doorCategory: 'garage',
|
||||||
|
doorType: nextDoorType,
|
||||||
|
leafCount: 1,
|
||||||
|
...dimensionUpdates,
|
||||||
|
handle: false,
|
||||||
|
threshold: false,
|
||||||
|
trackStyle: 'overhead',
|
||||||
|
operationState: 0,
|
||||||
|
garagePanelCount: 4,
|
||||||
|
contentPadding: [0.04, 0.04],
|
||||||
|
segments: foldingDoorSegments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextDoorType === 'garage-tiltup') {
|
||||||
|
return {
|
||||||
|
doorCategory: 'garage',
|
||||||
|
doorType: nextDoorType,
|
||||||
|
leafCount: 1,
|
||||||
|
...dimensionUpdates,
|
||||||
|
handle: false,
|
||||||
|
threshold: false,
|
||||||
|
trackStyle: 'overhead',
|
||||||
|
operationState: 0,
|
||||||
|
garagePanelCount: 4,
|
||||||
|
contentPadding: [0.04, 0.04],
|
||||||
|
segments: foldingDoorSegments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
doorCategory: 'interior',
|
doorCategory: 'interior',
|
||||||
doorType: nextDoorType,
|
doorType: nextDoorType,
|
||||||
leafCount: 1,
|
leafCount: 1,
|
||||||
...dimensionUpdates,
|
...dimensionUpdates,
|
||||||
|
threshold: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,19 +543,28 @@ export function DoorPanel() {
|
|||||||
archHeight,
|
archHeight,
|
||||||
openingRevealRadius,
|
openingRevealRadius,
|
||||||
}
|
}
|
||||||
: { openingKind: v },
|
: v === 'garage'
|
||||||
|
? {
|
||||||
|
openingKind: 'door',
|
||||||
|
...getDoorTypeUpdates(isGarageDoor ? doorType : 'garage-sectional'),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
openingKind: 'door',
|
||||||
|
...(isGarageDoor ? getDoorTypeUpdates('hinged') : {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Door', value: 'door' },
|
{ label: 'Door', value: 'door' },
|
||||||
{ label: 'Opening', value: 'opening' },
|
{ label: 'Opening', value: 'opening' },
|
||||||
|
{ label: 'Garage', value: 'garage' },
|
||||||
]}
|
]}
|
||||||
value={node.openingKind}
|
value={typeMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isOpening && (
|
{!isOpening && (
|
||||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||||
{doorTypeOptions.map((option) => {
|
{(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => {
|
||||||
const isSelected = doorType === option.value
|
const isSelected = doorType === option.value
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -604,10 +678,38 @@ export function DoorPanel() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isOpening && (
|
||||||
|
<PanelSection title="Garage">
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
{isSectionalGarageDoor && (
|
||||||
|
<SliderControl
|
||||||
|
label="Panels"
|
||||||
|
max={8}
|
||||||
|
min={3}
|
||||||
|
onChange={(v) => handleUpdate({ garagePanelCount: Math.round(v) })}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
value={node.garagePanelCount ?? 4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<PanelSection title="Dimensions">
|
<PanelSection title="Dimensions">
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Width"
|
label="Width"
|
||||||
max={3}
|
max={maxDoorWidth}
|
||||||
min={0.5}
|
min={0.5}
|
||||||
onChange={(v) => handleUpdate({ width: v })}
|
onChange={(v) => handleUpdate({ width: v })}
|
||||||
precision={2}
|
precision={2}
|
||||||
@@ -729,51 +831,53 @@ export function DoorPanel() {
|
|||||||
|
|
||||||
{!isOpening && (
|
{!isOpening && (
|
||||||
<>
|
<>
|
||||||
<PanelSection title="Frame">
|
<PanelSection title="Frame">
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Thickness"
|
label="Thickness"
|
||||||
max={0.2}
|
max={0.2}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||||
/>
|
/>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Depth"
|
label="Depth"
|
||||||
max={0.3}
|
max={0.3}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Content Padding">
|
{!isGarageDoor && (
|
||||||
<SliderControl
|
<PanelSection title="Content Padding">
|
||||||
label="Horizontal"
|
<SliderControl
|
||||||
max={0.2}
|
label="Horizontal"
|
||||||
min={0}
|
max={0.2}
|
||||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
min={0}
|
||||||
precision={3}
|
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||||
step={0.005}
|
precision={3}
|
||||||
unit="m"
|
step={0.005}
|
||||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
unit="m"
|
||||||
/>
|
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||||
<SliderControl
|
/>
|
||||||
label="Vertical"
|
<SliderControl
|
||||||
max={0.2}
|
label="Vertical"
|
||||||
min={0}
|
max={0.2}
|
||||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
min={0}
|
||||||
precision={3}
|
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||||
step={0.005}
|
precision={3}
|
||||||
unit="m"
|
step={0.005}
|
||||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
unit="m"
|
||||||
/>
|
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||||
</PanelSection>
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSwingDoor && (
|
{isSwingDoor && (
|
||||||
<PanelSection title="Swing">
|
<PanelSection title="Swing">
|
||||||
@@ -832,44 +936,46 @@ export function DoorPanel() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PanelSection title="Handle">
|
{!isGarageDoor && (
|
||||||
{isSwingDoor && (
|
<PanelSection title="Handle">
|
||||||
<ToggleControl
|
{isSwingDoor && (
|
||||||
checked={node.handle}
|
<ToggleControl
|
||||||
label="Enable Handle"
|
checked={node.handle}
|
||||||
onChange={(checked) => handleUpdate({ handle: checked })}
|
label="Enable Handle"
|
||||||
/>
|
onChange={(checked) => handleUpdate({ handle: checked })}
|
||||||
)}
|
|
||||||
{(node.handle || !isSwingDoor) && (
|
|
||||||
<div className="mt-1 flex flex-col gap-1">
|
|
||||||
<SliderControl
|
|
||||||
label="Height"
|
|
||||||
max={node.height - 0.1}
|
|
||||||
min={0.5}
|
|
||||||
onChange={(v) => handleUpdate({ handleHeight: v })}
|
|
||||||
precision={2}
|
|
||||||
step={0.05}
|
|
||||||
unit="m"
|
|
||||||
value={Math.round(node.handleHeight * 100) / 100}
|
|
||||||
/>
|
/>
|
||||||
{supportsHandleSide && (
|
)}
|
||||||
<div className="space-y-1">
|
{(node.handle || !isSwingDoor) && (
|
||||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
Handle Side
|
<SliderControl
|
||||||
</span>
|
label="Height"
|
||||||
<SegmentedControl
|
max={node.height - 0.1}
|
||||||
onChange={(v) => handleUpdate({ handleSide: v })}
|
min={0.5}
|
||||||
options={[
|
onChange={(v) => handleUpdate({ handleHeight: v })}
|
||||||
{ label: 'Left', value: 'left' },
|
precision={2}
|
||||||
{ label: 'Right', value: 'right' },
|
step={0.05}
|
||||||
]}
|
unit="m"
|
||||||
value={node.handleSide}
|
value={Math.round(node.handleHeight * 100) / 100}
|
||||||
/>
|
/>
|
||||||
</div>
|
{supportsHandleSide && (
|
||||||
)}
|
<div className="space-y-1">
|
||||||
</div>
|
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||||
)}
|
Handle Side
|
||||||
</PanelSection>
|
</span>
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => handleUpdate({ handleSide: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'Left', value: 'left' },
|
||||||
|
{ label: 'Right', value: 'right' },
|
||||||
|
]}
|
||||||
|
value={node.handleSide}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSwingDoor && (
|
{isSwingDoor && (
|
||||||
<PanelSection title="Hardware">
|
<PanelSection title="Hardware">
|
||||||
@@ -900,155 +1006,159 @@ export function DoorPanel() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PanelSection title="Segments">
|
{!isGarageDoor && (
|
||||||
{node.segments.map((seg, i) => {
|
<PanelSection title="Segments">
|
||||||
const numCols = seg.columnRatios.length
|
{node.segments.map((seg, i) => {
|
||||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
const numCols = seg.columnRatios.length
|
||||||
const normCols = seg.columnRatios.map((r) => r / colSum)
|
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
return (
|
const normCols = seg.columnRatios.map((r) => r / colSum)
|
||||||
<div className="mb-2 flex flex-col gap-1" key={i}>
|
return (
|
||||||
<div className="flex items-center justify-between pb-1">
|
<div className="mb-2 flex flex-col gap-1" key={i}>
|
||||||
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
|
<div className="flex items-center justify-between pb-1">
|
||||||
</div>
|
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
onChange={(t) => {
|
onChange={(t) => {
|
||||||
const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s))
|
const updated = node.segments.map((s, idx) =>
|
||||||
handleUpdate({ segments: updated })
|
idx === i ? { ...s, type: t } : s,
|
||||||
}}
|
)
|
||||||
options={[
|
handleUpdate({ segments: updated })
|
||||||
{ label: 'Panel', value: 'panel' },
|
}}
|
||||||
{ label: 'Glass', value: 'glass' },
|
options={[
|
||||||
{ label: 'Empty', value: 'empty' },
|
{ label: 'Panel', value: 'panel' },
|
||||||
]}
|
{ label: 'Glass', value: 'glass' },
|
||||||
value={seg.type}
|
{ label: 'Empty', value: 'empty' },
|
||||||
/>
|
]}
|
||||||
|
value={seg.type}
|
||||||
|
/>
|
||||||
|
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Height"
|
label="Height"
|
||||||
max={95}
|
max={95}
|
||||||
min={5}
|
min={5}
|
||||||
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
||||||
precision={1}
|
precision={1}
|
||||||
step={1}
|
step={1}
|
||||||
unit="%"
|
unit="%"
|
||||||
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Columns"
|
label="Columns"
|
||||||
max={8}
|
max={8}
|
||||||
min={1}
|
min={1}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||||
const updated = node.segments.map((s, idx) =>
|
const updated = node.segments.map((s, idx) =>
|
||||||
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
|
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
|
||||||
)
|
)
|
||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
precision={0}
|
precision={0}
|
||||||
step={1}
|
step={1}
|
||||||
value={numCols}
|
value={numCols}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{numCols > 1 && (
|
{numCols > 1 && (
|
||||||
<div className="mt-1 border-border/50 border-t pt-1">
|
<div className="mt-1 border-border/50 border-t pt-1">
|
||||||
{normCols.map((ratio, ci) => (
|
{normCols.map((ratio, ci) => (
|
||||||
|
<SliderControl
|
||||||
|
key={`c-${ci}`}
|
||||||
|
label={`C${ci + 1}`}
|
||||||
|
max={95}
|
||||||
|
min={5}
|
||||||
|
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
||||||
|
precision={1}
|
||||||
|
step={1}
|
||||||
|
unit="%"
|
||||||
|
value={Math.round(ratio * 100 * 10) / 10}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<SliderControl
|
<SliderControl
|
||||||
key={`c-${ci}`}
|
label="Divider"
|
||||||
label={`C${ci + 1}`}
|
max={0.1}
|
||||||
max={95}
|
min={0.005}
|
||||||
min={5}
|
onChange={(v) => {
|
||||||
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
const updated = node.segments.map((s, idx) =>
|
||||||
precision={1}
|
idx === i ? { ...s, dividerThickness: v } : s,
|
||||||
step={1}
|
)
|
||||||
unit="%"
|
handleUpdate({ segments: updated })
|
||||||
value={Math.round(ratio * 100 * 10) / 10}
|
}}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
<SliderControl
|
)}
|
||||||
label="Divider"
|
|
||||||
max={0.1}
|
|
||||||
min={0.005}
|
|
||||||
onChange={(v) => {
|
|
||||||
const updated = node.segments.map((s, idx) =>
|
|
||||||
idx === i ? { ...s, dividerThickness: v } : s,
|
|
||||||
)
|
|
||||||
handleUpdate({ segments: updated })
|
|
||||||
}}
|
|
||||||
precision={3}
|
|
||||||
step={0.005}
|
|
||||||
unit="m"
|
|
||||||
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{seg.type === 'panel' && (
|
{seg.type === 'panel' && (
|
||||||
<div className="mt-1 border-border/50 border-t pt-1">
|
<div className="mt-1 border-border/50 border-t pt-1">
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Inset"
|
label="Inset"
|
||||||
max={0.1}
|
max={0.1}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const updated = node.segments.map((s, idx) =>
|
const updated = node.segments.map((s, idx) =>
|
||||||
idx === i ? { ...s, panelInset: v } : s,
|
idx === i ? { ...s, panelInset: v } : s,
|
||||||
)
|
)
|
||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(seg.panelInset * 1000) / 1000}
|
value={Math.round(seg.panelInset * 1000) / 1000}
|
||||||
/>
|
/>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Depth"
|
label="Depth"
|
||||||
max={0.1}
|
max={0.1}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const updated = node.segments.map((s, idx) =>
|
const updated = node.segments.map((s, idx) =>
|
||||||
idx === i ? { ...s, panelDepth: v } : s,
|
idx === i ? { ...s, panelDepth: v } : s,
|
||||||
)
|
)
|
||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}}
|
}}
|
||||||
precision={3}
|
precision={3}
|
||||||
step={0.005}
|
step={0.005}
|
||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(seg.panelDepth * 1000) / 1000}
|
value={Math.round(seg.panelDepth * 1000) / 1000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="flex gap-1.5 px-1 pt-1">
|
<div className="flex gap-1.5 px-1 pt-1">
|
||||||
<ActionButton
|
|
||||||
label="+ Add Segment"
|
|
||||||
onClick={() => {
|
|
||||||
const updated = [
|
|
||||||
...node.segments,
|
|
||||||
{
|
|
||||||
type: 'panel' as const,
|
|
||||||
heightRatio: 1,
|
|
||||||
columnRatios: [1],
|
|
||||||
dividerThickness: 0.03,
|
|
||||||
panelDepth: 0.01,
|
|
||||||
panelInset: 0.04,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
handleUpdate({ segments: updated })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{node.segments.length > 1 && (
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className="text-white/60 hover:text-white"
|
label="+ Add Segment"
|
||||||
label="- Remove"
|
onClick={() => {
|
||||||
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
|
const updated = [
|
||||||
|
...node.segments,
|
||||||
|
{
|
||||||
|
type: 'panel' as const,
|
||||||
|
heightRatio: 1,
|
||||||
|
columnRatios: [1],
|
||||||
|
dividerThickness: 0.03,
|
||||||
|
panelDepth: 0.01,
|
||||||
|
panelInset: 0.04,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
{node.segments.length > 1 && (
|
||||||
</div>
|
<ActionButton
|
||||||
</PanelSection>
|
className="text-white/60 hover:text-white"
|
||||||
|
label="- Remove"
|
||||||
|
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { sfxEmitter } from '../lib/sfx-bus'
|
|||||||
import useEditor from '../store/use-editor'
|
import useEditor from '../store/use-editor'
|
||||||
|
|
||||||
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
||||||
|
const DOOR_TOGGLE_ANIMATION_MS = 520
|
||||||
|
const activeDoorAnimations = new Map<AnyNodeId, number>()
|
||||||
|
|
||||||
function updateDoorOpenState(
|
function updateDoorOpenState(
|
||||||
doorId: AnyNodeId,
|
doorId: AnyNodeId,
|
||||||
@@ -18,6 +20,36 @@ function updateDoorOpenState(
|
|||||||
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function animateDoorOpenState(
|
||||||
|
doorId: AnyNodeId,
|
||||||
|
field: 'operationState' | 'swingAngle',
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) {
|
||||||
|
const existingFrame = activeDoorAnimations.get(doorId)
|
||||||
|
if (existingFrame !== undefined) {
|
||||||
|
window.cancelAnimationFrame(existingFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = performance.now()
|
||||||
|
const ease = (value: number) => value * value * (3 - 2 * value)
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS)
|
||||||
|
const value = from + (to - from) * ease(progress)
|
||||||
|
updateDoorOpenState(doorId, { [field]: value })
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
|
||||||
|
} else {
|
||||||
|
activeDoorAnimations.delete(doorId)
|
||||||
|
updateDoorOpenState(doorId, { [field]: to })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
|
||||||
|
}
|
||||||
|
|
||||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
// 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.
|
// so that the global Escape handler knows not to also switch to select mode.
|
||||||
let _toolCancelConsumed = false
|
let _toolCancelConsumed = false
|
||||||
@@ -169,18 +201,26 @@ export const useKeyboard = ({
|
|||||||
node.doorType === 'folding' ||
|
node.doorType === 'folding' ||
|
||||||
node.doorType === 'pocket' ||
|
node.doorType === 'pocket' ||
|
||||||
node.doorType === 'barn' ||
|
node.doorType === 'barn' ||
|
||||||
node.doorType === 'sliding'
|
node.doorType === 'sliding' ||
|
||||||
|
node.doorType === 'garage-sectional' ||
|
||||||
|
node.doorType === 'garage-rollup' ||
|
||||||
|
node.doorType === 'garage-tiltup'
|
||||||
) {
|
) {
|
||||||
const currentOpenAmount = node.operationState ?? 0
|
const currentOpenAmount = node.operationState ?? 0
|
||||||
updateDoorOpenState(node.id, {
|
animateDoorOpenState(
|
||||||
operationState: currentOpenAmount >= 0.5 ? 0 : 1,
|
node.id,
|
||||||
})
|
'operationState',
|
||||||
|
currentOpenAmount,
|
||||||
|
currentOpenAmount >= 0.5 ? 0 : 1,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const currentSwingAngle = node.swingAngle ?? 0
|
const currentSwingAngle = node.swingAngle ?? 0
|
||||||
updateDoorOpenState(node.id, {
|
animateDoorOpenState(
|
||||||
swingAngle:
|
node.id,
|
||||||
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
'swingAngle',
|
||||||
})
|
currentSwingAngle,
|
||||||
|
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
}
|
}
|
||||||
@@ -212,7 +252,10 @@ export const useKeyboard = ({
|
|||||||
node.doorType === 'folding' ||
|
node.doorType === 'folding' ||
|
||||||
node.doorType === 'pocket' ||
|
node.doorType === 'pocket' ||
|
||||||
node.doorType === 'barn' ||
|
node.doorType === 'barn' ||
|
||||||
node.doorType === 'sliding'
|
node.doorType === 'sliding' ||
|
||||||
|
node.doorType === 'garage-sectional' ||
|
||||||
|
node.doorType === 'garage-rollup' ||
|
||||||
|
node.doorType === 'garage-tiltup'
|
||||||
? { operationState: 0 }
|
? { operationState: 0 }
|
||||||
: { swingAngle: 0 },
|
: { swingAngle: 0 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ function addRotatedBox(
|
|||||||
parent.add(m)
|
parent.add(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addBoxWithRotation(
|
||||||
|
parent: THREE.Object3D,
|
||||||
|
material: THREE.Material,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
d: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
z: number,
|
||||||
|
rotation: [number, number, number],
|
||||||
|
) {
|
||||||
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||||
|
m.position.set(x, y, z)
|
||||||
|
m.rotation.set(rotation[0], rotation[1], rotation[2])
|
||||||
|
parent.add(m)
|
||||||
|
}
|
||||||
|
|
||||||
function disposeObject(object: THREE.Object3D) {
|
function disposeObject(object: THREE.Object3D) {
|
||||||
object.traverse((child) => {
|
object.traverse((child) => {
|
||||||
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
||||||
@@ -484,7 +501,7 @@ function addPocketDoor(
|
|||||||
contentPadding: DoorNode['contentPadding']
|
contentPadding: DoorNode['contentPadding']
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const openAmount = Math.max(0, Math.min(1, operationState))
|
const openAmount = Math.max(0, Math.min(1, operationState)) * 0.88
|
||||||
const slideSign = slideDirection === 'right' ? 1 : -1
|
const slideSign = slideDirection === 'right' ? 1 : -1
|
||||||
const leafWidth = insideWidth
|
const leafWidth = insideWidth
|
||||||
const leafCenterX = slideSign * insideWidth * openAmount
|
const leafCenterX = slideSign * insideWidth * openAmount
|
||||||
@@ -775,6 +792,293 @@ function addSlidingDoor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addGarageSectionalDoor(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
{
|
||||||
|
insideWidth,
|
||||||
|
leafHeight,
|
||||||
|
leafCenterY,
|
||||||
|
leafDepth,
|
||||||
|
frameThickness,
|
||||||
|
frameDepth,
|
||||||
|
operationState,
|
||||||
|
garagePanelCount,
|
||||||
|
}: {
|
||||||
|
insideWidth: number
|
||||||
|
leafHeight: number
|
||||||
|
leafCenterY: number
|
||||||
|
leafDepth: number
|
||||||
|
frameThickness: number
|
||||||
|
frameDepth: number
|
||||||
|
operationState: number
|
||||||
|
garagePanelCount: number
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const openAmount = Math.max(0, Math.min(1, operationState))
|
||||||
|
const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount)))
|
||||||
|
const panelHeight = leafHeight / panelCount
|
||||||
|
const panelGap = Math.min(0.012, panelHeight * 0.08)
|
||||||
|
const travelDepth = Math.max(leafHeight, 1.4)
|
||||||
|
const curveRadius = panelHeight * 0.58
|
||||||
|
const curveLength = (Math.PI / 2) * curveRadius
|
||||||
|
const travel = openAmount * ((panelCount - 1) * panelHeight + curveLength + panelHeight * 0.65)
|
||||||
|
const overheadY = leafCenterY + leafHeight / 2 - panelHeight / 2
|
||||||
|
const railY = leafCenterY + leafHeight / 2 - 0.04
|
||||||
|
const railZ = -travelDepth / 2
|
||||||
|
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
0.035,
|
||||||
|
Math.max(0.04, frameThickness * 0.75),
|
||||||
|
travelDepth,
|
||||||
|
-insideWidth / 2 + 0.035,
|
||||||
|
railY,
|
||||||
|
railZ,
|
||||||
|
)
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
0.035,
|
||||||
|
Math.max(0.04, frameThickness * 0.75),
|
||||||
|
travelDepth,
|
||||||
|
insideWidth / 2 - 0.035,
|
||||||
|
railY,
|
||||||
|
railZ,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let index = 0; index < panelCount; index++) {
|
||||||
|
const orderFromTop = panelCount - 1 - index
|
||||||
|
const pathPosition = travel - orderFromTop * panelHeight
|
||||||
|
let y = overheadY + pathPosition
|
||||||
|
let z = 0
|
||||||
|
let rotationX = 0
|
||||||
|
|
||||||
|
if (pathPosition > 0 && pathPosition <= curveLength) {
|
||||||
|
const theta = pathPosition / curveRadius
|
||||||
|
rotationX = -theta
|
||||||
|
y = overheadY + curveRadius * Math.sin(theta)
|
||||||
|
z = -curveRadius * (1 - Math.cos(theta))
|
||||||
|
} else if (pathPosition > curveLength) {
|
||||||
|
rotationX = -Math.PI / 2
|
||||||
|
y = overheadY + curveRadius
|
||||||
|
z = -(curveRadius + pathPosition - curveLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealOffset = (panelHeight - panelGap) * 0.22
|
||||||
|
const trimDepth = 0.01
|
||||||
|
const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006
|
||||||
|
const addSectionalTrim = (localY: number) => {
|
||||||
|
addBoxWithRotation(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
insideWidth - 0.16,
|
||||||
|
0.012,
|
||||||
|
trimDepth,
|
||||||
|
0,
|
||||||
|
y + localY * Math.cos(rotationX) - trimFaceOffset * Math.sin(rotationX),
|
||||||
|
z + localY * Math.sin(rotationX) + trimFaceOffset * Math.cos(rotationX),
|
||||||
|
[rotationX, 0, 0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
addBoxWithRotation(
|
||||||
|
mesh,
|
||||||
|
baseMaterial,
|
||||||
|
insideWidth,
|
||||||
|
Math.max(0.04, panelHeight - panelGap),
|
||||||
|
leafDepth,
|
||||||
|
0,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
[rotationX, 0, 0],
|
||||||
|
)
|
||||||
|
addSectionalTrim(revealOffset)
|
||||||
|
addSectionalTrim(-revealOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
addBox(mesh, revealMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGarageRollupDoor(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
{
|
||||||
|
insideWidth,
|
||||||
|
leafHeight,
|
||||||
|
leafCenterY,
|
||||||
|
leafDepth,
|
||||||
|
frameThickness,
|
||||||
|
frameDepth,
|
||||||
|
operationState,
|
||||||
|
}: {
|
||||||
|
insideWidth: number
|
||||||
|
leafHeight: number
|
||||||
|
leafCenterY: number
|
||||||
|
leafDepth: number
|
||||||
|
frameThickness: number
|
||||||
|
frameDepth: number
|
||||||
|
operationState: number
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const openAmount = Math.max(0, Math.min(1, operationState))
|
||||||
|
const slatHeight = Math.max(0.055, Math.min(0.11, leafHeight / 22))
|
||||||
|
const visibleHeight = leafHeight * (1 - openAmount)
|
||||||
|
const visibleSlatCount = Math.ceil(visibleHeight / slatHeight)
|
||||||
|
const topY = leafCenterY + leafHeight / 2
|
||||||
|
const curtainCenterY = topY - visibleHeight / 2
|
||||||
|
const drumMaxRadius = Math.max(0.12, Math.min(0.22, leafHeight * 0.075))
|
||||||
|
const drumY = topY + drumMaxRadius * 0.12
|
||||||
|
const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72
|
||||||
|
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
0.032,
|
||||||
|
leafHeight,
|
||||||
|
Math.max(frameDepth * 0.48, 0.035),
|
||||||
|
-insideWidth / 2 + 0.03,
|
||||||
|
leafCenterY,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
0.032,
|
||||||
|
leafHeight,
|
||||||
|
Math.max(frameDepth * 0.48, 0.035),
|
||||||
|
insideWidth / 2 - 0.03,
|
||||||
|
leafCenterY,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (visibleHeight > 0.01) {
|
||||||
|
addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0)
|
||||||
|
|
||||||
|
for (let index = 0; index < visibleSlatCount; index++) {
|
||||||
|
const y = topY - Math.min(visibleHeight, index * slatHeight)
|
||||||
|
addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012)
|
||||||
|
}
|
||||||
|
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
insideWidth - 0.04,
|
||||||
|
0.028,
|
||||||
|
leafDepth + 0.018,
|
||||||
|
0,
|
||||||
|
topY - visibleHeight,
|
||||||
|
leafDepth / 2 + 0.004,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const drum = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36),
|
||||||
|
baseMaterial,
|
||||||
|
)
|
||||||
|
drum.position.set(0, drumY, drumZ)
|
||||||
|
drum.rotation.z = Math.PI / 2
|
||||||
|
mesh.add(drum)
|
||||||
|
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
insideWidth + frameThickness,
|
||||||
|
0.026,
|
||||||
|
Math.max(frameDepth * 0.52, 0.04),
|
||||||
|
0,
|
||||||
|
topY + 0.02,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGarageTiltupDoor(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
{
|
||||||
|
insideWidth,
|
||||||
|
leafHeight,
|
||||||
|
leafCenterY,
|
||||||
|
leafDepth,
|
||||||
|
frameThickness,
|
||||||
|
frameDepth,
|
||||||
|
operationState,
|
||||||
|
}: {
|
||||||
|
insideWidth: number
|
||||||
|
leafHeight: number
|
||||||
|
leafCenterY: number
|
||||||
|
leafDepth: number
|
||||||
|
frameThickness: number
|
||||||
|
frameDepth: number
|
||||||
|
operationState: number
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const openAmount = Math.max(0, Math.min(1, operationState))
|
||||||
|
const angle = (Math.PI / 2) * openAmount
|
||||||
|
const hingeY = leafCenterY + leafHeight / 2
|
||||||
|
const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2)
|
||||||
|
const panelCenterZ = -Math.sin(angle) * (leafHeight / 2)
|
||||||
|
const railLength = Math.max(leafHeight * 0.72, 1.2)
|
||||||
|
const railY = hingeY - frameThickness * 0.35
|
||||||
|
const railZ = -railLength / 2
|
||||||
|
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
0.03,
|
||||||
|
Math.max(frameThickness * 0.7, 0.035),
|
||||||
|
railLength,
|
||||||
|
-insideWidth / 2 + 0.04,
|
||||||
|
railY,
|
||||||
|
railZ,
|
||||||
|
)
|
||||||
|
addBox(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
0.03,
|
||||||
|
Math.max(frameThickness * 0.7, 0.035),
|
||||||
|
railLength,
|
||||||
|
insideWidth / 2 - 0.04,
|
||||||
|
railY,
|
||||||
|
railZ,
|
||||||
|
)
|
||||||
|
|
||||||
|
addBoxWithRotation(
|
||||||
|
mesh,
|
||||||
|
baseMaterial,
|
||||||
|
insideWidth,
|
||||||
|
leafHeight,
|
||||||
|
leafDepth,
|
||||||
|
0,
|
||||||
|
panelCenterY,
|
||||||
|
panelCenterZ,
|
||||||
|
[-angle, 0, 0],
|
||||||
|
)
|
||||||
|
|
||||||
|
const insetWidth = Math.max(0.1, insideWidth - 0.22)
|
||||||
|
const insetHeight = Math.max(0.1, leafHeight - 0.28)
|
||||||
|
const trimDepth = 0.012
|
||||||
|
const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006
|
||||||
|
const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => {
|
||||||
|
addBoxWithRotation(
|
||||||
|
mesh,
|
||||||
|
revealMaterial,
|
||||||
|
trimWidth,
|
||||||
|
trimHeight,
|
||||||
|
trimDepth,
|
||||||
|
localX,
|
||||||
|
panelCenterY + localY * Math.cos(angle) + trimFaceOffset * Math.sin(angle),
|
||||||
|
panelCenterZ - localY * Math.sin(angle) + trimFaceOffset * Math.cos(angle),
|
||||||
|
[-angle, 0, 0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
addTiltupTrim(0, insetHeight / 2, insetWidth, 0.018)
|
||||||
|
addTiltupTrim(0, -insetHeight / 2, insetWidth, 0.018)
|
||||||
|
addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight)
|
||||||
|
addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight)
|
||||||
|
|
||||||
|
addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0)
|
||||||
|
}
|
||||||
|
|
||||||
function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||||
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
@@ -815,6 +1119,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
operationState = 0,
|
operationState = 0,
|
||||||
leafCount = 1,
|
leafCount = 1,
|
||||||
slideDirection = 'left',
|
slideDirection = 'left',
|
||||||
|
garagePanelCount = 4,
|
||||||
} = node
|
} = node
|
||||||
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
|
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
|
||||||
|
|
||||||
@@ -878,7 +1183,38 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (doorType === 'folding') {
|
if (doorType === 'garage-sectional') {
|
||||||
|
addGarageSectionalDoor(mesh, {
|
||||||
|
insideWidth,
|
||||||
|
leafHeight: leafH,
|
||||||
|
leafCenterY,
|
||||||
|
leafDepth,
|
||||||
|
frameThickness,
|
||||||
|
frameDepth,
|
||||||
|
operationState,
|
||||||
|
garagePanelCount,
|
||||||
|
})
|
||||||
|
} else if (doorType === 'garage-rollup') {
|
||||||
|
addGarageRollupDoor(mesh, {
|
||||||
|
insideWidth,
|
||||||
|
leafHeight: leafH,
|
||||||
|
leafCenterY,
|
||||||
|
leafDepth,
|
||||||
|
frameThickness,
|
||||||
|
frameDepth,
|
||||||
|
operationState,
|
||||||
|
})
|
||||||
|
} else if (doorType === 'garage-tiltup') {
|
||||||
|
addGarageTiltupDoor(mesh, {
|
||||||
|
insideWidth,
|
||||||
|
leafHeight: leafH,
|
||||||
|
leafCenterY,
|
||||||
|
leafDepth,
|
||||||
|
frameThickness,
|
||||||
|
frameDepth,
|
||||||
|
operationState,
|
||||||
|
})
|
||||||
|
} else if (doorType === 'folding') {
|
||||||
addFoldingDoor(mesh, {
|
addFoldingDoor(mesh, {
|
||||||
insideWidth,
|
insideWidth,
|
||||||
leafHeight: leafH,
|
leafHeight: leafH,
|
||||||
|
|||||||
Reference in New Issue
Block a user