Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions thermion_dart/hook/build.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ outputDirectory : ${outputDirectory.path}
sources = sources.where((p) => !p.contains("linux")).toList();
}

// iOS is Metal-only — exclude Vulkan-utility sources whose symbols
// resolve through `bluevk` (which iOS does not link). Without this
// exclusion, linking fails with "Undefined symbols: bluevk::vk*".
// See native/src/vulkan/{VulkanUtils,BaseVulkanTexture}.cpp.
if (targetOS == OS.iOS) {
sources = sources.where((p) => !p.contains("vulkan")).toList();
}

// Material source paths (used by _processMaterials below)
final materialSources = <String, String>{
'capture_uv': 'native/include/material/capture_uv.c',
Expand All @@ -110,7 +118,11 @@ outputDirectory : ${outputDirectory.path}
'edge_outline': 'native/include/material/edge_outline.c',
'wireframe': 'native/include/material/wireframe.c',
'translation_axis': 'native/include/material/translation_axis.c',
'gizmo': 'native/include/material/gizmo.c',
// Renamed from gizmo.c to avoid a case-insensitive .obj collision
// with scene/Gizmo.cpp on Windows (both produced gizmo.obj, the
// material write-clobbered the class .obj, and the linker reported
// four LNK2019s for thermion::Gizmo::{Gizmo,pick,highlight,unhighlight}).
'gizmo': 'native/include/material/gizmo_material.c',
'bone_overlay': 'native/include/material/bone_overlay.c',
};

Expand Down Expand Up @@ -252,7 +264,11 @@ outputDirectory : ${outputDirectory.path}
"/std:c++20",
if (buildMode == BuildMode.debug) ...["/MDd", "/Zi"],
if (buildMode == BuildMode.release) "/MD",
"/VERBOSE",
// /VERBOSE is a linker option, not a compiler one — cl.exe parses it
// as the deprecated /V<string> and emits warning D9035. If the
// verbose link map is ever needed for diagnostics, pass it after
// native_toolchain_c's own /link separator (see libraryDirectories
// / linkerOptions paths in run_cbuilder.dart).
...defines.keys.map((k) => "/D$k=${defines[k]}").toList(),
]);
}
Expand Down Expand Up @@ -418,10 +434,17 @@ outputDirectory : ${outputDirectory.path}
if (platform == "windows") ...[
...includeDirs.map((d) => "/I${path.join(pkgRootFilePath, d)}"),
"@${srcs.uri.toFilePath(windows: true)}",
// ...sources,
// '/link',
// "/LIBPATH:$libDir",
// '/DLL',
// Library inputs (filament.lib, backend.lib, bluevk.lib, etc.)
// are declared via #pragma comment(lib, ...) directives in
// native/include/ThermionWin32.h, which is transitively included
// by the c_api headers and the Windows vulkan/d3d sources. The
// linker only needs to know WHERE to find those .lib files —
// that is wired via `libraryDirectories: [libDir]` below, which
// native_toolchain_c emits after its own /link separator
// (run_cbuilder.dart). Adding a second /link here puts cl.exe's
// auto-generated /LD and /Fe: AFTER our separator, where LINK
// ignores them as LNK4044 — the resulting binary has no /DLL
// and no entry point, failing with LNK1561.
],
],
libraryDirectories: [libDir],
Expand Down
20 changes: 20 additions & 0 deletions thermion_dart/hook/link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ void main(List<String> args) async {
var pkgRootFilePath = packageRoot.toFilePath(windows: Platform.isWindows);
final logger = createLogger(pkgRootFilePath, "link.log");

// The CLinker.library(... LinkerOptions.manual(...)) call below
// delegates to native_toolchain_c.runCl on Windows, which builds a
// cl.exe command line from the constructor's `sources` list. Our
// call passes no sources (the build hook already produced
// thermion_dart.dll), so cl.exe is invoked with only flags and exits
// immediately with `cl : Command line error D8003: missing source
// filename`. The link phase is optional here — pass the build
// hook's code assets through unchanged on Windows. Keep the
// CLinker call on platforms where it currently works.
if (input.config.code.targetOS == OS.windows) {
for (final asset in input.assets.code) {
output.assets.code.add(asset);
}
logger.info(
"Link step skipped on Windows; passed through "
"${input.assets.code.length} code asset(s).",
);
return;
}

final clinker = CLinker.library(
name: "thermion_dart",
linkerOptions: LinkerOptions.manual(stripDebug: false));
Expand Down
24 changes: 20 additions & 4 deletions thermion_dart/hook/log.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,25 @@ Logger createLogger(String packageRoot, String logFilename) {

final logger = Logger("")
..level = Level.ALL
..onRecord.listen((record) => logFile.writeAsStringSync(
record.message + "\n",
mode: FileMode.append,
flush: true));
..onRecord.listen((record) {
logFile.writeAsStringSync(
record.message + "\n",
mode: FileMode.append,
flush: true);
// Tee SEVERE records to stderr so subprocess errors (cl.exe,
// clang, ld) actually surface to whoever's watching the
// build. `native_toolchain_c.runProcess` routes captured
// subprocess stderr through `logger.severe`, but on a
// failure it then throws a `ProcessException` whose message
// is just the command + exit code — the real compiler /
// linker output stays in the build.log file inside the pub
// cache where CI never sees it. Mirroring SEVERE to stderr
// makes the actual error visible without affecting
// successful-build noise (compilers don't emit much stderr
// on success).
if (record.level >= Level.SEVERE) {
stderr.writeln(record.message);
}
});
return logger;
}
Original file line number Diff line number Diff line change
Expand Up @@ -322,47 +322,132 @@ class _MobileListenerWidget extends StatefulWidget {
}

class _MobileListenerWidgetState extends State<_MobileListenerWidget> {
// Tap/double-tap detection state. We synthesize taps from the eager
// scale recognizer's start/update/end callbacks because the eager
// recognizer claims the gesture arena on PointerDown — separate
// TapGestureRecognizer / DoubleTapGestureRecognizer entries can no
// longer win. See `_EagerScaleGestureRecognizer` below.
Offset? _scaleStartFocal;
DateTime? _scaleStartTime;
double _scaleMaxMovement = 0;
Offset? _lastTapPosition;
DateTime? _lastTapTime;

static const _kTapMaxMovement = 8.0;
static const _kTapMaxDuration = Duration(milliseconds: 250);
static const _kDoubleTapInterval = Duration(milliseconds: 300);

@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (details) {
widget.inputHandler.handle(TouchEvent(TouchEventType.tap,
details.localPosition.toVector2() * widget.pixelRatio, null));
},
onDoubleTap: () {
widget.inputHandler
.handle(TouchEvent(TouchEventType.doubleTap, null, null));
},
onScaleStart: (ScaleStartDetails event) async {
widget.inputHandler.handle(ScaleStartEvent(
numPointers: event.pointerCount,
localFocalPoint: (
event.focalPoint.dx * widget.pixelRatio,
event.focalPoint.dy * widget.pixelRatio
)));
},
onScaleUpdate: (ScaleUpdateDetails event) async {
widget.inputHandler.handle(ScaleUpdateEvent(
numPointers: event.pointerCount,
localFocalPoint: (
event.focalPoint.dx * widget.pixelRatio,
event.focalPoint.dy * widget.pixelRatio
),
localFocalPointDelta: (
event.focalPointDelta.dx * widget.pixelRatio,
event.focalPointDelta.dy * widget.pixelRatio
),
rotation: event.rotation,
horizontalScale: event.horizontalScale,
verticalScale: event.verticalScale,
scale: event.scale,
));
},
onScaleEnd: (details) async {
widget.inputHandler
.handle(ScaleEndEvent(numPointers: details.pointerCount));
},
child: widget.child);
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
_EagerScaleGestureRecognizer:
GestureRecognizerFactoryWithHandlers<_EagerScaleGestureRecognizer>(
() => _EagerScaleGestureRecognizer(),
(_EagerScaleGestureRecognizer instance) {
instance
..onStart = (ScaleStartDetails event) {
_scaleStartFocal = event.focalPoint;
_scaleStartTime = DateTime.now();
_scaleMaxMovement = 0;
widget.inputHandler.handle(ScaleStartEvent(
numPointers: event.pointerCount,
localFocalPoint: (
event.focalPoint.dx * widget.pixelRatio,
event.focalPoint.dy * widget.pixelRatio
)));
}
..onUpdate = (ScaleUpdateDetails event) {
if (_scaleStartFocal != null) {
final movement =
(event.focalPoint - _scaleStartFocal!).distance;
if (movement > _scaleMaxMovement) {
_scaleMaxMovement = movement;
}
}
widget.inputHandler.handle(ScaleUpdateEvent(
numPointers: event.pointerCount,
localFocalPoint: (
event.focalPoint.dx * widget.pixelRatio,
event.focalPoint.dy * widget.pixelRatio
),
localFocalPointDelta: (
event.focalPointDelta.dx * widget.pixelRatio,
event.focalPointDelta.dy * widget.pixelRatio
),
rotation: event.rotation,
horizontalScale: event.horizontalScale,
verticalScale: event.verticalScale,
scale: event.scale,
));
}
..onEnd = (ScaleEndDetails event) {
final now = DateTime.now();
final wasTap = _scaleStartTime != null &&
_scaleStartFocal != null &&
now.difference(_scaleStartTime!) < _kTapMaxDuration &&
_scaleMaxMovement < _kTapMaxMovement &&
event.pointerCount == 0;
if (wasTap) {
final tapPosition = _scaleStartFocal!;
final isDoubleTap = _lastTapPosition != null &&
_lastTapTime != null &&
now.difference(_lastTapTime!) < _kDoubleTapInterval &&
(tapPosition - _lastTapPosition!).distance <
_kTapMaxMovement;
if (isDoubleTap) {
widget.inputHandler.handle(
TouchEvent(TouchEventType.doubleTap, null, null));
_lastTapPosition = null;
_lastTapTime = null;
} else {
widget.inputHandler.handle(TouchEvent(
TouchEventType.tap,
tapPosition.toVector2() * widget.pixelRatio,
null));
_lastTapPosition = tapPosition;
_lastTapTime = now;
}
}
widget.inputHandler
.handle(ScaleEndEvent(numPointers: event.pointerCount));
_scaleStartFocal = null;
_scaleStartTime = null;
_scaleMaxMovement = 0;
};
},
),
},
child: widget.child,
);
}
}

/// Variant of [ScaleGestureRecognizer] that claims the gesture arena
/// the moment a pointer is added, instead of waiting for displacement
/// to cross [kPanSlop].
///
/// Why: the default recognizer waits to disambiguate a one-finger pan
/// from a two-finger pinch. While it waits, an ancestor `Scrollable`'s
/// `VerticalDragGestureRecognizer` reaches its acceptance threshold
/// first and wins the arena — the touch is interpreted as a page
/// scroll instead of a viewport gesture. With this eager subclass,
/// ancestors lose the arena on the first PointerDown inside the
/// Thermion view, so single-finger orbit and pinch-zoom both resolve
/// to the viewport regardless of what scrollable wraps it.
///
/// Tradeoff: separate `TapGestureRecognizer` /
/// `DoubleTapGestureRecognizer` entries can no longer participate
/// (eager scale wins arena before they ever accept). The mobile
/// listener compensates by synthesizing tap and double-tap events
/// from the scale callbacks — a "tap" is a scale gesture whose total
/// focal-point movement stays below 8px and whose total duration
/// stays below 250 ms; "double-tap" is two such taps within 300 ms.
class _EagerScaleGestureRecognizer extends ScaleGestureRecognizer {
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
resolve(GestureDisposition.accepted);
}
}
50 changes: 41 additions & 9 deletions thermion_flutter/thermion_flutter/windows/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,50 @@ target_compile_features(${PLUGIN_NAME} PUBLIC cxx_std_20)
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)

set(GENERATED_HEADERS_CMAKE "${CMAKE_CURRENT_SOURCE_DIR}/../.dart_tool/generated_headers.cmake")
include("${GENERATED_HEADERS_CMAKE}")

target_include_directories(${PLUGIN_NAME} INTERFACE
# `generated_headers.cmake` is produced by `thermion_flutter`'s build hook
# (`hook/build.dart`) during `flutter assemble dart_build`, which runs as a
# custom build step inside MSBuild AFTER cmake config completes. On a fresh
# checkout the file does not yet exist, so an unconditional `include()`
# would error out (`include could not find requested file`) and abort cmake
# — preventing the dart_build step from ever running and leaving a fresh
# `flutter run` / `flutter build windows` permanently stuck unless the user
# knows to fire the hook out of band first (e.g. via `flutter test`).
#
# Include conditionally and register the file as a configure dependency so
# that, once dart_build produces it, the next build invocation
# auto-regenerates cmake and the plugin compile picks up the correct
# `DART_PKG_HEADERS`. On a clean checkout this means a two-pass build:
# first pass populates the file (plugin compile fails because
# DART_PKG_HEADERS is empty), second pass succeeds.
if(EXISTS "${GENERATED_HEADERS_CMAKE}")
include("${GENERATED_HEADERS_CMAKE}")
else()
message(STATUS
"thermion_flutter: ${GENERATED_HEADERS_CMAKE} not yet present. "
"Cmake will continue with an empty DART_PKG_HEADERS so that "
"`flutter assemble dart_build` can run and produce the file; "
"the next build will pick it up automatically.")
set(DART_PKG_HEADERS "")
endif()
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${GENERATED_HEADERS_CMAKE}")

# PUBLIC scope: thermion_flutter_plugin.cpp itself needs the Filament
# headers (Platform.h transitively pulls in `utils::io::ostream`, etc.)
# AND any consumer that includes thermion_flutter_plugin.h needs them
# too. The previous INTERFACE-only scope hid the headers from this
# target's own compile, so `error C3083: 'io': the symbol to the left
# of a '::' must be a type` triggered on Platform.h:99.
#
# The second `include_directories(${PLUGIN_NAME} INTERFACE ...)` call
# below is also invalid CMake syntax — `include_directories` takes
# only paths, no target or scope keyword — so it silently does
# nothing. Removed.
target_include_directories(${PLUGIN_NAME} PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}"
"${DART_PKG_HEADERS}"
)

include_directories(${PLUGIN_NAME} INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}"
"${DART_PKG_HEADERS}"
${DART_PKG_HEADERS}
)

get_cmake_property(_variableNames VARIABLES)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@

#include "windows/import.h"

// Filament's `backend/Platform.h` (pulled in transitively by
// `WindowsVulkanContext.h` below) friend-declares
// `utils::io::ostream& operator<<(...)` without first declaring the
// nested namespace itself. Sibling Filament headers like
// `backend/DriverEnums.h` carry the forward declaration so the rest
// of Filament compiles cleanly, but this plugin's translation unit
// reaches `Platform.h` before any of them, leaving the namespace
// undeclared and cl.exe rejecting the friend with
// C3083 / C2039 / C4430. Mirror Filament's own forward declaration
// here so the friend resolves.
namespace utils::io {
class ostream;
} // namespace utils::io

#include "vulkan/windows/WindowsVulkanContext.h"

namespace thermion::tflutter::windows {
Expand Down
Loading