-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(wall): add endHeightOffset for sloped top edge #640
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
vducasse
wants to merge
15
commits into
pascalorg:main
Choose a base branch
from
vducasse:feat/wall-end-height
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
ddc08f1
feat(wall): add endHeightOffset for sloped top edge
f9213c2
fix(wall): enforce strict zero minimum for endHeightOffset
402111e
Merge remote-tracking branch 'origin/main' into feat/wall-end-height
8ef94a1
Merge remote-tracking branch 'origin/main' into feat/wall-end-height
83d0854
fix(wall): resolve end height offset bounds and
e821748
fix(openings): clamp sloped ceiling height and plane-bound wall slope
c6f64af
Merge remote-tracking branch 'origin/main' into feat/wall-end-height
4a2638a
fix(openings): enforce slope fit on 2D floorplan moves
e2bdea3
fix(spatial-grid): evaluate lowest ceiling across item span and ensur…
b7927c4
fix(openings): use curve length for curved wall ceiling checks
86950fe
refactor(wall): unify wall slope calculation across viewer and openin…
9746aee
fix(wall): clamp negative endHeightOffset in resolveWallTop to match …
8f6f4c2
fix(spatial-grid): route getWallHeight through canonical getWallEffec…
8f7ce5c
fix(wall): propagate slope parameter across room ceilings, overlays, …
cf8f12c
fix(core): invalidate spatial-grid and space-detection on wall slope …
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
2,638 changes: 1,319 additions & 1,319 deletions
2,638
packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
Large diffs are not rendered by default.
Oops, something went wrong.
962 changes: 483 additions & 479 deletions
962
packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,118 +1,120 @@ | ||
| import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' | ||
| import type { AnyNode, AnyNodeId } from '../schema/types' | ||
| import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' | ||
| import { resolveWallTop } from '../systems/wall/wall-top' | ||
| // Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: | ||
| // both sides only reference the other inside function bodies. | ||
| import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' | ||
|
|
||
| export const DEFAULT_LEVEL_HEIGHT = 2.5 | ||
|
|
||
| /** | ||
| * Effective ceiling height in level-local meters. An explicit stored | ||
| * `height` wins; absent height means the ceiling follows the level top — | ||
| * the same bound its write-clamp uses: min(storey plane, lowest | ||
| * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see | ||
| * {@link getCeilingClampBound}). Falls back to the default plane minus | ||
| * the same margin when the owning level is unresolvable. | ||
| */ | ||
| export function resolveCeilingHeight( | ||
| ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| ): number { | ||
| if (ceiling.height != null) return ceiling.height | ||
| const bound = | ||
| typeof ceiling.parentId === 'string' | ||
| ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) | ||
| : Number.POSITIVE_INFINITY | ||
| return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN | ||
| } | ||
|
|
||
| export function deriveLegacyLevelHeight( | ||
| levelId: string, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| ): number { | ||
| const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined | ||
| if (!level) return DEFAULT_LEVEL_HEIGHT | ||
|
|
||
| const levelChildren = level.children | ||
| .map((childId) => nodes[childId as keyof typeof nodes]) | ||
| .filter((child): child is AnyNode => child !== undefined) | ||
| const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') | ||
| const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') | ||
|
|
||
| let maxTop = 0 | ||
|
|
||
| for (const child of levelChildren) { | ||
| if (child.type === 'ceiling') { | ||
| // Absence here is the PRE-migration legacy schema default (2.5), not | ||
| // follows-mode — this derivation runs before the level has a height | ||
| // for a follows-mode bound to track. | ||
| const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT | ||
| if (height > maxTop) maxTop = height | ||
| } else if (child.type === 'wall') { | ||
| const wall = child as WallNode | ||
| const electedElevation = computeWallSlabSupport( | ||
| { | ||
| start: wall.start, | ||
| end: wall.end, | ||
| curveOffset: wall.curveOffset, | ||
| thickness: wall.thickness, | ||
| }, | ||
| slabs, | ||
| walls, | ||
| ).elevation | ||
| const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) | ||
| if (top > maxTop) maxTop = top | ||
| } | ||
| } | ||
|
|
||
| return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT | ||
| } | ||
|
|
||
| /** | ||
| * The ceiling covering level-local point `[x, z]`, or `null` when none | ||
| * sits over it. Points inside a ceiling's hole are treated as uncovered. | ||
| * When ceilings overlap, the lowest one wins — that's the surface a duct | ||
| * would actually hang from. | ||
| */ | ||
| export function getCeilingAt( | ||
| levelId: string, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| x: number, | ||
| z: number, | ||
| ): CeilingNode | null { | ||
| const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined | ||
| if (!level) return null | ||
|
|
||
| let best: CeilingNode | null = null | ||
| let bestHeight = Number.POSITIVE_INFINITY | ||
| for (const childId of level.children) { | ||
| const child = nodes[childId as keyof typeof nodes] | ||
| if (child?.type !== 'ceiling') continue | ||
| const ceiling = child as CeilingNode | ||
| if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue | ||
| if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue | ||
| const h = resolveCeilingHeight(ceiling, nodes) | ||
| if (best === null || h < bestHeight) { | ||
| best = ceiling | ||
| bestHeight = h | ||
| } | ||
| } | ||
| return best | ||
| } | ||
|
|
||
| /** | ||
| * Underside elevation (meters above the level floor) of the ceiling | ||
| * covering level-local point `[x, z]`, or `null` when no ceiling sits | ||
| * over that point. See {@link getCeilingAt}. | ||
| */ | ||
| export function getCeilingHeightAt( | ||
| levelId: string, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| x: number, | ||
| z: number, | ||
| ): number | null { | ||
| const ceiling = getCeilingAt(levelId, nodes, x, z) | ||
| return ceiling ? resolveCeilingHeight(ceiling, nodes) : null | ||
| } | ||
| import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' | ||
| import type { AnyNode, AnyNodeId } from '../schema/types' | ||
| import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' | ||
| import { resolveWallTop } from '../systems/wall/wall-top' | ||
| // Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: | ||
| // both sides only reference the other inside function bodies. | ||
| import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' | ||
|
|
||
| export const DEFAULT_LEVEL_HEIGHT = 2.5 | ||
|
|
||
| /** | ||
| * Effective ceiling height in level-local meters. An explicit stored | ||
| * `height` wins; absent height means the ceiling follows the level top — | ||
| * the same bound its write-clamp uses: min(storey plane, lowest | ||
| * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see | ||
| * {@link getCeilingClampBound}). Falls back to the default plane minus | ||
| * the same margin when the owning level is unresolvable. | ||
| */ | ||
| export function resolveCeilingHeight( | ||
| ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| ): number { | ||
| if (ceiling.height != null) return ceiling.height | ||
| const bound = | ||
| typeof ceiling.parentId === 'string' | ||
| ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) | ||
| : Number.POSITIVE_INFINITY | ||
| return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN | ||
| } | ||
|
|
||
| export function deriveLegacyLevelHeight( | ||
| levelId: string, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| ): number { | ||
| const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined | ||
| if (!level) return DEFAULT_LEVEL_HEIGHT | ||
|
|
||
| const levelChildren = level.children | ||
| .map((childId) => nodes[childId as keyof typeof nodes]) | ||
| .filter((child): child is AnyNode => child !== undefined) | ||
| const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') | ||
| const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') | ||
|
|
||
| let maxTop = 0 | ||
|
|
||
| for (const child of levelChildren) { | ||
| if (child.type === 'ceiling') { | ||
| // Absence here is the PRE-migration legacy schema default (2.5), not | ||
| // follows-mode — this derivation runs before the level has a height | ||
| // for a follows-mode bound to track. | ||
| const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT | ||
| if (height > maxTop) maxTop = height | ||
| } else if (child.type === 'wall') { | ||
| const wall = child as WallNode | ||
| const electedElevation = computeWallSlabSupport( | ||
| { | ||
| start: wall.start, | ||
| end: wall.end, | ||
| curveOffset: wall.curveOffset, | ||
| thickness: wall.thickness, | ||
| }, | ||
| slabs, | ||
| walls, | ||
| ).elevation | ||
| const topStart = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 0) | ||
| const topEnd = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 1) | ||
| const top = Math.max(topStart, topEnd) | ||
| if (top > maxTop) maxTop = top | ||
| } | ||
| } | ||
|
|
||
| return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT | ||
| } | ||
|
|
||
| /** | ||
| * The ceiling covering level-local point `[x, z]`, or `null` when none | ||
| * sits over it. Points inside a ceiling's hole are treated as uncovered. | ||
| * When ceilings overlap, the lowest one wins — that's the surface a duct | ||
| * would actually hang from. | ||
| */ | ||
| export function getCeilingAt( | ||
| levelId: string, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| x: number, | ||
| z: number, | ||
| ): CeilingNode | null { | ||
| const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined | ||
| if (!level) return null | ||
|
|
||
| let best: CeilingNode | null = null | ||
| let bestHeight = Number.POSITIVE_INFINITY | ||
| for (const childId of level.children) { | ||
| const child = nodes[childId as keyof typeof nodes] | ||
| if (child?.type !== 'ceiling') continue | ||
| const ceiling = child as CeilingNode | ||
| if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue | ||
| if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue | ||
| const h = resolveCeilingHeight(ceiling, nodes) | ||
| if (best === null || h < bestHeight) { | ||
| best = ceiling | ||
| bestHeight = h | ||
| } | ||
| } | ||
| return best | ||
| } | ||
|
|
||
| /** | ||
| * Underside elevation (meters above the level floor) of the ceiling | ||
| * covering level-local point `[x, z]`, or `null` when no ceiling sits | ||
| * over that point. See {@link getCeilingAt}. | ||
| */ | ||
| export function getCeilingHeightAt( | ||
| levelId: string, | ||
| nodes: Record<AnyNodeId, AnyNode>, | ||
| x: number, | ||
| z: number, | ||
| ): number | null { | ||
| const ceiling = getCeilingAt(levelId, nodes, x, z) | ||
| return ceiling ? resolveCeilingHeight(ceiling, nodes) : null | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,56 +1,70 @@ | ||
| import type { WallNode } from '../../schema/nodes/wall' | ||
|
|
||
| /** | ||
| * Minimum wall body height in meters. Governs both the wall height | ||
| * arrow's lower drag bound and the slab-elevation clamp: a slab may not | ||
| * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall | ||
| * elects it as its base, or the wall's extrusion (plane minus base) | ||
| * would collapse below this minimum. | ||
| */ | ||
| export const MIN_WALL_HEIGHT = 0.5 | ||
|
|
||
| /** | ||
| * Wall-top inversion (vertical building model): a wall with no stored | ||
| * `height` is plane-bound — its top sits at the storey plane (level-local | ||
| * Y = the level's stored height), so a slab lifting the wall's base makes | ||
| * the wall shorter, never taller, and no gap can open at the top of a | ||
| * level. A wall WITH `height` is an explicit exception (half wall, | ||
| * parapet) and keeps the legacy semantics: the top rides a raised elected | ||
| * base (`electedBase + height`), while a zero or sunken slab base leaves | ||
| * the top at `height` (the legacy negative-slab constraint). Explicit | ||
| * ground-hosted walls are the terrain exception: `height` is always body | ||
| * height, including below datum, so sculpting cannot stretch the wall. | ||
| * | ||
| * Returns the top in level-local Y (same frame as `electedBase`). | ||
| */ | ||
| export function resolveWallTop( | ||
| wall: Pick<WallNode, 'height' | 'supportSlabId'>, | ||
| storeyHeight: number, | ||
| electedBase: number, | ||
| ): number { | ||
| if (wall.height == null) return storeyHeight | ||
| if (wall.supportSlabId === 'ground') return electedBase + wall.height | ||
| return electedBase > 0 ? electedBase + wall.height : wall.height | ||
| } | ||
|
|
||
| /** | ||
| * Extruded height of the wall body: {@link resolveWallTop} minus the | ||
| * elected base. Base convention: the elected slab-support elevation itself | ||
| * — the viewer computes `effectiveBaseElevation = min(baseElevation, | ||
| * slabElevation)` and defaults `baseElevation` to the elected elevation, | ||
| * so with only the election in hand the two coincide. Fill-down below the | ||
| * elected base (`baseSegments`) is a geometry detail the extruder handles | ||
| * separately and never changes where the top sits. | ||
| * | ||
| * Equivalently: the wall-local Y of the wall's top, measured from the wall | ||
| * mesh origin (which sits at `electedBase`). May be non-positive when a | ||
| * slab reaches the storey plane; callers own the degenerate-geometry | ||
| * policy. | ||
| */ | ||
| export function resolveWallEffectiveHeight( | ||
| wall: Pick<WallNode, 'height' | 'supportSlabId'>, | ||
| storeyHeight: number, | ||
| electedBase: number, | ||
| ): number { | ||
| return resolveWallTop(wall, storeyHeight, electedBase) - electedBase | ||
| } | ||
| import type { WallNode } from '../../schema/nodes/wall' | ||
|
|
||
| /** | ||
| * Minimum wall body height in meters. Governs both the wall height | ||
| * arrow's lower drag bound and the slab-elevation clamp: a slab may not | ||
| * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall | ||
| * elects it as its base, or the wall's extrusion (plane minus base) | ||
| * would collapse below this minimum. | ||
| */ | ||
| export const MIN_WALL_HEIGHT = 0.5 | ||
|
|
||
| /** | ||
| * Wall-top inversion (vertical building model): a wall with no stored | ||
| * `height` is plane-bound — its top sits at the storey plane (level-local | ||
| * Y = the level's stored height), so a slab lifting the wall's base makes | ||
| * the wall shorter, never taller, and no gap can open at the top of a | ||
| * level. A wall WITH `height` is an explicit exception (half wall, | ||
| * parapet) and keeps the legacy semantics: the top rides a raised elected | ||
| * base (`electedBase + height`), while a zero or sunken slab base leaves | ||
| * the top at `height` (the legacy negative-slab constraint). Explicit | ||
| * ground-hosted walls are the terrain exception: `height` is always body | ||
| * height, including below datum, so sculpting cannot stretch the wall. | ||
| * | ||
| * Returns the top in level-local Y (same frame as `electedBase`). | ||
| */ | ||
| export function resolveWallTop( | ||
| wall: Pick<WallNode, 'height' | 'supportSlabId' | 'endHeightOffset'>, | ||
| storeyHeight: number, | ||
| electedBase: number, | ||
| t?: number, | ||
| ): number { | ||
| let top: number | ||
| if (wall.height == null) { | ||
| top = storeyHeight | ||
| } else if (wall.supportSlabId === 'ground') { | ||
| top = electedBase + wall.height | ||
| } else { | ||
| top = electedBase > 0 ? electedBase + wall.height : wall.height | ||
| } | ||
| if (wall.endHeightOffset && t !== undefined) { | ||
| const bodyHeight = Math.max(0.01, top - electedBase) | ||
| const minEndHeight = 0.01 | ||
| const clampedOffset = Math.max(wall.endHeightOffset, -(bodyHeight - minEndHeight)) | ||
| top += clampedOffset * t | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| return top | ||
| } | ||
|
|
||
| /** | ||
| * Extruded height of the wall body: {@link resolveWallTop} minus the | ||
| * elected base. Base convention: the elected slab-support elevation itself | ||
| * — the viewer computes `effectiveBaseElevation = min(baseElevation, | ||
| * slabElevation)` and defaults `baseElevation` to the elected elevation, | ||
| * so with only the election in hand the two coincide. Fill-down below the | ||
| * elected base (`baseSegments`) is a geometry detail the extruder handles | ||
| * separately and never changes where the top sits. | ||
| * | ||
| * Equivalently: the wall-local Y of the wall's top, measured from the wall | ||
| * mesh origin (which sits at `electedBase`). May be non-positive when a | ||
| * slab reaches the storey plane; callers own the degenerate-geometry | ||
| * policy. | ||
| */ | ||
| export function resolveWallEffectiveHeight( | ||
| wall: Pick<WallNode, 'height' | 'supportSlabId' | 'endHeightOffset'>, | ||
| storeyHeight: number, | ||
| electedBase: number, | ||
| t?: number, | ||
| ): number { | ||
| return resolveWallTop(wall, storeyHeight, electedBase, t) - electedBase | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.