* fix(editor): wall endpoint move — detach/attach modifier sync and zero-move drop - restore linked walls to original positions the moment alt-detach engages - re-run the endpoint preview on alt keydown/keyup so re-attach doesn't wait for a mousemove; preview, HUD badge, and commit share one alt source - second click at an unmoved position cleanly drops the endpoint (no history entry) instead of leaving the interaction stuck - 2D floorplan parity: clear stale linked overrides on detach, re-apply drag sessions on modifier changes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): demote orphaned auto slabs/ceilings to manual instead of deleting Deleting a wall that encloses a room no longer destroys the room's slab/ceiling (paint, holes, elevation). Unmatched auto surfaces are only deleted when >=60% of their footprint is still covered by a detected room (rooms-merged case); otherwise they are demoted to manual nodes. - demoted slab polygons are baked (inset by SLAB_OUTSET + AUTO_SLAB_INSET) so the rendered footprint doesn't jump between the auto and manual paths - auto-creation suppression now also matches manual nodes by mutual footprint coverage, so re-closing the room doesn't stack a duplicate auto surface on the demoted one Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): hide alt-to-detach hint when the moving endpoint has no linked walls Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): split host walls in every snap mode, stop wall chain on existing walls Splitting a room with a wall now splits the walls it lands on and migrates hosted doors/windows/items — matching what already happened to the slab and ceiling. The split machinery existed but was gated on magnetic snapping ('lines' mode) while the wall tool defaults to 'grid'; split resolution now always runs, with the join radius scoped to the active mode (0.35 magnetic, 0.05 connect snap otherwise), and the whole commit lands as one undo step. The drawing chain now terminates when a committed segment ends on a wall outside the current chain (T-junction), like the room auto-close — users don't draw overlapping walls. Applied in 3D and 2D, and the 2D-only path gains the previously missing wallClosesRoom parity check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(core): per-edge build-time slab offsets — slabs tile at wall centerlines Replaces the stored-polygon render offsets (auto: centroid inset 0.02, manual: flat outset 0.05) with one uniform per-edge rule computed at geometry build time, for auto and manual slabs alike: - edge shared with a sibling slab (collinear-overlap test, T-junction sub-segments included) → small relief inset; adjacent rooms tile exactly and can never overlap - edge on a wall centerline with no slab neighbor → expand outward by that wall's thickness/2, flush with the facade - free edge → rendered exactly as drawn Slab demotion no longer bakes polygons (offsets never live in node data); no stored-data migration. New slab system marks level slabs dirty when wall geometry/thickness or sibling slab footprints change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): import KTX2Loader from its deep path, not the jsm aggregate The Addons.js aggregate re-exports LottieLoader/TTFLoader whose CDN URL imports (lottie-web, opentype.js) abort bun test in every package that transitively imports the viewer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): split host wall when an endpoint move lands on its interior Moving a wall endpoint onto another wall's mid-span now splits that wall and migrates its doors/windows/items, matching the draw tool — previously the room closed (detection planarizes internally) but the wall node stayed whole. New resolveEndpointWallSplit reuses the draw path's split pipeline; endpoint write + split commit as one undo step. Applied in 3D and 2D. Corner drops still join without splitting; straddling openings skip the split; alt-detached commits split the stationary former sibling correctly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(core): slab-wall adoption — absolute edge projection, band snapping, rendered-footprint wall elevation Slab edges near a wall now render projected onto the wall's exact geometry instead of getting relative offsets, healing legacy scenes (face-aligned edges, old baked outsets, hand-drag slop) with no data migration: - adoption band: an edge collinear with a wall centerline within thickness/2 + 6cm (both sides) is wall-backed; nearest centerline wins, a sibling slab in the same band forces the interior seam - wall-backed exterior edges project to the outer face; interior edges to the centerline minus the relief gap; free edges render as drawn - slab edge resize: wall snapping is now edge-based, not cursor-based — fixes the drop landing short by the grab offset (0.34m arrow gap in 3D, hit-stroke slop in 2D); the snap translates the edge onto the wall centerline (canonical stored position), beacon/preview/commit agree, full band in lines mode, 5cm stick otherwise; 2D shows a dashed stored-boundary skeleton when it diverges from the fill - wall elevation tests the slab's rendered footprint instead of the stored polygon with a 0.1mm epsilon, so walls sit on legacy slabs and re-elevate when slabs are reshaped Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): subcut slab edges per backing span A slab edge backed differently along its length (offset rooms sharing a wall over part of the span, a wall shorter than the edge, collinear walls of different thickness) is now subdivided at the backing-span breakpoints; each sub-span classifies and projects independently, with a perpendicular step connector at intra-edge transitions that lands inside the crossing wall's footprint. Breakpoints closer than 5cm merge so no sliver geometry reaches the ring, and same-target spans re-fuse (curved-wall sampling doesn't balloon vertex counts; whole-edge cases render bit-identically to before). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): wall elevation picks the slab that supports it, not the highest graze A wall touching a raised slab only at an endpoint no longer lifts entirely to that slab. Elevation selection is now coverage-based: per-slab support is the wall's centerline+face length covered by the slab's rendered footprint minus holes; slabs within 0.1mm of elevation pool their support (party walls spanning two rooms still lift); the wall sits on the highest elevation covering >=50% of its length, else the best-covered group (ties prefer higher), else 0. Sub-5cm grazes are ignored outright. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): interior slab edges meet exactly at the wall centerline Removes the 2cm interior relief inset: both rooms' seam edges project onto the same centerline (or the symmetric sibling midline when no wall backs the seam), so adjacent slabs tile with a shared edge — the 4cm slit under shared walls, visible at its open end on the facade, is gone. Safe against z-fighting because slab side quads are single-sided (FrontSide is enforced repo-wide for the MRT scene pass) and the coincident seam faces have opposite normals; junction step pockets grow to 5cm but remain strictly inside the intersecting wall footprints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): elevation-aware interior slab seams — close the vertical band slit With unequal room elevations, both slabs stopping at the shared wall's centerline left the lower room's half of the wall band open between the lower slab top and the wall base (the wall seats on the higher slab). Interior seams across a wall are now elevation-aware: equal elevations keep the exact centerline seam; unequal elevations project BOTH slabs to the wall face on the lower side — the higher slab runs through the band under the wall, the lower butts the same plane. Wall-less unequal seams keep the sibling midline (a visible step face is correct there). Slab elevation joins the level dependency signature so height edits rebuild sibling slabs live. Verified against real local scenes: all wall bands solid where the seam rule applies (remaining pockets are the known flat-wall-base limitation, tracked separately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugin-trees): run wind displacement before the instance transform The r184->r185 upgrade broke the plant wind: r185 fixed TSL's statement emission order so a material's positionNode now runs *after* the instancing transform (r184 emitted it before — verified in the compiled WGSL of both versions). The wind nodes were tuned against the r184 order, reading positionLocal as geometry-local coordinates, so on r185 the displacement moved into level space: sway no longer scaled with the per-instance scale (scaled-down trees thrashed like a storm, leaf cards visibly detaching from branches), leaf phase followed world placement, and STEM_BEND's height term read the floor elevation, so grass/flowers on upper levels slid around rigidly. Restore the r184 semantics explicitly: WindNodeMaterial assigns the wind node to positionLocal inside setupPosition() before super applies the instance transform, instead of using positionNode. The emitted WGSL is statement-for-statement identical to r184's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): resolve slab joins across floor elevations * fix(editor): keep slab resize arrows visible --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
160 lines
6.4 KiB
TypeScript
160 lines
6.4 KiB
TypeScript
import type { Color, Material, Side, Texture } from 'three'
|
|
import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl'
|
|
import { MeshStandardNodeMaterial, type Node, type NodeBuilder } from 'three/webgpu'
|
|
|
|
/**
|
|
* Always-on wind for the plant kinds, done in TSL so it runs on the editor's
|
|
* WebGPU renderer (which ignores WebGL's `onBeforeCompile`). Two motions:
|
|
*
|
|
* - **Leaf flutter** (`LEAF_FLUTTER`) — ez-tree's own approach: the sway scales
|
|
* with the leaf card's `uv.y`, so each leaf swings from its attachment while
|
|
* the trunk and branches stay put. A whole-tree height-based bend, by contrast,
|
|
* is a rigid rotation about the base and reads as the tree spinning in place.
|
|
* Multi-frequency for a natural gust; phased per instance + per leaf.
|
|
* - **Stem bend** (`STEM_BEND`) — for the small procedural kinds (flowers,
|
|
* grass), a gentle whole-plant lean proportional to height reads fine.
|
|
*
|
|
* ez-tree isn't touched: its generated materials are re-created as node materials
|
|
* carrying the texture/tint (`toWindMaterial`) — only the `leaves` material gets
|
|
* the flutter node; bark stays static. `time` is advanced by the renderer.
|
|
*/
|
|
|
|
// ── Leaf flutter (tree leaves) ───────────────────────────────────────────────
|
|
const LEAF_FREQUENCY = 1.2
|
|
const LEAF_STRENGTH = 0.3
|
|
|
|
const leafFlutter = Fn(() => {
|
|
const p = positionLocal.toVar()
|
|
const offset = float(instanceIndex).mul(0.7).add(p.x.add(p.z).mul(0.3))
|
|
const t = time.mul(LEAF_FREQUENCY)
|
|
const wave = sin(t.add(offset))
|
|
.mul(0.5)
|
|
.add(sin(t.mul(2).add(offset.mul(1.3))).mul(0.3))
|
|
.add(sin(t.mul(5).add(offset.mul(1.5))).mul(0.2))
|
|
const sway = uv().y.mul(LEAF_STRENGTH).mul(wave)
|
|
p.x.addAssign(sway)
|
|
p.z.addAssign(sway)
|
|
return p
|
|
})
|
|
const LEAF_FLUTTER = leafFlutter()
|
|
|
|
// ── Stem bend (flowers, grass) ───────────────────────────────────────────────
|
|
const STEM_FREQUENCY = 1.3
|
|
const STEM_STRENGTH = 0.05
|
|
|
|
const stemBend = Fn(() => {
|
|
const p = positionLocal.toVar()
|
|
const h = p.y.max(0)
|
|
const phase = float(instanceIndex).mul(0.618)
|
|
const t = time.mul(STEM_FREQUENCY).add(phase)
|
|
p.x.addAssign(h.mul(STEM_STRENGTH).mul(sin(t)))
|
|
p.z.addAssign(h.mul(STEM_STRENGTH).mul(cos(t.mul(1.1))))
|
|
return p
|
|
})
|
|
const STEM_BEND = stemBend()
|
|
|
|
/**
|
|
* Standard node material whose wind displacement runs **before** the instance
|
|
* transform. The wind nodes read `positionLocal` expecting raw geometry-local
|
|
* coordinates — leaf phase from the card's position inside its own tree, stem
|
|
* height from the plant's own base — with the per-instance scale/rotation/
|
|
* translation applied on top. three r184 happened to emit `positionNode`
|
|
* statements in exactly that order; r185 fixed the emission order so
|
|
* `positionNode` now runs *after* instancing, which put the wind in level
|
|
* space: sway stopped scaling with the instance, leaf phase followed world
|
|
* placement, and STEM_BEND's height term read the floor elevation, so plants
|
|
* on upper levels slid around rigidly. Assigning `positionLocal` inside
|
|
* `setupPosition` before `super` applies instancing restores the r184
|
|
* (geometry-space) semantics on r185.
|
|
*/
|
|
class WindNodeMaterial extends MeshStandardNodeMaterial {
|
|
windNode: Node | null = null
|
|
|
|
setupPosition(builder: NodeBuilder): Node {
|
|
if (this.windNode !== null) positionLocal.assign(this.windNode)
|
|
return super.setupPosition(builder)
|
|
}
|
|
|
|
customProgramCacheKey(): string {
|
|
return `${super.customProgramCacheKey()}|wind:${this.windNode ? this.windNode.id : 'none'}`
|
|
}
|
|
}
|
|
|
|
/** The classic-material fields we carry over — enough to reproduce ez-tree's
|
|
* bark/leaf look (textured, tinted, alpha-cut billboards). */
|
|
type ClassicMaterial = Material & {
|
|
map?: Texture | null
|
|
alphaMap?: Texture | null
|
|
color?: Color
|
|
side?: Side
|
|
alphaTest?: number
|
|
opacity?: number
|
|
transparent?: boolean
|
|
depthWrite?: boolean
|
|
}
|
|
|
|
const cache = new WeakMap<Material, MeshStandardNodeMaterial>()
|
|
|
|
/** Re-create a generated (ez-tree) material as a `MeshStandardNodeMaterial`,
|
|
* transferring its texture/tint explicitly (node materials don't pick these up
|
|
* via `Material.copy()`). Only the `leaves` material flutters; bark stays static.
|
|
* Cached per source so shared variant materials convert once. */
|
|
export function toWindMaterial(material: Material): MeshStandardNodeMaterial {
|
|
const cached = cache.get(material)
|
|
if (cached) return cached
|
|
const src = material as ClassicMaterial
|
|
const node = new WindNodeMaterial({
|
|
map: src.map ?? null,
|
|
alphaMap: src.alphaMap ?? null,
|
|
color: src.color,
|
|
side: src.side,
|
|
alphaTest: src.alphaTest ?? 0,
|
|
transparent: src.transparent ?? false,
|
|
opacity: src.opacity ?? 1,
|
|
depthWrite: src.depthWrite ?? true,
|
|
roughness: 1,
|
|
metalness: 0,
|
|
})
|
|
if (material.name === 'leaves') node.windNode = LEAF_FLUTTER
|
|
cache.set(material, node)
|
|
return node
|
|
}
|
|
|
|
/** Build a swaying node material for the procedural kinds (flowers/grass). */
|
|
export function windStandardMaterial(
|
|
params: ConstructorParameters<typeof MeshStandardNodeMaterial>[0],
|
|
): MeshStandardNodeMaterial {
|
|
const material = new WindNodeMaterial(params)
|
|
material.windNode = STEM_BEND
|
|
return material
|
|
}
|
|
|
|
const staticCache = new WeakMap<Material, Material>()
|
|
|
|
/** Windless twin of a wind material — same look, no wind node. The outline
|
|
* mask pass renders outlined meshes with a shared override material, so an
|
|
* outline can never follow the GPU sway; the selection proxy renders this twin
|
|
* instead, so the outlined silhouette and the visible mesh match exactly (the
|
|
* plant simply holds still while hovered/selected). Built by explicit property
|
|
* transfer, not `.clone()` — node-material clone drops `map`/`color` (same
|
|
* pitfall as `toWindMaterial`). Cached per source. */
|
|
export function toStaticMaterial(material: Material): Material {
|
|
const cached = staticCache.get(material)
|
|
if (cached) return cached
|
|
const src = material as MeshStandardNodeMaterial
|
|
const twin = new MeshStandardNodeMaterial({
|
|
map: src.map ?? null,
|
|
alphaMap: src.alphaMap ?? null,
|
|
color: src.color,
|
|
side: src.side,
|
|
alphaTest: src.alphaTest ?? 0,
|
|
transparent: src.transparent ?? false,
|
|
opacity: src.opacity ?? 1,
|
|
depthWrite: src.depthWrite ?? true,
|
|
roughness: src.roughness,
|
|
metalness: src.metalness,
|
|
})
|
|
staticCache.set(material, twin)
|
|
return twin
|
|
}
|